MediaServer gains a RangeSource entry whose bytes may still be arriving
(known final size, blocking reads) and serves every request on its own
thread so a slow media body no longer stalls the HEAD probe or a seek's
second Range request. Chromecast: play/pause/stop never launch the
Default Media Receiver (an app launched with nothing loaded sat IDLE and
read as "track ended"); disconnect stops the receiver app instead of
leaving it on screen with the last item.
cc
Carry hardware-volume capability, selection, diagnostics, and status through the daemon API, CLI, and TUI while preserving fail-closed behavior on unsupported devices.
cx
Add capability-aware enablement, exact control selection, diagnostics, live volume synchronization, and translated unsupported-state feedback to the Qt settings surface.
cx
Probe exact mixer element identities, retain stable device routes, and fail closed for ambiguous or switch-only controls. Synchronize player volume through ALSA events without changing direct PCM configuration.
cx
`SleepInhibitor::acquire` runs on every Playing state update and retried
the `Inhibit` call each time it failed. On a headless daemon with no
logind session polkit answers `InteractiveAuthorizationRequired` forever,
which produced one WARN every ~2 s (369 lines in 11 min on the Pi smoke,
2026-08-29). Classify the failure: a policy denial is logged once at info
and never retried for this process; a transient error backs off 5 min.
Playback was never affected.
cc
The daemon only prefetched the next track after a gapped advance, so every
edge after a seamless hand-off (and every QConnect/API play) reached the
10 s gapless window cold and had to materialize the whole next file in
time. Whether it made it was file size vs bandwidth (#699 follow-up on a
1 GB Pi: gapless "sometimes, varies by album"; alternating pattern).
Add a `WarmSuccessor` driver action fired once per current track id on
the first tick that sees it, for every entry point, run as a driver-owned
task that never blocks the control loop; drop the awaited prefetch from
`advance_and_play`. For a successor that is still not cached at arm time,
append its initial CMAF buffer as an incremental stream
(`queue_gapless_streaming`, the desktop's hand-off) and keep the
whole-file byte fetch only as fallback.
Tests: 2 new driver tests (warm once per track for any entry, seamless
edge warms the new successor); qbz-app 269 and qbzd 298 pass.
cc
A double-click on a clickPlays row fired playRequested three times
(two releases plus doubleClicked) and restarted the track three times
within 230 ms. Debounce the release and skip the doubleClicked arm on
clickPlays rows.
cc
The cast gate lived only in `play_queue_track`; every other entry point
(single track, album, artist, track lists, playlists, MyQBZ, the Local
Library funnel) only checked the QConnect peer, so clicking a track while
casting started the local backend beside the renderer (DLNA played one
track, the host another). `route_play_remote` (cast, then peer, then
local) is now the single gate, called from all 12 entries and from
`play_audible`; `route_play_to_peer` is private. A tripwire warning in
`play_resolved_offline_aware` names any future entry that skips it.
cc
`get_media_position` returned `NotConnected` whenever no media app had
been launched yet, and the 1 Hz poll folded that into "no answer", so a
connect made while paused hit LOST_POLL_MAX and disconnected after
exactly 5 s (reproduced three times on the 2026-08-29 smoke). Add a
typed `CastError::NoMediaSession`, and while idle prove liveness with
the receiver-level `get_status` request instead of the media session.
Also coalesce cast volume: a slider drag now hands the newest level to
one sender task every 120 ms instead of one Cast/SOAP round trip per
pixel.
cc
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
Decided 2026-08-29: the arm64 Qt build only works on ubuntu-24.04-arm
(aqt's arm64 host tools segfault on 22.04-arm), so the arm64 desktop
app requires glibc 2.39; qbzd keeps building on 22.04-arm at 2.35 so a
trimmed Pi OS bookworm image stays a valid target. The floor gate asserts
per arch instead of warning; README states both floors.
cc
Qt 6.9's macdeployqt leaves @rpath/Qt*.framework references resolved
through LC_RPATH @executable_path/../Frameworks; the old assert only
accepted a literal @executable_path and failed a correct bundle. Reject
absolute paths, require the rpath, and let the offscreen smoke prove it.
cc
Both 6.8.3 and 6.9.3 arm64 qmake die with SIGSEGV on ubuntu-22.04-arm
with no missing libraries. Try the 24.04-arm image; the glibc floor gate
warns instead of failing for aarch64 until the floor is decided.
cc
The QMAKE handoff exposed the real failure: aqt's linux_gcc_arm64 6.8.3
qmake dies with SIGSEGV on ubuntu-22.04-arm, which cxx-qt-build reported
as QtMissing. The arm line pins 6.9.3 (QT_VERSION_LINUX_ARM64 overrides);
x86_64 keeps the 6.8 LTS floor. file/ldd diagnostics run before the
qmake query so the next such failure names itself.
cc
aarch64 runner: cxx-qt-build reported QtMissing with a correct
QT_ROOT_DIR — set QMAKE/CMAKE_PREFIX_PATH explicitly (as macOS/Windows
already do) and print the qmake version before the build.
macOS: StreamType::output_sample_rate (macOS-only) matched Rodio only;
Direct is intentionally not cfg-gated, so the macOS build failed with
E0004. A direct sink answers with its opened rate.
cc
Under gapless the engine never reports a not-playing tick between two
tracks, so the poll loop's track_ended edge does not fire and the next
track's edge found the previous row still open and closed it as skip
(smoke: 229 s of a 230 s track recorded as skip). The Qt edge now uses
the tracker's inference (last observed position within 2 s of the
duration = natural), the same rule qbzd already applies.
cc
Linux: scripts/packaging/desktop_entry_check.sh asserts the MPRIS
DesktopEntry constant against the recipes (nfpm basename + Icon=,
StartupWMClass, reverse-DNS basenames) and against the built deb/rpm/
AppDir. The AppDir's desktop file now carries the reverse-DNS basename
so an integrated AppImage resolves the media-widget icon too. This
invariant regressed more than once; it is a gate now, not a rename.
macOS: compile packaging/icons/QBZ.icon (Icon Composer, issue #712) with
Xcode 26's actool into Assets.car + CFBundleIconName=QBZ; icon.icns stays
as CFBundleIconFile for older macOS. The Intel leg moves to macos-26-intel
so both legs carry actool.
cc
An ISRC or fingerprint column plus a partial index, with
set_content_key / get_by_content_key helpers, so a local or Plex copy of
the same recording can reuse an analysis. The analyser and every audio
path are untouched; set/get keep their track_id contract.
cc
Section listings ask for includeGuids=1 and the track parser reads the
<Guid id="mbid://…"/> child (only the Plex Music agent writes it; other
schemes are ignored). Stored in a new plex_cache_tracks.recording_mbid
column (additive; COALESCE on upsert) and read back by the PlexTrack
mappers. PlexCachedTrack and the qbz-source Plex constructor do not
carry it yet — the catalog projection does.
cc
Jellyfin requests Fields=ProviderIds on both listing passes and maps
ProviderIds.MusicBrainzTrack (the recording id) into JellyfinTrack;
Subsonic reads OpenSubsonic musicBrainzId and the first isrc. Both land
in two additive remote_cache_tracks columns (isrc, recording_mbid), kept
across upserts with COALESCE so a known id is never overwritten by a
server that stops sending it, and travel to the queue through
qbz-source's remote constructor.
cc
The scanner now keeps the identity tags Picard writes (ISRC/TSRC and the
MusicBrainz recording, track, release, release-group and artist ids —
the same lofty ItemKeys the tag writer already knows) in six additive
local_tracks columns, migrated idempotently (also after the sample_rate
table rebuild) and indexed on isrc and the recording id. Rows fill on
the next scan. The catalog projection (legacy.rs) stops hardcoding
isrc / musicbrainz_recording_id to None for local, remote and Plex rows
(the upsert already bound both), treating an empty string as no id.
qbz-source stamps both keys on local queue tracks.
cc
cargo-test.sh and test-crates.yml assert qbz-app::listen_log lists at
least 25 tests and passes, so a refactor that drops the module's tests
fails loudly instead of shrinking coverage. Bounded: the binary is
already built by the workspace run.
cc
Settings > Integrations > PRIVACY: a Listening history toggle (default
ON; the state lives in the per-user listen_meta, so it follows the
account) and a confirmed Clear (DELETE + VACUUM). Strings in all eight
catalogs; README names where the data lives.
ScrobblerSettings::history_read_allowed(service) = *_active(): the
scrobbling switch now governs reading a service's history for
Recommendations as well as sending to it, and its Settings text says
so. recommendations_qt uses it instead of *_is_authed().
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
listen_log_qt binds the per-user ListenLogger at login/restore/offline
entry and drops it at logout. Hooks: the de-duped track edge opens a row
(closing the previous as skip), every 1 Hz tick feeds the accumulator,
the natural end-of-track predicate closes as natural, the stream-failure
toast closes as error, three consecutive empty ticks are the stop, and
main.rs closes the row in flight as shutdown after the event loop (and
on the Windows session-commit path). Cast and peer QConnect stay out.
Ephemeral ids never reach the log (same guard as recently_qt).
cc
QueueTrack gains two additive, serde-default identity keys (the ISRC and
the MusicBrainz recording id) that the listen log stamps; every
constructor in the workspace sets them (Qobuz-backed ones from
Track.isrc, local ones from the scanned tags, the rest None). They are
JOIN keys, not the row identity: numeric ids collide across sources, so
(source, native item id) stays the identity.
Track.audio_info (replaygain_track_gain / peak on DiscoverAudioInfo) is
modelled from the captured search envelopes; no reader yet and no
popularity field (0 in every sample).
cc
New qbz-app::listen_log (contract §12.1): listen_events with PK = event
(never the track), snapshot text per row, source + native item id,
context, stream facts; listen_meta with schema_version, origin_id,
paused, retention_days (unlimited). The tracker is a pure state machine:
played_ms accumulates only while playing from monotonic deltas <= 5 s,
so pauses, seeks and stalls add nothing; flush every 10 s of play; rows
close as natural/skip/stop/error/shutdown and orphans close as shutdown
on the next open. No threshold at write time: is_play / is_skip are
pure reader rules with boundary tests. ListenLogger is the async facade
(spawn_blocking everywhere); paused writes nothing, clear is
DELETE + VACUUM + checkpoint. 29 tests.
cc
- INCOMPATIBLE_GENRES / INCOMPATIBLE_TITLE_KEYWORDS removed: a global
blocklist papered over an identity bug (name search returning a
homonym) and punished every user's taste for it.
- artist_guardrail: ArtistLookup seam (mockable), id before text
(qobuz:{id} nodes resolve by id; MBIDs hit the mb_qobuz_artist_match
cache before search_artists), validation relative to the playlist
seeds (shared level-1 genre root or a shared MusicBrainz tag), homonym
tie-break by seed neighbourhood / label, reject when no signal exists.
- suggestions: stable sort by similarity_score desc, dedup keeping the
higher score, THEN truncate (the shuffle threw the score away and the
dedup kept the first hit while claiming the best).
- Eight network-free tests cover the rules; rand is now unused here.
cx
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
Removing a library folder pruned Recently Played but not
album_play_history.db, so Most Played kept cards onto deleted tracks.
radio_history had no delete path at all; sessions idle for more than
30 days (by last played_at, so a running radio is never pulled) are
dropped on open.
cc
playlist_stats.play_count had no writer anywhere in the workspace
(increment_playlist_play_count: zero callers), so the badge and the
Play Count sort were 0 for everyone. Derive the counts from the store
the track-start edge actually writes; the library table is untouched.
cc
OfflineCacheDb::touch had no caller, so LRU eviction ordered by the
download date and evicted the most-played tracks first. Touch in
resolve_offline_bytes, the one resolver every offline play goes through.
cc
The delayed scrobble used unix_now() when it fired (up to 240 s late on
a long track) and slept on wall-clock, so a track paused two minutes in
still scrobbled. Capture started_at on the track edge and wait on the
player's own position while it is playing: pause holds the scrobble, a
stop drops it, a newer track edge still self-cancels via the generation.
Same rule qbzd's scrobble_engine already applies.
cc
recommendations_qt and external_reco_qt built private MusicBrainzClient
instances, which start enabled and ignore the musicbrainz_enabled
opt-out that integrations_qt applies to the core's client. Expose the
core's Arc<MusicBrainzClient> and borrow it instead, so a disabled
MusicBrainz sends zero requests from Discover > Recommendations. The tag
editor's three private clients stay: those are explicit manual lookups
the user triggers by hand.
cc
Add bounded native library models, complete grouping rails and scan progress. Bring source-aware metadata editing, media information and collection offline preflight to Qt, and repair inherited Plex album artwork without overriding healthy tokens.
cx
Teach qmllint about statically registered native QML types and enforce metadata parity. Pin release builds to the MSVC 2022 runner and force Cargo to use the Visual Studio linker instead of Git for Windows link.exe.
cx
Preserve attribution for PR #678 after its solution was superseded by the current crate-local implementation. Make the About grouping test grow with the contributor list.
cx
Three strings: the modal's title, its "Don't show this again" box and its
button. The BODY is not among them and stays English on purpose -- it is the
author's statement of what the Windows build does and does not promise, and the
wording is the point.
`en` is deliberately untouched. That catalogue is sparse, carrying only the
strings whose English differs from the msgid, and a missing lookup falls back
to the msgid, which is already the English text.
The register follows what each file already had, "I understand and accept these
terms" in particular, so the button reads like the rest of the app rather than
like a second voice arriving.
cc
The Windows counterpart of alsa_backend::get_device_supported_rates. It exists
because the answer cannot be inferred: measured on the box, the owner's DAC
accepts 44100/48000/88200/96000/192000 in exclusive mode and REFUSES 176400 --
the 44.1 family stops at 88.2 while the 48 family reaches 192 -- while a second
endpoint on the same machine does accept 176400. No rule about higher rates
implying lower ones survives that, and 176400 is the DSD64 DoP carrier.
Exclusive is the only question worth asking. IsFormatSupported(SHARED) answers
about the mixer, which resamples and therefore says yes to nearly everything.
"I could not ask" is not "it supports nothing". A busy endpoint, a failed
activation and a device that genuinely accepts nothing all produce an empty
list, and the first draft cached that as authoritative -- which would have
outlived its cause, because a stream CLOSING raises no device notification.
Only a real answer is stored.
The DoP guard is now FAIL-CLOSED on Windows, and the fix turned out simpler
than probing: the DoP handler is cfg(target_os = "linux") and answers "DoP
playback is Linux-only" with a stream error, so dispatching there can only
fail. Converting to PCM is the only outcome that plays the track. It matters
that this is closed rather than open: an undecoded DoP stream is read as PCM
carrying packed DSD and marker bytes, which comes out as very loud broadband
noise. An earlier draft fell back to "yes" whenever the probe could not answer
-- preserving exactly the guess this work existed to remove.
The hotplug callbacks run on a thread the Windows audio system owns, where the
documented rule is to return immediately. Each does ONE lock-free atomic
increment and nothing else: no mutex, and no logging either, since a log macro
can allocate, take its own lock and perform synchronous output. Invalidation is
a generation counter rather than per-endpoint removal, which cannot block,
cannot lose an event, and matches the real granularity -- a device arriving
changes exclusive contention for endpoints other than itself. The generation is
read BEFORE a probe and stored with the result, so an event landing mid-sweep
expires that answer instead of being overwritten by it.
One IAudioClient serves every probe: IsFormatSupported may be called any time
after activation and neither initialises nor reserves the endpoint, so a client
per probe was forty activations buying nothing. A sweep gate coalesces
concurrent misses -- startup enumerates from two independent places and the log
showed every endpoint measured exactly twice. Measured after: four sweeps for
four endpoints, 34 ms for the lot.
is_supported, deliberately not is_supported_exclusive_with_quirks: the sweep
must predict what the OPEN path does, and that path asks the plain question. A
rate accepted only through a quirk the opener never tries would appear in the
UI and then fail to open.
cc