When receiving RendererCommandApplied::SetState with a current_track,
actually download the track audio and feed it to the player before
applying the playing state. Previously only called core.resume() which
did nothing since no audio was loaded.
Matches desktop's ensure_remote_track_loaded pattern from
qconnect_service.rs. Also adds >2s threshold for seek to avoid
redundant seeks from server echo.
cc
Missing fields that the Qobuz server needs to register a renderer:
- device_uuid: persistent UUID (generated once, saved to disk)
- capabilities: min/max audio quality + volume_remote_control
- serial_number: null (same as desktop)
Without device_uuid, the server can't identify the renderer and
won't send SESSION_STATE.
Copied from desktop's default_qconnect_device_info_with_name.
cc
Complete rewrite of qconnect.rs following desktop's
QconnectServiceState::connect() line by line:
1. Subscribe to transport events BEFORE bootstrap (was after)
2. Event loop checks InboundQueueServerEvent for SESSION_STATE
(was checking SessionManagementEvent via sink channel)
3. Forward events to handle_transport_event AFTER inspection
4. Bootstrap called AFTER event loop starts
5. Re-bootstrap on reconnect (Subscribed + has_disconnected)
The key bug: event loop was subscribing AFTER bootstrap, missing
the SESSION_STATE response. Desktop subscribes at line 1238,
bootstraps at line 1405.
cc
The Qobuz server doesn't always send SESSION_STATE after a controller
JoinSession (only on topology changes). This left the daemon invisible
as a renderer because deferred_renderer_join never triggered.
Now: renderer join happens immediately in bootstrap after a 1s delay,
using null session_uuid. The server should assign one. If a
SESSION_STATE arrives later via the sink channel, a second join
is attempted with the real uuid.
cc
SESSION_STATE comes as QconnectAppEvent::SessionManagementEvent
through the sink, NOT as a transport InboundQueueServerEvent.
The event loop was looking in the wrong place.
Now: sink receives SessionManagementEvent, extracts session_uuid,
sends it via mpsc channel to the event loop which does the
deferred_renderer_join. This matches the desktop pattern where
TauriQconnectEventSink processes session management events.
cc
When target is qbzd, playTrack() sends play-track to daemon and
returns immediately. No local stop (was killing daemon audio),
no cmdStop (was sending HTTP stop to daemon), no metadata update,
no scrobbling, no QConnect handoff.
The daemon handles stop-before-play internally in its play_track
endpoint. The desktop sends ONE command and exits. Zero leakage.
cc
play-track/play-album set skip_auto_advance flag before stop() to
prevent the orchestrator from auto-advancing during the download
window. This fixes the 'track starts then immediately stops' bug.
Added QConnect event type logging to debug why SESSION_STATE never
triggers the deferred renderer join.
cc
When controlling a qbzd daemon, QBZ Desktop becomes a pure remote
control. 119 invoke() call sites across 16 files now check
skipIfRemote() before executing local operations.
Local-only (skipped entirely): castStore, offlineStore,
offlineCacheState, updatesStore, titleBarStore, qobuzLegalStore,
sessionService.
Daemon-routable (guarded, daemon API routing in follow-up):
favoritesStore, albumFavoritesStore, artistFavoritesStore,
artistBlacklistStore, playlistFoldersStore, playbackPreferencesStore,
playbackContextStore.
playbackService: scrobbling, MPRIS metadata, and notifications
all skipped (daemon handles its own).
cc
When playbackTarget is 'qbzd', the play-track command goes directly
to the daemon HTTP API. Previously, it also went through
handoffPlayTrackToRemoteRenderer (QConnect) which sent play to ALL
connected renderers, causing audio on both local desktop AND remote
daemon simultaneously.
Now: qbzd mode = HTTP API only. QConnect handoff only runs in
local mode. Local player stopped explicitly via invoke before
sending to daemon.
cc
Replaces naive auto-advance polling with full orchestrator matching
desktop behavior:
Gapless: when player signals gapless_ready (5s before track end),
downloads next track and calls play_next() for seamless transition.
Handles repeat-one (queue same track), defensive same-track check,
and upcoming queue peek (same as desktop's setGaplessGetNextTrackId).
Auto-advance: on track end, advances queue respecting repeat mode.
Repeat-one replays same track. Repeat-all wraps queue. Off stops
at queue end.
Shared download_track() helper used by play-track, play-album, and
orchestrator to avoid code duplication.
cc
Auto-advance: background task monitors when track ends and
automatically downloads and plays the next track in the queue.
Polls every 500ms. Queue exhaustion logs and stops.
Play-album: POST /api/playback/play-album sets the queue with
all album tracks and starts playing from the specified index.
cc
Banner inside now-playing-bar grows it to 128px. CSS variable
--player-bar-height drives content-area/main-content calc() so
layout adjusts automatically. No more overlap.
Fix disconnect button text: actions.disconnect -> settings.integrations.disconnect
cc
LAN-only mode means no token auth. The empty string token ('') was
failing the truthy check in requireRemote(), causing 'Not in remote
mode' error on every command after connecting.
Now: token header only sent when token is non-empty. requireRemote
only checks type === 'qbzd' and baseUrl presence.
This was THE bug preventing QBZ Desktop from controlling the daemon.
cc
The second command (AskForQueueState) was failing because the
first (JoinSession) was still pending. Now clears pending slot
between commands with 500ms delay, matching desktop behavior.
Also fixed state_handle() Arc lifetime issues.
cc
3 fixes:
1. qws/createToken needs form body (jwt=jwt_qws, user_auth_token_needed,
strong_auth_needed) not empty body. Was getting 400 Bad Request.
2. mDNS hostname must end with '.local.' for service registration.
3. Response parsing: jwt_qws.endpoint + jwt_qws.jwt, not tokens array.
All 3 patterns copied from working desktop implementation in
qconnect_service.rs.
cc
POST /api/playback/play-track accepts track_id + quality, downloads
the audio stream from Qobuz, and feeds it to the Player.
This was the missing piece — without this, the daemon could search
and manage queue but couldn't actually play anything.
cc
Cache: interactive RAM detection, L1/L2 size, prefetch config.
Saves to qbzd.toml via save_default_config.
Integrations: ListenBrainz token input, saves directly to V2
cache database. MusicBrainz enable/disable toggle.
QConnect: enable/disable + device name. Saves to qbzd.toml.
All sections read current values as defaults and persist changes.
No more placeholders.
cc
QEMU emulation crashes compiling ring's C code (SIGSEGV in cc).
Switch to ubuntu-24.04-arm native runner — faster and no emulation
issues. Build time drops from ~40min to ~5min.
cc
On headless systems without gnome-keyring/kwallet, the keyring
fails with ServiceUnknown. Now saves token to ~/.local/share/qbz/.oauth-token
(mode 0600) as fallback. Load also checks file after keyring.
This fixes the 'login works but credentials lost on restart' bug
on headless machines.
cc
playbackService.ts: v2_play_track and v2_stop_playback now route
to daemon HTTP API when in remote mode.
9 views: bulk queue operations (add_tracks_to_queue, add_tracks_to_queue_next)
now use cmdAddTracksToQueue/cmdAddTracksToQueueNext instead of
direct invoke(). 14 call sites total.
This was the root cause of 'connects but nothing plays' — the
play/queue commands were always going to local Tauri, never to
the remote daemon.
cc
Moves the 'Controlling: X' banner from inside the now-playing-bar
to a fixed position above it (bottom: 104px). The player bar
keeps its 104px height unchanged. Main content area shrinks
slightly when the banner is visible.
cc
CastPicker: manual IP:port input for connecting to daemons when
mDNS/avahi is unavailable. Saves recent connections to localStorage
(max 5). One-click reconnect from saved list. Remove button on hover.
Layout: manual input field at top, saved daemons as 'Recent',
mDNS discovered daemons below with scanning indicator.
Login: binds to 0.0.0.0 with auto-detected LAN IP so the OAuth
callback URL works from any browser on the network. 5 min timeout
for remote copy-paste workflow.
i18n: recentDaemons, discoveredDaemons in all 5 locales.
cc
qbzd login now works on remote/headless machines:
- Binds callback server to 0.0.0.0 (not 127.0.0.1)
- Detects LAN IP via UDP socket probe for redirect URL
- Always prints URL prominently for copy-paste to any browser
- Browser on same LAN redirects back to daemon's IP:port
- Timeout increased to 300s for remote copy-paste workflow
- open::that still tried but failure is silent (headless)
cc
Ubuntu runners' apt multi-arch is broken for arm64 cross-deps
(security.ubuntu.com 404s for arm64 packages, DEB822 format
sources not handled by sed).
Switch to uraimo/run-on-arch-action: builds natively inside a
Debian Bookworm aarch64 container via QEMU. Slower (~15 min)
but reliable. Binary is native aarch64 with Bookworm's glibc 2.36.
cc
The sed was only modifying /etc/apt/sources.list but security.ubuntu.com
and other sources in sources.list.d/ were also trying to fetch arm64
packages from repos that don't have them (404).
Now: force [arch=amd64] on ALL .list files, arm64 exclusively from
ports.ubuntu.com.
cc
glibc 2.31 is too old for system shared libs (libasound, libdbus)
on Ubuntu 22.04 runners which have glibc 2.35. The linker rejects
symbols from libs compiled against newer glibc.
glibc 2.35 covers: Raspberry Pi OS Bookworm (2.36), Ubuntu 22.04+,
Armbian Bookworm, DietPi Bookworm. Bullseye (2.31) is EOL June 2026.
cc
reqwest default features included native-tls which pulled in
openssl-sys. This broke zigbuild cross-compilation because zig's
C compiler doesn't support the glibc version suffix for openssl
header expansion.
Fix: default-features = false on reqwest in workspace and
qbz-integrations (which had its own reqwest dep with defaults).
Now the entire daemon tree uses rustls-tls exclusively.
cc
QBZ Daemon tab appears first in the device picker alongside
Chromecast and DLNA. Discovered daemons show name + host:port.
Clicking a daemon:
1. Pings to verify it's reachable
2. Connects via playbackTargetStore
3. Starts SSE event subscription
4. Closes picker
Connected daemon shows in the connected banner with disconnect.
Disconnecting returns to local playback mode.
i18n: noQbzdFound + noQbzdFoundHint in all 5 locales.
cc
Library scan now extracts metadata (title, artist, album, duration,
sample rate, bit depth) from audio files using qbz-library's
MetadataExtractor. Tracks are inserted into the library database
with full metadata.
MetadataExtractor was already in qbz-library crate — no migration
needed.
cc
Uses cargo-zigbuild to target glibc 2.31 for maximum compatibility:
- Raspberry Pi OS Bullseye and newer
- DietPi, Armbian, Ubuntu 22.04+
Triggered by qbzd-v* tags or manual dispatch.
Produces two binaries: qbzd-linux-x86_64, qbzd-linux-aarch64.
Creates pre-release on tag push.
cc
HeadlessQconnectSink routes QConnect events to the daemon event bus.
Fetches QWS JWT credentials from Qobuz API, connects via WebSocket.
The daemon now registers as a Qobuz Connect renderer — controllable
from the Qobuz mobile app and web player. QConnect starts
automatically after login if enabled in config.
cc
Spawns a dedicated event bus listener that updates MPRIS metadata
(title, artist, album, duration) when a new track starts playing.
Clears metadata on logout.
MPRIS now fully functional: media keys (play/pause/next/prev) +
metadata display (track info in system media widget).
cc
CLI now shows all 5 subcommands: login, status, token, setup, help.
Setup placeholder ready for TUI wizard implementation (ratatui).
Config save helper for persisting changes to qbzd.toml.
cc
Playback loop now updates MPRIS state (playing/paused/position)
on every emit cycle so media controls always reflect current state.
qbzd status: queries running daemon via HTTP and prints status.
Shows logged_in state, port, and daemon info.
cc
LAN-only CORS: allows any origin on local network for qbz-control PWA.
MPRIS via souvlaki (D-Bus, no window): play/pause/toggle/next/prev/stop
media keys work out of the box on any Linux desktop or headless system
with D-Bus. Metadata and playback state updates available via helpers.
Security model finalized:
- LAN: open access, no auth needed (private IP check)
- WAN: completely blocked (403 for non-private IPs)
- Remote: QConnect only (cloud, Qobuz-managed auth)
cc
Replaces token-based authentication with LAN-only IP restriction.
Requests from non-private IPs (not 10.x, 172.16.x, 192.168.x,
127.x, link-local, ULA) are rejected with 403.
Security model: LAN = trusted, WAN = blocked.
Remote control outside LAN is exclusively via QConnect (cloud).
cc
qbzd login: interactive OAuth via system browser. Opens browser,
captures callback, exchanges code, saves token to keyring. Same
flow as desktop app. Daemon auto-logins on next start.
Library scan endpoint (POST /api/library/scan): discovers audio
files in registered folders. Full metadata extraction deferred
until MetadataExtractor moves to qbz-library crate.
Service files: systemd, OpenRC, runit + example qbzd.toml config.
cc
Library endpoints for standalone operation:
- GET /api/library/albums, artists, album tracks, search, stats
- POST/DELETE /api/library/folders (add/remove scan directories)
Integrations endpoints:
- GET/POST/DELETE /api/integrations/listenbrainz (status, connect, disconnect)
- GET /api/integrations/lastfm (status, setup deferred to TUI wizard)
ListenBrainz connects by writing token directly to the V2 cache
database (same format as desktop app). Last.fm requires OAuth
flow which will be implemented in the TUI wizard.
Total: 67 HTTP endpoints. The daemon is now fully operable
without the desktop app for all core functionality.
cc
Daemon: 5 new audio settings endpoints:
- GET /api/audio/settings (full settings)
- PATCH /api/audio/settings (partial update)
- GET /api/audio/backends (available backends)
- GET /api/audio/devices?backend= (devices for backend)
- GET /api/audio/hardware-status (current hw state)
Frontend: commandRouter gains audio settings commands that route
to daemon API when in remote mode, local invoke when local.
Total daemon endpoints: 32.
cc
Daemon side: registers _qbz._tcp.local mDNS service on startup
so desktop apps can discover it on the LAN. Uses hostname as
service name (configurable via qbzd.toml).
Desktop side: new qbzd_discovery.rs module with Tauri commands:
- v2_qbzd_start_discovery: browse _qbz._tcp services
- v2_qbzd_stop_discovery: stop browsing
- v2_qbzd_get_devices: list discovered daemons
Uses same mdns-sd crate already used for AirPlay discovery.
cc
Accent-colored banner shows 'Controlling: {device_name}' with a
disconnect button when the app is targeting a remote qbzd daemon.
Hidden in local mode.
i18n: player.controllingRemote in all 5 locales.
cc
New commandRouter.ts provides a single bifurcation point for all
playback and queue operations. When playbackTarget is 'qbzd', commands
route to the daemon HTTP API. When 'local', they use Tauri invoke().
Stores updated:
- playerStore.ts: pause, resume, stop, seek, volume, play_track (6 sites)
- queueStore.ts: shuffle, repeat, add, add-next, set, clear (8 sites)
No behavioral change in local mode — invoke() calls are identical.
cc
Playback event loop polls QbzCore player state with adaptive timing
(250ms playing, 1s paused, 5s idle) and broadcasts PlaybackSnapshot
events to the event bus.
New SSE endpoint at /api/events streams all DaemonEvents to clients.
Broadcast channel bounded to 256 events with lagged client handling.
cc
New session.rs module ports activate_session from Tauri to headless:
- Creates per-user directories (same layout as desktop app)
- Persists last_user_id marker for session restore
- Emits RuntimeEvent::Ready via event bus
- Skips desktop-only stores (tray, visualizer, remote control)
Session activation wired into daemon startup after auto-login.
Audio settings reload deferred until qbz-player crate exposes
reload_settings (currently on legacy player only).
cc
DaemonCore now initializes the real QbzCore with DaemonAdapter,
creates Player with audio settings from shared database, and
attempts auto-login from saved OAuth token in system keyring.
Working features:
- QbzCore init (bundle token extraction from Qobuz web player)
- Player audio thread (ready for playback commands)
- AudioCache with auto-detected size from RAM
- Shared audio settings with desktop app (same SQLite DB)
- Keyring credential lookup (same service as desktop)
- HTTP endpoints: /api/ping, /api/info, /api/status
- Graceful shutdown (SIGTERM/SIGINT -> stop player -> exit)
cc
Rename commands_v2.rs to commands_v2/mod.rs as the first step
of splitting the 14,635-line file into domain-specific modules.
No functional changes.
cc
Chain Option combinators to reduce nesting. The await boundary
requires one outer if-let, but the rest flattens into a single
and_then/map/filter pipeline.
cc
The custom QConnect device name was stored in-memory only,
resetting to "Qbz - hostname" on every app launch.
Now persists to qconnect_settings.db in the global data dir
(~/.local/share/qbz/). Loaded on startup, saved on change.
Fail-open: any db error silently falls back to default.
Fixes#284
cc
- V2_PREFETCH_COUNT: 2 -> 5 (cache ~2 min ahead)
- V2_MAX_CONCURRENT_PREFETCH: 1 -> 2 (overlap downloads)
- V2_PREFETCH_LOOKAHEAD: 10 -> 15 (find more Qobuz tracks)
Combined with parallel CMAF segments from previous commit,
this keeps the cache well ahead of playback. The sliding
window re-triggers on every track play, so the 5-track
buffer advances continuously through the queue.
cc
Download CMAF segments concurrently (3 slots) instead of
sequentially for prefetch/cache operations. Each slot has a
500ms cooldown after download to stay under CDN rate limits.
This only affects cache prefetch (try_cmaf_full_download),
not real-time streaming which remains sequential for smooth
playback. Segments are reassembled in order after download.
Typical improvement: 10-segment track goes from ~10 serial
HTTP requests to ~4 batches of 3 concurrent downloads.
cc
WebView OAuth: cancel button appears during loading, captcha hint
shows after 25s or when user closes WebView. Hint suggests trying
system browser instead with one-click action.
System browser OAuth: cancel link appears next to loading text,
immediately aborts the wait.
i18n: new keys captchaHint and trySystemBrowser in all 5 locales.
cc
Add OAuthCancelState with shared Notify for system browser flow.
v2_start_system_browser_oauth uses tokio::select! to race between
code received, 120s timeout, and user cancel signal.
New commands:
- v2_cancel_system_browser_oauth: triggers cancel notify
- v2_cancel_oauth_login: closes all OAuth WebView windows
Timeout reduced from 300s to 120s for system browser flow.
cc
When the user closes the OAuth WebView window manually, the
Destroyed event now triggers notify to unblock the 5-minute
timeout immediately, returning oauth_cancelled instead of
hanging until timeout.
cc
Add request_ts + request_sig (MD5 signature) to catalog/search,
album/search, track/search, artist/search, and playlist/search
endpoints matching the mobile app authentication pattern.
Migrate v2_search_all from legacy AppState to CoreBridge, removing
the last direct API call that bypassed the V2 crate architecture.
cc