Backend plumbing for Phase 3 AwardView. New endpoints:
- v2_get_award_page(awardId) → hero + categorized releases
- v2_get_award_albums(awardId, …) → paginated album list
Mirrors Android's AwardDto shape (id, name, image, awarded_at,
magazine, releases, playlists). Follows the label/page pattern for
signing and error handling. Deserializer accepts the V2 generic
{has_more, items} envelope as well as the legacy search-style
{albums: {items, total}} in case Qobuz swaps shapes per account.
cc
/album/get returns award.awarded_at as a Unix timestamp integer while
/discover/index returns it as a YYYY-MM-DD string — our struct only
accepted String, so /album/get started failing with
'invalid type: integer `…`, expected a string' as soon as awards were
added to Album in Phase 2. Every 'Load album' call broke.
Adds a custom deserializer that accepts both shapes and normalizes to
String downstream. Applied in both qbz-models (V2) and src-tauri legacy
api::models for symmetry.
cc
Ports the Qobuz web 'label + awards' sidebar pattern to AlbumView.
Track list and sidebar sit side-by-side above 1100px and collapse
vertically below it. Label card navigates to the existing LabelView.
Award cards call onAwardClick when provided (disabled until Phase 3
lands AwardView).
Wiring:
- Adds awards (Vec<PageArtistAward>) to both qbz-models Album and
src-tauri api::models Album so /album/get payloads hydrate the
field instead of silently dropping it.
- Adds awards to QobuzAlbum and AlbumDetail frontend types plus the
convertQobuzAlbum adapter.
- New album.sidebar i18n keys in all five locales.
Card ribbon still shows only the LAST award (Phase 1 behavior);
sidebar renders every award as a tap target — mobile-style.
cc
Changes the AlbumCard ribbon prop shape from a union string to
{kind, label} so press accolades (Pitchfork BNM, Rolling Stone 5★,
Gramophone Editor's Choice, …) can render with their actual award
name rather than a pre-defined string. pickAlbumRibbon now picks the
LAST entry in album.awards[] (per product direction) and classifies
it as qobuzissime (id 88), albumOfTheWeek (id 151) or press (anything
else). Press variant gets a red border-left to differentiate from the
Qobuz-branded awards. Full award stack will render in AlbumView's
sidebar (Phase 2).
cc
Ports the Qobuz mobile 'Radar de Novedades' / Release Watch screen 1:1
(see rr0/i.java): a standalone view with three lazy-loaded tabs —
Artists, Labels, Awards — each hitting /favorite/getNewReleases with
the matching type parameter (limit 150 per tab, matching the mobile
cap).
Home's Release Watch rail (which still shows the Artists feed by
default, matching the mobile's default tab) now has a 'See all' link
that routes to the new view.
Adds view 'discover-release-watch' to the navigation type, wires the
route in +page.svelte, and adds tab/empty-state i18n keys in all five
locales.
cc
AlbumDto from Qobuz carries both legacy singular 'artist' and current
'artists[]' array. /favorite/getNewReleases omits the singular field
and only populates the array, so our Album struct (which has a default
serde Artist) deserialized an empty name — every Release Watch card on
Home read 'Unknown Artist'.
Pre-process the JSON response items before deserializing: when 'artist'
is absent or null, copy artists[0] into it. Keeps the rest of the
album-list plumbing untouched.
cc
User asked explicitly for the ribbon to sit below the hover overlay;
misread the original request and put it above. Relocates ribbon to
bottom:8px left:0 and drops z-index to 1 so .action-overlay (z-index 2)
covers it on card hover.
cc
The ribbon feature relied on album.awards arriving on the frontend, but
the DiscoverAlbum Rust struct in both qbz-models and src-tauri/api was
missing the 'awards' field, so serde silently dropped it during
deserialization of /discover/index. Result: frontend always saw an
empty awards array and pickAlbumRibbon returned undefined.
Also bumps the home sessionStorage cache to schemaVersion 2 so pre-fix
caches (which persisted AlbumCardData without ribbon) are discarded on
next launch instead of rehydrating with missing fields.
cc
Adds a small top-left ribbon on AlbumCard that surfaces Qobuz's two
locale-stable editorial awards: 'Qobuzissime' (award id 88, purple) and
'Álbum de la semana Qobuz' (award id 151, yellow). The ribbon is
z-index 4, above the hover action-overlay (z-index 2), so it stays
visible when the user hovers the card.
HomeView maps every /discover/index rail via discoverAlbumToCardData
through a new pickAlbumRibbon(awards) helper that matches by stable
award id rather than translated name. Essential Discography and
Forgotten Favorites (which render DiscoverAlbum raw) call
pickAlbumRibbon inline.
i18n keys added to all five locales under 'ribbon.*'.
cc
The card hover painted the same bg-tertiary that the follow button uses
on hover, so mousing over the button produced no visible state change
because the card background already matched it. Removes the card-level
background transition; the cursor:pointer is enough to signal the card
is clickable, and the button now shows its hover distinctly against the
plain background.
cc
Aligns the 'More labels' Follow/Following button with the existing
artist '.follow-btn' style in ForYouTab (6px border-radius, 14px
horizontal padding, border-primary / text-secondary). No more pills
anywhere in the app.
cc
Previously onMount fired a single 'forYouLoaded' guard that launched
loadArtistsToFollow, loadSpotlight and loadSimilarAlbums immediately —
but each of those functions early-returns when its input prop is empty,
and the guard prevented re-running once the prop arrived. Users who
switched to the For You tab before HomeView finished resolving topArtists
would see these rails stay permanently blank (no 'Artists to Follow',
no Spotlight, no 'Similar to').
Replaces the single guard with per-section guards and $effects that
react to topArtists / recentAlbums becoming non-empty. Sections with no
prop dependencies (forgotten, essentials, release watch) still fire
once at mount.
cc
Swaps the top-right heart-in-circle on 'More labels' cards for a
pill-shaped Follow/Following button underneath the label name, matching
the Qobuz mobile app's affordance. Uses UserPlus icon + short label
('Sigue' / 'Follow' / etc.) when not followed, Check icon + 'Siguiendo'
when followed. Adds 'label.followShort' and 'label.followingShort'
i18n keys for all five locales.
cc
Release Watch is fetched from a REST endpoint separate from /discover/index,
so it lives in its own state (releaseWatchAlbums / loadingReleaseWatch).
HomeCacheData did not include it, so restoreFromCache on a 'fresh' hit
populated every other section but left the Release Watch row stuck on its
initial loading=true / empty-items state, showing dark skeleton boxes.
Adds releaseWatchAlbums to HomeCacheData, writes it in setHomeCache,
reads it back in restoreFromCache, and resets loadingReleaseWatch=false
there so the row either renders populated or hides itself.
cc
Introduces 'labels' as a first-class favorites tab alongside
tracks/albums/artists/playlists. Adds the navigation view
'favorites-labels', plumbs it through sidebar, titlebar dropdown,
favorites preferences modal and navigation store, and renders a
circular grid of followed labels with search and empty states.
Clicking a card navigates to the existing LabelView detail page.
Spanish copy uses 'Sellos' and Portuguese 'Selos' to match
Qobuz mobile terminology.
cc
Changed label-card from <button> to <div role=button> (nested buttons
are invalid HTML) and overlaid a 28x28 heart button on each card image.
Heart is hidden by default, visible on hover/focus, and stays visible
when the label is already followed.
cc
Adds the same follow flow we already have for artists, applied to
labels. Confirmed against the inferred OpenAPI spec
(qbz-nix-docs/qobuz-api-inferred-openapi-v9.7.0.3.yaml) — the
existing /favorite/create, /favorite/delete and
/favorite/getUserFavorites endpoints already accept fav_type='label'
and label_ids, so no new Qobuz endpoints were needed.
Backend
- favorites_cache: new favorite_labels SQLite table + add / remove /
sync / get methods alongside the artist counterparts. clear_all
drops the new table on logout.
- commands_v2::favorites: 4 new commands —
v2_get_cached_favorite_labels, v2_sync_cached_favorite_labels,
v2_cache_favorite_label, v2_uncache_favorite_label. Same wiring as
the artist commands.
- lib.rs invoke_handler registers the four new commands.
Frontend
- src/lib/stores/labelFavoritesStore.ts: clone of
artistFavoritesStore — same in-memory Set, same isLoaded flag,
toggle calls /favorite/create with fav_type=label, sync paginates
/favorite/getUserFavorites?type=labels.
- routes/+page.svelte: loadLabelFavorites() called alongside the
other three after login is confirmed.
- LabelView.svelte: heart-shape Follow button in the label header
info column, mirroring ArtistDetailView's .artist-actions
treatment. Reactive to the store via labelFavoritesVersion bump
so any future Favorites view that toggles a label will keep this
view in sync.
- i18n: label.follow / label.unfollow in all five locales.
cc
HomeSettingsModal renders the section label with $t(`home.${section.id}`),
so the home.releaseWatch key was missing and the settings row showed
the raw key. Added it to all 5 locales (Spanish 'Radar de Novedades'
per the iOS screenshot; other languages match Qobuz's own strings
from the Android resources).
In ForYouTab, the section is now placed immediately after Your Mixes
instead of before Essentials — personal-feed sections (DailyQ / WeeklyQ
/ FavQ / TopQ + followed-artist releases) now sit together at the top.
HomeView section title switched to $t('home.releaseWatch') to match
the naming convention other sections already use.
cc
The endpoint returns V2GenericListDto<AlbumDto> which is just
{has_more: bool, items: [...]} — no total/offset/limit. Our caller
wants SearchResultsPage<Album> to stay compatible with the rest of
the album-list flow, so map items through directly and synthesise
total from has_more + items.len() + the offset we asked for.
Endpoint itself returns 200 OK with a real feed now (confirmed in
logs with Godsmack etc. on the test account).
cc
The inferred OpenAPI spec
(qbz-nix-docs/qobuz-api-inferred-openapi-v9.7.0.3.yaml, source:
x20/b.java) documents the endpoint as /favorite/getNewReleases with
three required query params:
@GET favorite/getNewReleases
- type: String (artists | labels | awards — matches the three
tabs in the mobile UI)
- limit: Integer
- offset: Integer
My earlier attempts at /albums/releaseWatch were chasing the Android
route-table entry for the Release Watch *screen*, not the HTTP call
that screen makes. Different layer.
Also removes my made-up speculation about mobile app_id gating — the
/favorite/* endpoints are standard web-API calls the existing client
already knows how to sign (via authenticated_headers + /0.2/ base URL).
v2_get_release_watch now takes an optional releaseType arg defaulting
to 'artists' so callers can switch tabs later without another command.
cc
The debug-level log lines for status + response keys + raw body never
printed at default log level, leaving zero observability on the call.
Bumped to info and added body capture so a non-success status or a
deserialize mismatch dumps the first 400-600 chars, which will tell
us whether the endpoint returned {albums:{items:[]}}, {items:[]}, an
auth error, or something else entirely.
cc
Third try's the charm. The previous attempt called the endpoint via
signed_get_auth (RPC-style with request_sig + request_ts), which
yielded 'no Service found with those values' — the RPC dispatcher
simply doesn't know this path.
The /albums/* plural namespace (releaseWatch, favoriteAlbums,
purchasedAlbums, similarTo, essential, artistAlbums, discoverAlbums)
is REST-style, not RPC. QBZ already uses the same pattern for the
/radio/* endpoints in src-tauri/src/api/client.rs: plain
.get(url).headers(authenticated_headers()).query(...) with X-App-Id
and X-User-Auth-Token, and no signing. Same pattern here.
Also reverted the release_watch container addition to
DiscoverContainers — /discover/index does not return it alongside
the editorial sections, confirmed by the empty result when reading
from there even with follows set.
Radio and Artist Spotlight work in QBZ via this same REST + auth
headers pattern despite being 'mobile' features; Release Watch
should too.
cc
The initial implementation added a dedicated get_release_watch client
method calling /albums/releaseWatch, but that path is only routed on
the mobile API; the web API (api.json/0.2) that qbz-qobuz speaks to
returned 'no Service found with those values'.
The correct source is already available: /discover/index — which we
were already calling for newReleases/pressAwards/qobuzissimes/etc —
also returns a release_watch container when the user has follows. We
were silently dropping it because DiscoverContainers didn't declare
the field.
Now:
- DiscoverContainers (Rust + TS mirror) declares the release_watch
field with a serde alias that accepts both camelCase and snake_case
spellings, so future naming churn upstream is tolerated
- HomeView reads it in fetchAllDiscoverData next to the other editorial
sections (one less network round-trip)
- ForYouTab calls v2_get_discover_index and extracts only the
release_watch container
- Reverted the custom /albums/releaseWatch endpoint, client method,
core passthrough, bridge wrapper and v2_get_release_watch command
Section identity (homeSettingsStore id, i18n keys, render blocks)
is unchanged.
cc
First iteration hard-parsed the /albums/releaseWatch response as
SearchResultsPage<Album>, which failed with 'missing field items'
because the wrapper key (albums / releaseWatch / release_watch /
bare items) differs across Qobuz API deployments and is not
publicly documented.
Now walks a candidate list of wrapper keys, accepts both
{items: [...]} objects and bare arrays, falls back to root-level
parsing, and finally logs the response shape + returns an empty
page rather than propagating an error that would kill the whole
Discover screen.
cc
The Qobuz mobile client (v9.7) surfaces a 'Release Watch' /
'Radar de Novedades' feed in its Discover screens — new releases
from the artists, labels and award series the user follows. We
didn't have parity for it. This adds it as a module in both
Discover tabs.
Backend:
- qbz-qobuz: new ALBUMS_RELEASE_WATCH endpoint constant
(/albums/releaseWatch, plural as in mobile) and signed_get_auth
client method returning a SearchResultsPage<Album>. Signing
method is 'albumsreleaseWatch' per the Qobuz secret protocol.
- qbz-core: passthrough get_release_watch on QbzCore.
- src-tauri: core_bridge wrapper, new v2_get_release_watch Tauri
command registered in lib.rs. Filters through the artist
blacklist like other album endpoints.
Frontend:
- HomeView: new renderable section 'releaseWatch' in the home tab
(default visible, placed right after New Releases). Parallel
fetch alongside the existing discover_index call; mapped via
the existing toAlbumCard helper.
- ForYouTab: section appears ahead of 'Essentials' since followed
artists/labels are typically more personal.
- homeSettingsStore: new HomeSectionId 'releaseWatch' in the type
union and DEFAULT_SECTIONS so the user can toggle/reorder it
from home customization.
- i18n: discover.releaseWatch.title + subtitle added to all five
locales, using Qobuz's own translations where they exist
('Radar de Novedades' for Spanish, 'Release-Radar' for German)
with the 'followed artists/labels/awards' subtitle copy adapted
from the mobile app's strings.xml resources.
cc
Addresses the reported behavior where tracks marked available offline
still buffered from Qobuz after the v1.2.2 upgrade. Three layers:
1. **Snapshot / restore stream-first-track.** offline_settings gains a
new nullable column pre_offline_stream_first_track (SQLite ALTER
migration). v2_set_manual_offline now coordinates with
AudioSettingsState: entering offline mode stashes the user's current
stream_first_track preference and forces it to false; exiting
restores the snapshot. User's preference survives the round-trip
even across app restarts.
2. **Explicit offline refusal.** v2_play_track now takes
OfflineState, reads manual_offline_mode up front, and — when every
local cache miss has been exhausted — returns a new
RuntimeError::TrackNotAvailableOffline instead of silently reaching
for the network. Low-quality offline hits still play (better than
nothing). The playback path also masks stream_first_enabled while
offline as a belt-and-suspenders defense.
3. **Cache-miss visibility.** Every call site that consults the
offline cache DB now logs [V2/CACHE STALE] at warn level when the
DB says a track is cached but the filesystem path is missing. This
made the issue #279 behavior invisible; the orphan entries from
per-user isolation migration (aab3e6764) are now diagnosable from
logs.
Frontend:
- playbackService surfaces TrackNotAvailableOffline as a toast ('…
isn't saved for offline playback') instead of the generic error
- SettingsView listens for a new audio-settings-changed event so the
stream-first-track toggle in the UI flips when the offline toggle
mutates it
- i18n key offline.trackNotAvailableOffline added to all 5 locales
cc
The old Linux-only guard only rejected absurd sizes (>8K) as corruption
sentinels; anything under that was passed through to the builder
verbatim. So a size saved on a 4K display would open on a 1080p display
at full 4K dimensions — off-screen, unreachable when decorations are
hidden.
Added a second clamp inside setup() that queries app.available_monitors()
after Tauri has the display connection, takes the largest logical size
across all connected monitors, and shrinks the persisted size to 95% of
that when it overflows. The original 8K corruption guard stays as a
first pass (runs before setup, catches corrupt DB rows before we even
open a display connection).
Works on all platforms — no cfg gating, since 'saved size bigger than
any monitor' is a user-visible problem everywhere, and the 8K guard
also protects macOS/Windows against a corrupt settings row.
cc
Tauri's default tray uses libayatana-appindicator, which by design does
not dispatch primary-click events to its client — libayatana delegates
every left-click to the libdbusmenu-managed menu. The existing
show_menu_on_left_click(false) setting and TrayIconEvent::Click handler
never fire on Linux. Confirmed present in tray-icon 0.21.x and 0.22.0
(the newest release).
Switch the Linux backend to ksni, a pure-Rust StatusNotifierItem
implementation that exposes the full SNI protocol including Activate
(left-click), SecondaryActivate (middle), and ContextMenu. Same SNI
compatibility as the previous backend — works wherever the panel
already handled our tray (KDE, GNOME+AppIndicator, XFCE/Cinnamon/MATE,
wlroots trays). Flatpak just needs the --talk-name=org.kde.
StatusNotifierWatcher permission which is already in our manifest.
Tauri's tray stays on macOS (no Windows client is shipped). The
dispatch lives in tray.rs behind cfg(target_os); Linux delegates to
the new tray_linux_ksni module. Playback-control menu events
(tray:play_pause, tray:next, tray:previous) still emit into the main
webview window, so frontend handlers keep working unchanged.
cc
Missed ForYouTab.svelte in the previous pass — it has its own copy of
the mix-gradient-*::before + @keyframes silk-* definitions used by
the 'Your Mixes' row inside the For You tab. Same treatment: drop
the 4 animation properties and the 4 keyframe blocks, keep the
static gradients and remove the will-change: transform hint.
cc
The silk-daily/weekly/favq/topq keyframe animations on DailyQ, WeeklyQ,
FavQ and TopQ cover art (and their HomeView preview cards) were
practically invisible — 30s transforms of ±8% translate + small
rotation are below most users' motion perception thresholds — but
produced visible glitches on some hardware when the transform
compositor layer got evicted under memory pressure (e.g. during
scroll with many visible mix cards).
Dropping the animation, keeping the static silk gradients. The
will-change: transform hint goes too since there's nothing animating
anymore.
cc
Previously the compact variant set height: 36px, visibly shorter than
the quality badge + audio badges stack next to it. Only the width
needed to shrink; height inherits from the parent (100%) like the
full variant so the badge aligns with its neighbors.
cc
The build script unconditionally rewrote capabilities/auto-updater.json
on every run, which bumped the file's mtime. tauri-build emits
cargo:rerun-if-changed=capabilities so any touch in that directory
re-runs this script, and 'tauri dev' also watches that folder for
source reloads. The two watchers fed each other: compile → rewrite
(same bytes, fresh mtime) → cargo marks build.rs dirty → recompile
→ tauri dev sees mtime change → request another build → repeat.
Now we read the existing file first and only write when the content
actually differs. mtime stays stable when the generated text is
identical across runs, so neither cargo nor tauri dev observe a
change and the loop terminates.
cc
When the now-playing bar is narrower than 1100px (reported overlap at
half a 1866px display), the right-section now collapses into two
elements:
- An overflow button (…) that opens a popup menu containing Cast,
Qobuz Connect, Lyrics, Mini-player, Full-screen, and Normalization.
- A mute/volume button that opens a vertical XP-style slider popup
upward; the horizontal inline slider is only rendered at wide
widths.
Detection uses a ResizeObserver on the bar element so the breakpoint
reacts to the actual space the bar receives (sidebar state, window
chrome, etc.) rather than a viewport media query. In the same narrow
mode QualityBadge and QconnectBadge switch to their compact variants
so the badges remain visible without the controls overlapping.
Queue button and offline/degraded indicators stay visible in both
layouts. Volume lock (ALSA hw:) keeps the existing inline disabled
variant so the 100% indicator is always visible.
i18n key player.moreControls added for all 5 locales.
cc
QualityBadge.compact now keeps the icon + color tier identity and the
downgrade/bit-perfect indicators; drops the tier-label row and shortens
'24-bit / 96 kHz' to '24/96' so the badge fits ~70px. The old plain-
text compact mode (inline tracklist usage) is replaced by this richer
variant.
QconnectBadge.compact is a 36x36 icon-only button; popup and color
behavior are unchanged. Hover title still surfaces the full state.
Both variants are opt-in via a 'compact' prop so existing callers are
unaffected; NowPlayingBar will flip them when the bar collapses (next
commit).
cc
AudioOutputBadges (DAC/HW/EXC/CAST) used to update only on mouseover
or during the 1 Hz polling loop — which itself was gated to
dac_passthrough or ALSA, so PipeWire users without passthrough never
saw the active device reflect a new stream.
The comment in the file noted that an earlier $effect watching
is_playing caused an infinite loop because loadStatus() writes
outputStatus, which re-triggered the effect. That's fixed here by
subscribing to the player store with manual edge detection: we only
call loadStatus() when is_playing transitions false→true or when
track_id changes. Neither signal is written by loadStatus(), so
there is no loop.
cc
QualityBadge now has three new visual states wired off the
bit_perfect_mode field the backend emits in PlaybackEvent:
- DirectHardware → subtle green check next to the tier label,
border tinted green. Reassurance that audiophile listeners are
getting the stream untouched.
- PluginFallback → purple down-arrow + new tooltip explaining the
DAC doesn't support the source rate so ALSA (plughw) is doing
software resampling. The tooltip's suggested fix is explicit
about the mitigation (pick lower-rate source or a broader DAC)
since this is a hardware-capability constraint, not a user
setting issue.
- Disabled / null → no change.
Plumbed through the existing playerStore subscription pattern so
the indicator reflects the stream the moment it opens. i18n keys
added for all 5 locales (en/es/de/fr/pt).
cc
Adds a bit_perfect_mode field to SharedState and PlaybackEvent so the
frontend can display whether the active stream is:
- DirectHardware — ALSA hw: direct access (bit-perfect guaranteed)
- PluginFallback — ALSA plughw: with software resample (bit-perfect
lost; happens when the DAC doesnt support the stream's sample
rate, e.g., the Vanatoo case in #288)
- Disabled — shared system path (pipewire/pulse/CPAL)
try_init_stream_with_backend now takes the SharedState so it can
record the mode the moment the stream is successfully opened,
whether via ALSA direct or the CPAL/Rodio fallback.
cc
The old 'Device not found' error fired for two very different
situations: the device truly missing (unplugged, renamed) vs. present
in /proc/asound but not loadable by CPAL at the requested sample rate
or by the stored name format. The latter sent users on wild goose
chases.
Now we distinguish the cases: if /proc/asound still reports the card,
the message points at the likely causes (rate/format mismatch or
ALSA name mismatch) and suggests the plughw plugin, which is the
workaround we already apply automatically in Fix 2.
cc
When /proc/asound reports that the selected hw: device does not
natively support the track's sample rate (e.g., 96 kHz track on a
48 kHz-only Vanatoo Transparent Zero), the previous code returned
None from try_create_direct_stream. The caller then dispatched to
CPAL's create_output_stream, where the CPAL ALSA host could not
locate the device by its /proc/asound name and failed with the
misleading error 'Device not found'.
The same function already has a plughw: fallback path further down
that wraps the hw device with ALSA's plug plugin — the latter
resamples in ALSA space and is the correct behavior for rate
mismatches. This change makes the /proc/asound pre-check set a
local flag that skips the hw attempt, falling through to the
existing plughw branch instead of returning None.
Sound now comes out. Bit-perfect is lost (by necessity) and the
mode reported back to the player is PluginFallback, which lets the
UI surface the degradation (wired up in Fix 4).
cc
Previously the quality downgrade only ran when the requested quality
was UltraHiRes and only tried a single step down to HiRes. Since Qobuz
returns up to 96 kHz content for both UltraHiRes and HiRes tiers, that
single step was a no-op for DACs that top out at 48 kHz (e.g. Vanatoo
Transparent Zero, many entry-level USB DACs): playback still failed
with the misleading 'Device not found' error.
This change iterates down the quality ladder after checking the
actual stream sample rate, continuing to Lossless (44.1 kHz) when
HiRes still produces an unsupported rate. Applies to both the current
track (v2_play_track_with_options) and prefetched tracks
(spawn_v2_prefetch).
Lossy MP3 is never selected automatically — if even Lossless is
unsupported, the stream continues at the original rate and Fix 2
(software resample fallback) takes over.
cc
Gates tauri-plugin-updater and tauri-plugin-process behind a default-on
Cargo feature 'updater'. Flatpak and Snap builds now pass
--no-default-features so the plugins are not linked in sandboxed
packages, addressing store policies against competing auto-updaters.
cc
Snap and Flathub builds now invoke
`npm run tauri build -- --no-bundle -- --no-default-features` so
cargo compiles without the `updater` feature, excluding
tauri-plugin-updater and tauri-plugin-process from the binary and
satisfying store policies that prohibit competing auto-updaters.
cc
Makes tauri-plugin-updater and tauri-plugin-process optional
dependencies enabled by a default-on `updater` Cargo feature.
Sandboxed packaging targets (Flatpak, Snap) will build with
`--no-default-features` to exclude the plugins entirely,
satisfying store policies that prohibit competing auto-updaters.
Changes:
- Cargo.toml: add `updater` feature; plugins are now optional deps
- lib.rs: gate plugin registration on the feature
- updates/mod.rs: is_auto_update_eligible() returns false when
the feature is off (catch-all for unusual build configs)
- capabilities/default.json: remove updater/process permissions
- build.rs: emit capabilities/auto-updater.json only when the
feature is enabled; remove it otherwise. The file is gitignored
since it is fully build-driven.
cc
Adds in-app auto-updater via tauri-plugin-updater (macOS + Linux
AppImage only; Flatpak/Snap/DEB/RPM keep their own update channels).
Includes signed release artifacts, updater manifest generation,
progress modal, and i18n coverage in all 5 locales.
cc
Replaces PLACEHOLDER_PUBLIC_KEY with the real minisign public key
generated via 'tauri signer generate'. The corresponding private key
and password are stored locally; they must be added to GitHub Actions
secrets (TAURI_SIGNING_PRIVATE_KEY and TAURI_SIGNING_PRIVATE_KEY_PASSWORD)
before the first signed release.
Minisign public keys are intended to be public and embedded in the
binary; the updater uses this key to verify the signature of the
downloaded update bundle.
cc
Additively wires the in-app auto-update flow: on +page.svelte the
Update Available modal offers Download & Install when eligible, and
on SettingsView the Check for Updates result modal does the same.
Both show the UpdateProgressModal while the update runs, with
Cancel (pre-install) and Download manually fallback (on error).
No existing behavior is modified: nextTrackGuarded, volumeLocked,
repeat-one handling, player bar height variables, and the platform
helper all remain untouched.
cc
Additive: adds optional autoUpdateEligible and onAutoUpdate props to
UpdateAvailableModal and UpdateCheckResultModal. When both are set,
the primary button becomes 'Download & Install' and 'Visit release
page' stays as a secondary option. Existing $t() translations are
preserved.
cc
Additive changes only: adds isAutoUpdateEligible() getter and
eligibility fetch in initUpdatesStore (keeps existing skipIfRemote
guards); adds performAutoUpdate() in updatesService for the
download/install/relaunch flow with cancellation support.
cc
New modal component that shows download progress, install and restart
states during an in-app auto-update. Uses a regular function (not
$derived with $t) for the status text to avoid Svelte Phantom CSS
Error (see ADR-001).
cc
Adds updates.autoUpdate.* keys (title, checking, downloading,
downloadingProgress, installing, restarting, failed) and
actions.downloadAndInstall / actions.downloadManually to all 5
locales (en, es, de, fr, pt).
cc
Adds TAURI_SIGNING_PRIVATE_KEY(_PASSWORD) env to release-linux,
release-linux-aarch64, and release-macos build steps, collects the
resulting .sig files into the release artifacts, and adds a new
release-updater-manifest workflow that waits for all platform sig
files to land on the release and assembles latest.json for the
updater endpoint.
Cherry-picked from #305 by @afonsojramos.
cc
Adds tauri-plugin-updater and tauri-plugin-process crates, the updater
config block in tauri.conf.json (with PLACEHOLDER_PUBLIC_KEY to be
replaced before release), capabilities for updater/process, and the
v2_is_auto_update_eligible command that returns true on macOS and
Linux AppImage installs only.
Cherry-picked from #305 by @afonsojramos.
cc
Supersedes PR #306 (dependabot conflict with pre-release).
Includes security fixes for redirect validation and BODY_SIZE_LIMIT
enforcement on chunked requests.
cc
When iOS controls via QConnect without sending a queue, the local
QbzCore queue is empty so auto-advance fails. Now we extract next_track
from each SetState command, store it in DaemonCore, and the orchestrator
uses it as a fallback when the local queue is empty.
Also adds a periodic state reporter (every 2s) so iOS knows the
renderer's actual state (playing/paused/stopped + position). Without
this iOS thinks the daemon is still playing after a track ends.
Bump version to 0.1.9.
cc
When QConnect controls the daemon, the local QbzCore queue is empty so
the orchestrator's auto-advance fails. Now on QueueUpdated, fetch all
tracks via get_tracks_batch and populate the local queue, mirroring
desktop's materialize_remote_queue. Bump version to 0.1.8.
cc
Skip redundant resume() when already playing, and ignore seek-to-zero
from server refresh echoes after renderer join. Matches desktop's
should_force_remote_track_restart guard logic.
cc
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