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
Preserve the remaining queue when shuffle is disabled, expose clear transport state tooltips, retain Library source filters for the session, and add independent track-artwork preferences for Queue and Library.
cx
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
Show SACD images in folder grouping, keep track details responsive, avoid repeated album reload work, and exclude disabled or unconfigured media servers from filters and sync.
cx
Measured on a 24-core box: 52 threads held 82 glibc malloc arenas, 257 MB
of fragmented anonymous mappings at idle, and the 400 MB in-memory audio
cache kept every played track until another evicted it. main now calls
mallopt(M_ARENA_MAX, 2) before the first thread (an explicit
MALLOC_ARENA_MAX wins), the tokio runtime runs eight workers instead of
one per core, and the playback poll moves the L1 audio cache to the L2
disk cache after 300 s with nothing audible locally, remotely or on a
cast. Idle RSS went from 728 MB to 537 MB; the trim verified at exactly
300 s after a pause.
cc
The scan never treated an .iso as audio; images only entered the library
through Open > disc image. Each root's scan now walks its .iso files,
asks qbz_disc::sacd::is_sacd_image for the SACDMTOC signature (PVD plus
Master TOC sector, four 2 KB reads at most, no directory parsing) so a DVD
or data ISO leaves silently, skips images already imported with the same
size and mtime without any I/O, and adopts the rest through the
generation-based import. The row builder moved from sacd_qt into
qbz_library::sacd_scan and the manual open is now its client, with the
translated fallback labels. Parser and import rejections are scan errors.
cc
DSD gets its own Quality chip and Hi-Res goes back to 24-bit PCM only, in
the MediaFilter, the native catalog SQL (tracks CASE and the materialized
album quality_tier) and the legacy QML filters. A Format chip would be the
same predicate, so there is none. Schema version 6 re-derives the album
tiers that were materialized as hires.
cc
/album/get sends streamable_at and release_date_stream per track: a future
value marks a pre-release, null a pulled track. Track carries both fields
and Track::is_upcoming applies the one rule (future streamable_at wins,
then a future release date); the album, playlist, search and label rows
publish qobuzUpcoming and the unavailable glyph reads "This track is not
yet available" instead of "no longer". Artist page rows carry no dates and
keep the generic copy. New msgid in the seven translated catalogs.
cc
The now-playing stamp bubble came out wider than its lines with the text
offset, and never reproduced in isolation. The three stamp ToolTips now
bind width/height straight to the content's implicit size with zero
paddings, insets and margins, leaving nothing for the style to add.
cc
A KEF LSX reached the end of every track and never advanced (#733); its
poll answers were swallowed, so an INFO log could not tell a mute renderer
from one that never reports STOPPED. The DLNA poll now logs a failed
position read, GetTransportInfo faults degrade to the last transport
command instead of failing the whole read, and a RelTime frozen inside the
end guard for five polls without STOPPED counts as end-of-track. A user
pause never counts. Pure helper with unit tests.
cc
rodio's Decoder::new leaves byte_len unset and the source non-seekable, so
symphonia's MP3 demuxer never estimates the duration of a file without a
Xing/Info/VBRI frame. The local play path then fell back to duration 0 and
the playback clock stayed clamped at 0 for the whole track, which the Slint
2.0.2 frontend read as a stuck load and restarted every 45 s (#734).
The decoder is now built with the buffer length, and an unknown (0)
duration no longer clamps the position. Regression fixture: a 2 s CBR MP3
written without an Info tag.
cc
Apply interface scaling through Qt at startup, complete keyboard navigation, move account migration into setup and progress modals, and defer Subsonic sync while the server scans.
cx
After a recovery advance (`recovery: engine empty … poll: advancing to X`)
the poll loop reset `last_track_id` and started the next track, but for
1-2 s (CMAF session + init segment, or a DSD→PCM conversion) the player
kept reporting the id of the track that had just ENDED. The 1 Hz tick took
that as a track change: `sync_current_to_id` rewound the queue cursor one
row and `refresh_now_playing` republished the previous track (now-playing,
MPRIS, notification), then the next tick flipped back — three
notifications per change, the middle one a ghost, intermittent because it
depends on the tick landing in that window. With the cursor rewound, the
gapless stage asked `peek_upcoming(1)` and was handed the track already
playing, so it played twice (smoke 2026-09-02, local DSD row; the queue had
no duplicate).
`stale_after_advance` remembers the ended id at the advance (0 under
repeat-one, where old == new); while the player still reports it and
nothing plays, the tick does its bookkeeping and skips the edge; any other
id or running audio clears it. The gapless stage also refuses a successor
equal to the player's own current track id. No change to the audio
backend.
cc
Same shape as the listen-log presence check: the workspace run already
executes them; the counts keep a renamed module or a dropped cfg from
shrinking coverage without a trace. The workflow calls the script, so the
mirror stays exact; its header names the new check.
cc
A new top-level Settings section, Import / Export, gathers everything that
moves data in or out of this install:
- SETTINGS PORTABILITY: the settings bundle export moves here from
Developer, with a note on what the bundle does not hold (favorites,
playlists, blacklist, purchases).
- BLACKLIST: export the blocked artists and albums as a JSON one user can
hand to another; import merges additively and never touches the enabled
flag (qbz_app::settings::blacklist_portable, frontend-agnostic).
- ACCOUNT MIGRATION: create a snapshot while signed in with the old
account, then migrate it into the account signed in now, with the local
profile copy and its three opt-in toggles (media servers, scrobblers,
listening history, all on). The disclaimer states that subscriptions and
purchases do not move. Progress and results are status lines in the
settings document; the favorites cache re-warms and the sidebar reloads
after a run, the local half asks for a restart.
Also: the header shows a translated "Member" for accounts without a
subscription, and the login screen says what a member can and cannot do.
50 new strings in all eight catalogs.
cc
A frontend-agnostic crate for moving a Qobuz account's favorites and
playlists into another account, additively (contract
qbz-nix-docs/specs/2026-09-02-account-migration-contract.md §9):
- snapshot: capture the signed-in account (five favorite kinds with
favorited_at, owned playlists with track ids in order, followed
playlists) into users/<uid>/account_migration/snapshot-*.json; the same
capture is the target's live state at migration time.
- plan: the pure delta against the target and the ledger. Same-name
playlists holding nothing foreign are the same playlist and get the
missing tracks appended; anything foreign means a different playlist and
a " (migrated)" copy. Favorites are written oldest first so the target's
own stamps keep the chronology.
- apply: one favorite per call with pacing and bounded retries, 50-track
chunks, subscriptions re-followed; every success lands in the ledger
before the next write, so a run resumes and a second run adds nothing.
- local: copy the old profile directory row by row (library folders and
scanned tracks on the fast path, playlist folders, settings keyed by
qobuz_playlist_id remapped through the ledger, pinned items, orders,
preferences, blacklist; media servers, scrobblers and listening history
behind options). Never the favorites cache, the subscription clock, the
session or the purchases.
The writes are the ones the app already issues when a user hearts or
creates a playlist; nothing touches subscriptions, purchases or account
data.
cc
The 30-day offline grace (D4) was produced by the Qt shell alone, and only
when a login was REJECTED; its purge predicate had no caller in the live
tree, so past the window the CMAF cache was blocked from playing but never
removed (Tauri purged it; Slint recorded the gap). With member accounts now
signing in, the clock has to key on the entitlement, not on a rejection,
and it has to run wherever a session activates.
`qbz_app::subscription_lifecycle` records the verdict from the core's
session (`offline_streaming` clears the clock; a member keeps it running
from its first observation) and purges the offline cache once the window
has passed, through the cache a host registers with
`AppRuntime::set_offline_cache`. `activate_at` runs it for every host (Qt
via `activate`, qbzd directly), a daily ticker per session re-checks it,
and a cache registered after activation is enforced against on
registration. qbzd registers no cache and keeps the verdict. The Qt
producers are gone; `offline_playback_allowed` stays the read gate.
cc
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
Qobuz answers 200 for an account without an active subscription, but the
login parser rejects it (IneligibleUser) before anything can be captured.
Add `exchange_oauth_code` (OAuth step 1, extracted from
`login_with_oauth_code` unchanged), `login_with_token_raw` and
`get_stream_url_raw`, which return status and body verbatim without
parsing or storing a session, and a probe example that signs in through
the same system-browser OAuth flow as the app (or a pasted token), then
prints the redacted login payload, favorites totals, playlists and the
getFileUrl responses at CD and Hi-Res quality. Nothing is written to
Qobuz or to disk.
cc
Merges bugfix/internal/purchases-albums-tab (17 commits): the blank
Albums tab root cause (on-prefixed QML property, now gated by an audit),
entitlement-driven formats with the streaming ladder below the purchase,
intent=download grants and a DSD-safe streaming writer, album-artist
folders with master cover and goodies, the purchased copy playing under
its Qobuz id, the DSD tier and mark across purchases and the Local
Library, purchases list/detail polish with a genre filter, and active
downloads in the sidebar. Gates: source 95, offline-cache 75+9,
qbz-qt 663, six QML audits; owner smokes on the live purchases account.
cc
The Downloads section leaves the nav flyout. With the sidebar open, the
purchases downloading now sit under the Purchases entry, one row per
album with a thin progress bar, each reopening its detail. With the
sidebar collapsed, and in the compact header nav when it is closed, they
fold into one progress ring whose hover bubble lists every album with
its count and percentage. The ring is a Canvas that repaints only when a
publish moves the value — no timer, per the repaint-pulse rule. The
active set now carries done/total/percent per album.
cc
The list view's quality column was 110 px for a badge that is 136 px
wide, so the badge ran into the release-date column; it is 150 now, the
album list's width. Both list rows and track rows draw the odd-row
zebra every other list in the app draws. The filter panel gains a Genre
section built from the catalog genre on each purchased album (albums
tab only, transient like group and sort), counted and summarised with
the other filters.
cc
The local rows, the source-side quality hint and the MyQBZ badge still
tiered a 1-bit file as "hires"; they now say `dsd`, so the same DSD mark
the purchases show reaches every local surface, and the Local Library's
hi-res filter keeps counting DSD as hi-res.
cc
The downward streaming ladder read `album.hires` to add 27/7, but the
live DSD album is hires:true with a 16/44.1 stream, so "[FLAC][24-bit,
192kHz]" was offered and the CDN filled a folder by that name with CD
files. Gate the two hi-res rungs on the catalog's `hires_streamable`.
cc
The purchase detail badges what was bought on every row (DSD128 on a
DSD purchase, not the catalog's 24/352.8), marks the formats already on
disk with a check in the dropdown, offers a track filter, links to the
album in the Local Library once its folder is indexed, and gives the
download the accent while Play drops to a plain control of the same
size — on this screen the download is the point.
Downloads are process-wide and survive leaving the page; the nav flyout
now lists the purchases with a download in flight under "Downloads · N"
so the user can find them again, each row reopening its detail.
cc
A 1-bit master wore the Hi-Res logo and "24-bit / 352.8 kHz" — a stream
Qobuz never serves (nothing above 24/192 streams). Give DSD its own
tier: the official DSD mark, baked light and dark so the monochrome logo
follows the theme, on the mini badge, the full badge and the now-playing
stamp, with the multiple (DSD64 / DSD128) as the detail line. The
now-playing derivation and the delivered-tier evaluation report `dsd`
for a 1-bit stream instead of folding it into hires.
cc