A new audio setting, on by default: the normalization boost is capped so the
track's known peak stays 1 dB under full scale. Store column + migration,
portable in the settings bundle, shown in diagnostics, and settable from
qbzd (settings get/set audio.normalization_prevent_clipping).
cc
The public README never mentioned that the control API binds 0.0.0.0 by
default nor how to restrict it; the boot line also told users who had set
[server] token that anyone could control playback.
cc
open_log_file_at renamed the live file to .prev on every open, so a second
qbz launch (the #749 two-line qbz.log) or a qbzd in the same directory took
the running process's log away. Writers now hold a shared advisory lock on
<name>.lock; an opener that cannot take it exclusively appends instead of
rotating. The daemon gets its own qbzd.log.
cc
Add shared playback memory profiles, bounded disk-backed playback and storage
selection for desktop and daemon. Preserve gapless during streaming-only playback
and provide explicit cache eviction with playback confirmation.
Stop watcher read events and unchanged scans from rebuilding the local catalog.
Restore playlist/sidebar updates, folder availability feedback and player layout.
Add appearance presets and a radio-based cache settings panel with translated
trade-offs, plus consistent clipboard menus across visible Qt text editors.
Validate with Rust regressions, QML interaction tests, isolated release startup
and idle-library checks; extend the shared local and CI gates.
cx
Extract the HTTP host and catalog layer into qbz-control, preserve the
existing daemon API, and keep private session credentials out of SSE.
Scope library stores and scan workers to their host. Share bounded source
search and show Local results alongside Qobuz in the Qt search page.
Add the launch-gated Qt Orbit laboratory and persistent remote-context
banner. Let qbzd run --orbit own and administer its file library through
instance-scoped API and CLI operations. Full remote UI control remains
pending.
Validate library/control/daemon suites, Qt search and QML regressions,
two isolated daemon processes, Qt release build and final cargo check.
cx
The TUI cascade cleared `exclusive_mode` on any non-ALSA backend. On macOS
System default honours it through CoreAudio Hog Mode, so mirror the Qt
shell's cascade (#748): the flag survives onto ALSA and, on macOS, System
default; every other backend still resets it. The platform is a parameter so
the macOS arm runs on the Linux gate.
cc
Keep queue edits from resetting the playback clock, preserve Play next and Play later batches, and report observed renderer state and owned output telemetry. Guard duplicate-track gapless transitions by queue occurrence.
cx
The packaged AppDir has an intermittent SIGSEGV during startup, in the
window between QGuiApplication construction and the QML engine coming up,
with the tokio workers, the audio thread, the loudness analyzer, the
visualizer producer and connectivity all running beside it. It hit twice
on 2026-09-08 on unrelated commits: the v2.1.0 tag's own release-flatpak
attempt 1, which then passed on a re-run of the identical commit, and the
#745 bugfix branch. Both left the same evidence - "status -11" and a log
that stops mid-startup - which is nothing to act on, and a user who hits
this has no re-run to reach for.
The process now reports its own death. A handler for SIGSEGV, SIGBUS,
SIGILL, SIGFPE and SIGABRT writes the signal, the faulting address, the
thread and a backtrace to stderr and flushes the file sink, so the next
occurrence names a frame instead of costing another round trip. It runs
on a dedicated signal stack so a stack-overflow fault can still report,
latches against re-entry, and arms alarm() BEFORE capturing anything:
taking a backtrace allocates and takes loader locks, so a fault inside
malloc could otherwise deadlock the handler and turn a crash into a hang.
The signal is re-raised with the default disposition restored, so the
wait status stays the signal and CI, the smoke harness and any core dump
still see a crash. The integration test asserts exactly that pair on a
child segfaulted on purpose, and it is a named gate in test-crates and
its local mirror.
qbzd installs it too: a headless daemon that dies of a signal leaves even
less behind than the GUI.
cc
Correct dead platform declarations, stale rustdoc and test warnings; validate complete gettext headers. Install LLD in the Qt job and explicitly validate committed shader packs without rebaking them. Keep the independent shader bake gate and add three build-policy tests.
The full local gate passed 2398 workspace tests and 715 Qt tests, QML audits, shader baking and the isolated offscreen smoke without compiler, rustdoc or gettext warnings.
cx
Initialize the four new optional AlbumSummary fields in all six test fixtures. Preserve absent metadata semantics and restore workspace test compilation.
cx
Group identical redacted diagnostics across stderr, file and the live ring. Flush repeat summaries on visible transitions, exports and shutdown while keeping periodic counts for sustained bursts.
cx
The artist page's "In library" count was a snapshot of the Library feed
taken when the document was built, over a feed that only loads when the
Library view is opened and that never gains a row for a favourite created
afterwards. On a cold session, or after an account switch, every artist
opened with an invisible toggle; hearting an album on the page moved
nothing until the next session.
- library_qt: `artist_library_items` computes the tab's rows with the
Library > Albums/Tracks rule (favourites ∪ purchases, one row per
entity, purchase row representative, `is_favorite` = also hearted);
local/Plex and `following` rows never enter. A track is a member
through its performer OR its album's main artist (new
`FeedItem::album_artist_id`, fed by the new `AlbumSummary::artist`,
which the wire carries — search-results capture).
- library_qt: `reconcile_qobuz_favorite` inserts the row of a freshly
hearted track/album (one `/track/get` or `/album/get`, mapped by the
same producers the full load uses) and drops the `favorites` rows of a
cleared one, keeping a purchase row with its heart cleared.
- artist_qt: the rows ride inside the artist document (`libraryItems`),
and `refresh_library_items` / `apply_favorite_change` republish them
through `publish_patch` when the feed lands or a heart settles. A
debug-level log prints the membership split for the open artist.
- main: `settle_favorite` is the one settle point (reconcile, fan out,
republish on membership change) for the Library and queue-panel
hearts; `open_artist` warms the feed in the background; `reload_library`
hands the open page its rows; `warm_library` serves the toggle from
cache.
Tests: membership by performer or album artist, exclusion of local /
following, dedup on the purchase row, and the `top_queue` contract that
a foreign id starts the popular list at row 0 (the #737 defect class).
cc
Add random access for DSF, DFF, and SACD demuxers, replace live DoP/native sources without releasing ALSA, and expose seek through Qt, MPRIS, and the daemon API.
cx
Qobuz answers `/user/login` with 200 for an account without an active
subscription and the web player signs it in as a "Qobuz member" with its
favorites, playlists, purchases and 30-second previews. QBZ threw that
session away: `parse_login_response` returned `IneligibleUser` whenever
`credential.parameters` was empty (verified 2026-09-02 with a lapsed
account). The gate was ours, not Qobuz's.
The parser now reads the entitlement flags (`lossy/lossless/hires/
hires_purchases/offline/mobile_streaming`) into a typed `Entitlements`
on `UserSession`, labels a member "Member", and keeps `subscription.
end_date`/`offer` as information (the embedded Qobuz fixture has a past
end_date with every flag true: the flags are the verdict, the date is
not). `StreamUrl` gains `sample`, the flag Qobuz sets when it serves a
preview instead of the track. `IneligibleUser` stays in the enum for its
existing matchers; nothing emits it from this path any more. Persisted
pre-member sessions still load (`serde(default)`).
cc
Preserve local playback when it is authoritative, make renderer conflicts explicit and persist the selected policy. Filter unsupported queue rows, restore portable Qobuz identities after disconnect, and fence takeover commands to avoid stale queue and playback loops.
cx
Carry hardware-volume capability, selection, diagnostics, and status through the daemon API, CLI, and TUI while preserving fail-closed behavior on unsupported devices.
cx
rusqlite 0.40 removed ToSql/FromSql for u64 (SQLite integers are i64):
every u64 column now reads/writes through an explicit i64 cast. It also
rejects a multi-statement execute() instead of silently running only the
first statement — which is what catalog.rs's editions/logical_albums
prune had been doing all along; it is an execute_batch now, so the
logical_albums prune finally runs.
cc
A CoreEvent-bus sibling of scrobble_engine WITHOUT its enabled gate: the
log is local and on by default, so a headless streamer finally records
history. TrackStarted opens a row and closes the previous one with an
inferred reason (natural within 2 s of the end, skip otherwise — nobody
emits TrackEnded); PositionUpdated feeds the accumulator; Stopped closes
with the same inference. origin_id = qbzd:<hostname>; the shutdown path
aborts the task and closes the open row.
cc
The boot comment claimed suggestions work un-cached without the store;
suggestions.rs and builder.rs both answer "No active session" when it
is None. Warn on failure and say what the symptom will be.
cc
Windows media keys never worked because SMTC never started. souvlaki's Windows
backend does `config.hwnd.expect(..)`, which PANICS rather than degrading, and
`MediaControls::new` runs inside a tokio task, so a missing handle aborted the
task instead of returning the Err the caller was written for. Phase A bowed out
before reaching it and logged "SMTC disabled: no window handle wired yet".
`spawn` now takes a `NativeWindow`, and `cxx/win_shell.cpp` supplies the
top-level HWND through `QGuiApplication::topLevelWindows()`. The guard stays:
a missing integration is a missing feature, a panic in a tokio task is a dead
session.
Windows reads the handle from a bounded retry on the GUI thread. Both things it
waits for -- the tray bridge's Qt thread and a shown window -- are "not yet",
never "not ever": `tray_bridge::ui` is a no-op until `QbzTray::boot` registers
the thread, and `QbzSession.boot()` runs first (Main.qml:254 vs :380).
NON-WINDOWS IS UNCHANGED, deliberately. An earlier draft routed every platform
through that hop and it was a Linux/macOS regression three ways: initialization
could be dropped outright before boot, MPRIS would start only when the Qt queue
drained (losing the first metadata push), and `main_window_hwnd` calls
`QWindow::winId()`, which CREATES the platform window as a side effect on
platforms with no reason to.
Cover art needed its own fix. souvlaki 0.8.3 branches on
`url.starts_with("file://")`, then trims exactly that prefix and hands the rest
to `GetFileFromPathAsync`. So `file:///C:/x.jpg` arrives as `/C:/x.jpg` -- a
slash before a drive letter, which never opens -- while a bare `C:\x.jpg` no
longer matches the prefix and takes the remote-URI branch instead. The value
that works is the hybrid `file://C:\x.jpg`. UNC is handled too: `file://nas/m/a`
is a real Windows path and was being passed through as the relative `nas/m/a`.
The percent-decode is full and applied once: `art_url` arrives from
`url::Url::from_file_path` (space as %20) and from `fs_url::file_url` (only
% # ? escaped), and one full decode reads both -- `%2520` gives back a literal
`%20` rather than a space. `fs_url::local_path` is deliberately not used here;
its three-escape decode would leave the other producer's %20 intact.
Eight tests cover the matrix. Reviewed adversarially before commit, which is
where the inverted cover conversion and the non-Windows regression were caught;
the souvlaki branch was then read at the source to confirm.
cc
Three copies read HOSTNAME and then /etc/hostname. Windows has neither, so
every Windows install fell through to the same last-resort word and they
collided on Qobuz Connect. One implementation in qbz-app; HOSTNAME keeps
precedence so Linux behaviour does not move.
qbzd/src/qconnect/transport.rs was a fourth copy the plan did not list, and
the settings_qt.rs one it did list no longer exists on this branch. Both
callers keep their own last-resort word - "Desktop" here, and qbzd's own -
because they are user-visible and mean different things.
qbz-credentials/src/lib.rs:273 also reads HOSTNAME and is deliberately NOT
touched: it is a salt in the KDF fallback chain, not a device label, so
adding COMPUTERNAME there would change the derived key on Windows and make
every already-encrypted credential file undecryptable. That is W17.
cc
lock.rs imported std::os::unix and called libc::flock unconditionally, so
a workspace test run could not compile on Windows.
The Windows arm locks ONE byte at offset 1 GiB rather than the whole file.
Byte-range locks are mandatory there, not advisory like flock: measured on
this box, a whole-file lock from offset 0 makes the losing instance's
read_to_string fail with ERROR_LOCK_VIOLATION, so AlreadyRunning would
always carry None - the pid is the only reason that variant exists. With
the lock byte past any content the pid still reads back, the same handle
conflict is still detected, and the lock still releases when the File
drops. Reserving a region past EOF is what SQLite's Windows VFS does.
Only ERROR_LOCK_VIOLATION means contention; a filesystem that does not
implement byte-range locks answers ERROR_INVALID_FUNCTION and must surface
as Io, not as a daemon that is not running. The unix arm keeps its
existing any-failure-means-busy behaviour unchanged.
cc
The bridge's track-change dedup kept the last track id across a stop,
so stop -> play of the same track produced only PlaybackStateChanged
and consumers treating TrackStarted as now-playing (scrobbler, hooks)
missed the replay. A stop ends the current track: reset the tracker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A pause -> stop transition never emitted PlaybackStateChanged: the
bridge deduped on the raw is_playing boolean, and paused and stopped
are both not-playing. Track the mapped Playing/Paused/Stopped state
instead. Found by driving the hook end-to-end on a Raspberry Pi.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds an event-hook facility for headless integrators (moOde, Volumio,
DIY audio boxes): 'qbzd settings set hooks.script /path/to/script' (or
the QBZD_HOOK env var) names an executable the daemon forks once per
daemon event with the event described in QBZ_* environment variables --
the same push-integration contract pleezer (--hook) and shairport-sync
(run_this_...) established, so a box can stop its local player when a
Qobuz Connect session starts and restore state when it ends, without
keeping a poller or SSE client alive.
Getting there required closing a gap on the CoreEvent bus: the playback
variants (TrackStarted, PlaybackStateChanged, PositionUpdated,
VolumeChanged) were defined and consumed -- mpris.rs keeps its widget
fresh from them, scrobble_engine.rs times scrobbles by them -- but
nothing ever emitted them, so those consumer arms were dead and
/api/events (SSE) / 'qbzd watch' carried no playback traffic. A new
events_bridge task wakes on the driver's existing ReportEdge pulse
(plus a 2 s poll fallback for track-id-clearing stops), dedups against
what it last published, and sends the deltas to the bus. This revives
live MPRIS updates and scrobble timing as a side effect.
Also emitted now: QconnectSessionChanged (new CoreEvent variant) from
the qconnect lifecycle latches -- off/connecting/connected/retrying/
exhausted -- and PlaybackError from the driver's stream-error latch, so
integrators can react to a busy audio device at play time.
The hook dispatcher never waits for the script inside the recv loop
(each event forks detached, a reaper task collects the exit status) and
the LoggedIn session payload (auth token) is never forwarded to child
environments. hooks.script validates as absolute-or-empty; the setting
lives in daemon_prefs.json and is deliberately NOT part of the settings
bundle (a machine-local path).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`#[serde(default)]` on a bare bool means an absent key deserializes to FALSE,
so a catalog Track from any endpoint that omits `streamable` came back marked
unavailable. The models' own tests document this as a hazard rather than a bug
(the "streamable split": PurchaseTrack defaults true, catalog Track defaults
false, and the comment warns that a wrong default "inverts that whole screen's
behaviour"). The inferred OpenAPI disagrees with the code and documents
`default: true`.
That was survivable only because nothing read the field. It stops being
survivable the moment a frontend greys rows out and keeps them out of the
queue: one terse endpoint would blank an entire view, silently.
`None` now means the endpoint made no claim, and only `Some(false)` marks a
track pulled. `is_streamable()` is the ONE place absence is interpreted, and it
resolves to available: refusing to play a track because a response was terse is
strictly worse than playing one that turns out to be dead, which the reactive
skip path already handles. Album.streamable was already Option<bool> with a doc
comment naming the same causes, so this makes Track consistent rather than
novel.
Every consumer in the workspace moves to the helper: both matchers, the ISRC
validator, the daemon driver, the purchases service (which maps catalog Track
into PurchaseTrack and therefore feeds the very screen the split protects), the
radio pool, the mixtape resolver and the QConnect renderer. The replaced tests
pin both directions of the new contract.
Also here, because it lives in the same file as the matcher's streamable skip:
`rank_candidates`, an additive public entry point over the existing weighted
matcher (ISRC short-circuit, title/artist/album weights, duration bonus,
stop-word normalisation, quality tie-break). `match_tracks` is untouched.
cc
The system_capabilities profiler (Normal vs LowMemory, built for #331)
lost all its consumers in the Tauri removal; 2.0.2 shipped it as dead
code and qbzd got OOM-killed (exit 137) on 1 GB Pi hosts at track
transitions. Phase A wiring:
- Frontends read memory_profile() at startup and push it down via the
new player memory_tuning (initial-buffer cap, L1 budget, low-memory
gate) before the Player is created.
- L1 audio cache sized from the profile (400 MB Normal / 50 MB
LowMemory; was hardcoded 400 MB).
- Oversized tracks (>L1/4 = 12.5 MB on LowMemory) skip the promotion
clone and the parallel CMAF RAM accumulator entirely; the finished
track is streamed to the L2 disk cache in 1 MB chunks straight from
the playback buffer — no second in-RAM copy. Gapless degrades to a
small gap for those tracks (a gap beats an OOM kill).
- Stream buffer preallocates total_size up front (try_reserve_exact,
1 GiB cap, dynamic fallback) — no more ~8 doubling reallocs + memcpy
per track (weak-CPU relief).
- New memory-pressure watchdog (15 s): critical pressure evicts the
L1 (L2 disk kept) and halts prefetch; low pressure pauses HiRes
prefetch (downgrades to Lossless). Spawned by both frontends; qbzd
aborts+joins it for the #521 shutdown ordering.
- Prefetch honors the profile (depth cap, HiRes->Lossless downgrade).
Normal-profile behavior is byte-identical (every behavioral change is
gated on the LowMemory class; profile values equal the old hardcodes).
Phase B (follow-ups): tempfile spill of the live stream buffer for
giant tracks, POLLERR flood rate-limit, desktop kick_prefetch halt
check, L2-read on the play_track cache-hit path.
km
playerctl scripts/widgets can now identify the exact song:
https://open.qobuz.com/track/<id> on every Qobuz catalog row (offline
qobuz_download keeps the real id, so it qualifies too). Local/Plex rows
omit the field — QueueTrack carries no file path to map to file:// and
a synthetic local id would mint a bogus Qobuz link. Wired in both the
app (refresh_now_playing_meta) and qbzd (track_meta).
km
The rustls crypto provider was not installed for `qbzd`, which caused the
process to panic:
> thread 'tokio-rt-worker' (15737) panicked at [...]/rustls-0.23.37/src/crypto/mod.rs:249:14:
>
> Could not automatically determine the process-level CryptoProvider from
> Rustls crate features.
> Call CryptoProvider::install_default() before this point to select a
> provider manually, or make sure exactly one of the 'aws-lc-rs' and 'ring'
> features is enabled.
> See the documentation of the CryptoProvider type for more information.
The function `ensure_crypto_provider()` was defined in the `qbz-cast` crate
which is obviously not a dependency of `qbzd`.
This commit moves `ensure_crypto_provider()` to `qbz-app`, calls it from `qbzd`
and updates `qbz` accordingly.
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
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
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