Commit Graph
6233 Commits
Author SHA1 Message Date
vicrodh 99722aedaf feat: an unreachable track can no longer stop playback
Two owner requirements, one shape: something that cannot play must be
skipped, and cleaned when there is nowhere left to skip to. The causes
are outside our control — Qobuz withdraws a track overnight, or a
local/Plex file sits on a share this network cannot see today.

THE FUNNEL, which is where the real gap was. `play_current_if_local`
returned a bool whose two `false`s meant opposite things: "not my row,
run the Qobuz path" and "my row, and it cannot play". The caller could
only act on the first, so an unreachable local file fell through to the
Qobuz tier-walk, failed there with an unrelated error, and
`auto_skip_unavailable` then declined it as non-terminal. One dead file
stopped playback silently. It is now a three-state `LocalPlay`, and a
local failure takes the SAME bounded walk a pulled Qobuz track takes —
to the user they are the same event.

CLEANING. Both dead ends of that walk (budget spent, queue edge) now
call `purge_unavailable_upcoming`, which drops only rows
`queue_track_unavailable` already refuses to play, walking backwards so
a removal cannot shift an index still to be visited. A track on an
unreachable share can never land there: it is Unreachable, not dead.

PROBES THAT CANNOT HANG (`qbz-library/src/reachability.rs`, 4 tests).
The machinery for network shares already existed — mount_info,
`library_folders.is_network`, a populated `local_tracks.is_network_mount`,
even an unreachable-folder guard in scan.rs. Every one of them probed
with a bare blocking syscall, and a mounted-but-unreachable share does
not make `exists()`/`read_dir()` fail, it makes them BLOCK for the
mount's timeout. The guards could not fire; scan.rs hung on the very
condition it existed to detect.

Three states, not two, and that distinction is the whole safety
property: `Missing` is the filesystem saying the file is gone and may
be cleaned; `Unreachable` is it saying nothing, and may only ever be
skipped. Collapsing them — which a bare `exists()` does — would delete
a user's NAS library because they changed networks.

The deadline goes on the PROBE only, never the transfer: a hi-res FLAC
over a working-but-slow share legitimately takes longer than any probe
budget, and timing the read out would turn "your network is slow" into
"this track is gone". A blocked syscall cannot be cancelled, so a
timed-out probe leaks its thread; the mount is therefore remembered
with a cool-down, one probe per dead mount rather than one per track,
which on a 2000-row queue is the difference between a defence and a
thread bomb.

PERSISTENCE. `busy_timeout=1000` on all 18 store pragmas. Deliberately
NOT the 2-3s discussed: these writes run on the Qt thread, so a 3s wait
would install exactly the stall this whole sweep has been removing,
while today's 0 means contention loses a setting silently and instantly.
One second resolves transient contention without a visible stall.

`home_bridge::ui` announces both of its silent failure modes. Three
builds went into proving it was NOT dropping the Home document during
the launch freeze; one warn line would have answered it in one.

Verified against the owner's real session state (1935-row queue): the
app launches to a painted Discover with no QML errors and no dropped
publishes.

cc
2026-08-18 12:40:52 -06:00
vicrodh 47a7641fc2 fix(qt): bound the queue panels, and make the gate catch what it missed
Follow-up to 66ba12bd5, which fixed the coverflow but left two of its
neighbours and a gate that could not see either of them.

QueueTabsPanel built one ImmQueueRow per queue track when opened —
including every collapsed 0-height history slot, which on the reported
session was 1162 of 1935. The slot stays (its height is what makes the
collapsed-history arithmetic work); the row inside is now built only
within a viewport of the visible band.

SuggestionsPanel and AppShell's QueuePanel move behind Loaders. The
queue panel was never the freeze — its rows are paginated at 40 in
Rust — but a closed panel should not be constructed, and the audit
cannot know about a cap that lives on the other side of the bridge.
Gating it also let SuggestionsPanel drop the onVisibleChanged half of
its load hook: mounting IS becoming visible now.

The audit is the real content of this commit. Written narrow — only a
model fed by unbounded user data counts, because the wide rule reports
145 sites and a gate that noisy gets switched off in a day — it then
reported the pre-fix tree CLEAN. A gate that cannot catch the bug it
was written for is worth nothing, so it was tested against that tree
and fixed on two counts:

  - CROSS-FILE. The `visible:` sat in ImmersiveView.qml while the two
    Repeaters over the whole queue lived in CoverflowPanel.qml. There
    is now a transitive index: a component is heavy if its own file has
    an unbounded view or if it mounts something that does.
  - MULTI-LINE. The coverflow's gate spans two lines, so a first-line
    read saw `QbzShaderScene.scene === 0`, matched no known gate, and
    skipped it.

With both, it finds all five mounts on the pre-fix tree, including the
two that froze the app. Known gates also grew past tab predicates: the
coverflow was keyed on QbzImmersive.mode.

Verified on a copy of the real session state with the 1935-row queue
restored: launches to a painted Discover, idle at 1.0% CPU.

cc
2026-08-18 07:48:56 -06:00
vicrodh 0ed9db2f3f fix(qt): stop the Weekly rows rebuilding the whole Recommendations tab
Reported as "it loads, then it flashes and loads again", and only on
that tab. Measured: the cached blob paints at t+0 and the two Weekly
rows land at t+0.9s — and that is with BOTH of their per-week caches
hitting, so the delay is ListenBrainz's playlist listing, not the
tracks. Their arrival called publish_snapshot, which republishes the
document, which hands QML a brand-new array; QQuickItemView::setModel()
then tears down every rail's QQmlDelegateModel and rebuilds it. Nine
rails destroyed and rebuilt to deliver two, a second after the tab had
already settled.

Not the artwork, which was the obvious suspect: the same run logged
"0 artwork misses".

Split them out the way HomeView already splits pinned / radio /
spotlight, for the same reason and with the same shape: the document
carries an ordering slot, the rows ride reco_weekly_json, and rebinding
that re-creates only those two delegates. Every other rail's sectionData
resolves to the same modelData reference, so a Weekly landing
re-evaluates nine bindings and rebuilds two.

The slots are emitted unconditionally (a new `slot()` helper) rather
than self-hiding on emptiness like `section()` does — a slot that
vanished while empty and reappeared when filled would change the
document, which is the republish being avoided. QML hides an empty one
instead, on the gate the other out-of-document rails already use.

Weekly covers are attached in the same split, so a late cover
republishes that property rather than the document: two rails, not
nine.

cc
2026-08-18 07:25:37 -06:00
vicrodh 19836547d9 feat(qt): give Recommendations its cache window and Refresh now
The gear was dead on that tab and the cache window was unreachable, so
whatever hours value happened to be in the shared prefs was permanent.
It reads 24h on this account; the reference offers 24/36/48/72 and
defaults to 48.

Only the WRITER was missing. `reco_cache_ttl_secs()` already read
`reco_cache_ttl_hours` from the shared per-user discover prefs, and
`refresh()` was already written — and carrying `#[allow(dead_code)]`
because nothing called it.

TTL_HOURS is the reference's table verbatim (discover_prefs.rs:100).
That matters because the value lands in the file BOTH builds read: if
the two disagree on the index mapping, a window chosen in one reads
back as a different one in the other. The QML option order is the other
half of the same contract, noted at both ends.

The setter republishes the RESOLVED index rather than the one it was
handed, so a failed write (no per-user store) snaps the select back to
what is actually stored instead of showing a window nobody saved. The
index is seeded at shell entry next to publish_settings(), the first
point where the per-user directory is known — without it the select
would open on the store's 48h default and misreport anyone who chose
otherwise.

The modal now has two modes on the same six blocks the reference gates
(DiscoverConfigModal.slint:187/213/221/250/261/326): section tabs keep
the list, banner, count and reset footer; Recommendations hides those
and shows the explainer, the window select and Refresh now. Its
empty-state line is hidden there too — DiscoveryTab has no
"recommendations" arm, so an empty row list is by design and "No
sections to configure yet" would be a permanent lie.

All seven strings already existed translated in all eight catalogs.

Two comments removed on the way: both claimed the external reco engine
was "not ported" and justified the disabled gear with it. It has been
ported for a long time (recommendations_qt.rs, 745 lines) — the fossil
was the only thing still keeping the setting out of reach.

cc
2026-08-18 07:25:37 -06:00
vicrodh ccd5272cc3 feat(qt): let a nav-bench hop name its landing tab
`library:all`, `local:tracks`. A tab switch inside a mounted view
changes no route, so the route stopwatch never fires for it — hopping
away and back with a landing tab is what puts a given tab's cost on the
clock, which is how the Library grid/list measurement was taken.

It also drives the nav-flyout entry path (navigate_to_tab) rather than
the plain sidebar one, so the gate now exercises both.

cc
2026-08-18 07:25:37 -06:00
vicrodh aa2f0362d9 perf(qt): stop Library building the body it is not showing
The grid and the list are siblings, both anchored to fill the content
area, and both bound to `visibleRows` — and `visible: false` stops a
QQuickItemView from painting, not from refilling. Measured with a
per-view creation counter: on Albums the grid built 51 delegates and
the hidden list built 47 beside it; on Tracks the list built 47 and the
hidden GRID built 51, the expensive direction since a FeedGridCell
carries a cover. A mount went 368ms -> 216ms, to-idle 727ms -> 441ms.

The predicates above them already claimed this ("one predicate per
body, so no two can mount at once"); they only ever governed painting.

Gated by collapsing the height rather than by swapping the model: this
file spends a paragraph on why handing `model` a new value is harmful
(QQuickItemView::setModel resets the scroll offset and rebuilds every
delegate — the "a heart click in All throws me back to the top" bug),
and the height gate leaves `model` untouched.

Also: the derive and the feed parse are now timed under the shared
qbz.nav.timing category, deferred through Qt.callLater. The defer is
required, not tidiness — `visibleRows` is evaluated during creation,
and a LoggingCategory used before the component completes never gets
its defaultLogLevel applied, which Qt warns about and which left the
logging ON for every user. The parse log was previously a bare
console.log doing exactly that.

Measured for the record: the derive is NOT the cost. Over a 354-item
feed it runs in 0-13ms, so the O(n log n) toLowerCase in its sort
comparator is a real but currently theoretical problem.

cc
2026-08-18 07:25:37 -06:00
vicrodh 66ba12bd53 fix(qt): stop a long queue freezing the app at launch
A session with 1935 persisted queue rows came up frozen for ~28s at
120% CPU, every launch, deterministically. It looked like corrupted
local state — moving ~/.local/share/qbz aside "fixed" it — but nothing
was corrupt: that is simply a queue the UI could not carry.

The chain. AppShell mounts ImmersiveView unconditionally (its own
comment: while closed it is "an invisible, non-interactive Item"), and
`visible: false` stops an item painting, never building. CoverflowPanel
reads the flat WHOLE-queue document — unlike the queue panel's, which
paginates at 40 — and runs two Repeaters over it, the forward list and
a reversed copy. So every launch built ~4000 CoverCards, each an image
with a masked layer, for a panel nobody had opened.

Two gates, because either alone leaves it broken:

  - CoverflowPanel and both QueueTabsPanel mounts move behind a Loader
    keyed on the condition they already carried in `visible:`. That is
    the launch freeze.
  - The coverflow delegates become zero-size, non-clipping slots whose
    CoverCard exists only within |offset| < 6.5 (the fan paints 3.5;
    the margin covers the 320ms ease). That is opening it, which the
    Loader alone would still freeze.

The reference does create one element per row and hides |offset| > 3
(CoverflowPanel.slint), and that is fine there: a Slint element is
cheap where a QML delegate is not. Copying its shape literally is what
cost the 28 seconds.

Verified against the real session state, queue restored, not a
synthetic one: 120% CPU -> 2.9%, and a screenshot of Discover fully
painted where it used to sit on the skeleton.

cc
2026-08-18 07:21:03 -06:00
vicrodh a6b373cb5a build(qt): fail the build on a tab body that is built while hidden
The class the previous commit fixed leaves no trace a reader would
notice: the view renders correctly, cargo is happy, both existing
audits pass, and the only symptom is that entering the section takes
seconds. So it needs a static gate or it comes back.

qml_eager_tab_audit.py reports a per-tab body gated only on `visible:`,
and reports it only when it is genuinely heavy — it must instantiate a
Repeater/ListView/GridView AND that view's model must be data rather
than an inline literal. Without the second condition it flagged the
sort popups in LibraryToolbar, which are Repeaters over four fixed
options, and three false positives would have buried the one real
finding.

cc
2026-08-17 17:49:22 -06:00
vicrodh a4c29c796c perf(qt): build the tab that is showing, not all of them
Entering Discover froze the UI thread for 1.52s — not slow, frozen:
zero pixels changing anywhere in the window — where the Slint build
painted the same page, artwork included, in a single frame. Measured
frame by frame from a screen recording, with the click located at the
moment the nav flyout disappears.

`visible: false` hides an item; it does not stop QML from building it.
HomeView declared its four Discover tabs as sibling Columns gated only
on `visible:`, so every mount instantiated all four and 19 of its 28
section rails belonged to tabs nobody was looking at. Gating each body
on a Loader's `active:` cut a Home mount from 426-512ms to 182-190ms,
and the tab block itself from 364-444ms to 134-140ms.

Synchronous, deliberately: incubation is time-sliced, so `asynchronous`
spreads the same work over more frames instead of removing it.

Three more of the same class, found by auditing for it:
  - SlimGrid's delegate Loader had no `active:`, so it built a full
    card for the items past `total` and then hid them.
  - LocalLibraryView mounted all four tab bodies, the Tracks one being
    the 16K-row view.
  - AlbumInfoModal built a credits row per track of the album even when
    the Description tab was showing.

ContentRouter's header claimed the fix was to keep views alive across
navigation. It is not, and the references say so: Slint destroys its
view tree with `if` (AppShell.slint:426) and Tauri removes it from the
DOM (+page.svelte:6286), and Tauri even refetches from the network on
mount while still transitioning instantly. Neither gets its speed from
caching the view — both keep the mount proportional to one viewport.
The rule is the SIZE of a mount, not its lifetime. Paragraph rewritten
to say that.

cc
2026-08-17 17:49:11 -06:00
vicrodh 8015439304 feat(qt): drive navigation without a GUI, and time what a route costs
The cost of a route change is wall-clock on the UI thread, so no cargo
test and no static audit can see it: measuring it needs the real app to
actually navigate. QBZ_QT_NAV_BENCH=home,library,home walks a hop list
after the first home publish, which paired with the qbz.nav.timing
probes prints what each mount spent.

It also mounts views the offscreen gate never reaches. That gate keys
off QbzShell.currentView and nothing navigates during it, so it only
ever instantiates the startup view — which is how two entirely
unloadable modals and a live ReferenceError once survived fifteen
parity rounds.

cc
2026-08-17 17:48:58 -06:00
vicrodh e779b6ec1e fix(qt): a pulled track's menu offers nothing that leads nowhere
Smoke found the row rendering correctly and the menu still offering four
entries that cannot work on it.

Add to mixtape and Add to playlist are refused, in both row implementations.
The precedent was already there -- `hasPlaylistAddSeam` excluded a broken local
ref for the same reason -- this is the same idea with a different cause. Gated
on `pulled` rather than `pulledDead`, so a downloaded copy is refused too:
what a playlist or mixtape stores is the Qobuz catalog id, which is dead on
every other device, every other surface and for anyone the list is shared with.
The local copy playing here does not make the row safe to seed elsewhere.

Both share links go too. Qobuz's page for a withdrawn track is gone and
Song.link resolves through it, so each one rendered and led nowhere. Track info
as well: it needs the catalog record the pull took away.

That empties the menu on a surface that does not offer the repair -- an album
page, search results -- so the row menu no longer opens when its model comes
back empty. An empty popup reads as a broken control rather than as "there is
nothing to do with this row". The ArtistView fork keeps its go-to entries and
can never reach that state.

cc
2026-08-17 15:37:12 -06:00
vicrodh b2c74c4140 feat(qt): find an available version for a track Qobuz pulled
Reachable from a dead row's context menu, in a playlist the user owns. The
reference had this and shipped it with four defects; none are ported.

It was unranked -- Qobuz's raw relevance order shown to the human. The weighted
matcher that already lives in qbz-playlist-import does the ranking now, and its
ISRC short-circuit matters most: licensing churn usually republishes the SAME
recording under a new id with the SAME ISRC, and the pulled track keeps its
isrc (confirmed in the 2026-08-17 capture of album 0886443985094). That case
needs no human judgement, so the ISRC lookup runs first and pins its hit to the
top.

It removed before it added, so a failed add lost the track outright. The order
here is add, reposition, remove.

It did not preserve position -- the computed index went to a console.log and
the replacement landed at the end. It lands where the dead row was, through the
new updateTracksPosition verb. Every failure in that step is a warning and a
different success toast: the repair still stands at the end of the playlist,
exactly where the reference always put it. Position is a nicety, never a reason
to abort a repair.

Two guards. If the ISRC lookup answers with the dead track itself -- it can,
since a pulled track keeps its metadata and search still indexes it -- then
"replace" would be a no_duplicate no-op followed by a remove, and the user
would lose the row while being told it was repaired. And when the remove fails
the playlist permanently holds both rows: that is not rolled back, because a
rollback is a second write that can fail the same way and would delete the only
good copy. The list is refreshed so the user SEES both, and the toast says the
old one has to go by hand rather than claiming a reload will fix it.

cc
2026-08-17 14:41:39 -06:00
vicrodh 7780efa5db feat(qt): mark tracks Qobuz pulled, and keep them out of the queue
Qobuz withdraws tracks: sometimes a recording, sometimes only a quality tier,
sometimes it republishes the same recording under a new album id. The row keeps
appearing and simply cannot play. Until now the user found out by clicking it.

The app already knew and threw the answer away: eight builders hardcoded
`streamable: true`, so the API's own flag never reached a row model. The
reactive half was already here and stays -- a terminal failure still toasts and
skips, bounded at five consecutive, with the 403 back-off kept separate so a
403 storm cannot burn five good tracks.

Detection has two clauses. The API's `streamable: false`, and a session-only
memory of tracks that died terminally under the player this run: /album/get is
honest, but plenty of rows only fail at the stream-url step, and that memory is
what stops the queue re-offering them. Session-only on purpose -- the reference
kept the equivalent set in localStorage, never expired it and never revalidated
it, so a track Qobuz restored stayed dead forever.

Dead rows are dimmed, get an alert glyph and a tooltip, lose click, drag,
favourite and download, and STAY visible and right-clickable: hiding them would
defeat the point and remove the only route to a replacement. Qobuz's own player
does exactly this, and the pulled track carries displayable:false while still
being rendered -- that flag is not a hide instruction.

A downloaded copy is exempt everywhere. The offline tier resolves before any
availability question, so a pulled track already on disk plays fine; dropping it
from the queue would take away something the user downloaded, and it would bite
hardest offline. Those rows say they are playing the local copy and keep their
play affordance.

Two seams filter, not one, and both had to learn the surviving anchor: every
caller read `first_id` from the UNFILTERED list, so the seam dropped that row
and the app then asked the player for the id it had just discarded -- silence,
no toast. That was already reachable today through the artist blacklist. The
seams now return the surviving start and its id.

Two id bugs surfaced on the way and are fixed here. A playlist row's
`playlist_track_id` was filled with the CATALOG id; they are different numbers
for the same row, one naming the recording and the other its slot in that
playlist, and Qobuz's delete and reposition verbs both take the membership id --
so "remove from playlist" was addressing a row that does not exist. And the For
You rows were built with `..default()`, claiming availability while the queue
built from the same track read the truth.

Catalogs: the two strings this needed already existed in all seven translated
locales and are reused; the skipped-tracks toast is a plural pair, and the
replacement feature's strings ride along here since they share the files.

cc
2026-08-17 14:41:26 -06:00
vicrodh 8e52157299 feat(qobuz): add the updateTracksPosition verb
Reordering tracks inside a playlist had no client verb: addTracks takes only
[playlist_id, track_ids, no_duplicate] and appends, and updatePlaylistsPosition
reorders the user's PLAYLISTS, not the tracks within one.

The endpoint exists and is documented from a desktop web-player capture --
{playlist_id, playlist_track_ids, insert_before} -- not from the decompiled
Android route tables, which are not a valid source for this. Its exact argument
encoding is the one part no capture in the repo pins down, so every caller must
treat a failure as recoverable rather than fatal.

Written for the replacement flow, which needs the repaired track to land where
the dead one was instead of at the end of the playlist.

cc
2026-08-17 14:41:05 -06:00
vicrodh 371be04be8 feat(models): Track.streamable becomes Option<bool>, and absence stops meaning dead
`#[serde(default)]` on a bare bool means an absent key deserializes to FALSE,
so a catalog Track from any endpoint that omits `streamable` came back marked
unavailable. The models' own tests document this as a hazard rather than a bug
(the "streamable split": PurchaseTrack defaults true, catalog Track defaults
false, and the comment warns that a wrong default "inverts that whole screen's
behaviour"). The inferred OpenAPI disagrees with the code and documents
`default: true`.

That was survivable only because nothing read the field. It stops being
survivable the moment a frontend greys rows out and keeps them out of the
queue: one terse endpoint would blank an entire view, silently.

`None` now means the endpoint made no claim, and only `Some(false)` marks a
track pulled. `is_streamable()` is the ONE place absence is interpreted, and it
resolves to available: refusing to play a track because a response was terse is
strictly worse than playing one that turns out to be dead, which the reactive
skip path already handles. Album.streamable was already Option<bool> with a doc
comment naming the same causes, so this makes Track consistent rather than
novel.

Every consumer in the workspace moves to the helper: both matchers, the ISRC
validator, the daemon driver, the purchases service (which maps catalog Track
into PurchaseTrack and therefore feeds the very screen the split protects), the
radio pool, the mixtape resolver and the QConnect renderer. The replaced tests
pin both directions of the new contract.

Also here, because it lives in the same file as the matcher's streamable skip:
`rank_candidates`, an additive public entry point over the existing weighted
matcher (ISRC short-circuit, title/artist/album weights, duration bonus,
stop-word normalisation, quality tie-break). `match_tracks` is untouched.

cc
2026-08-17 14:40:57 -06:00
vicrodh 3f5f27bba5 fix(qt): local playlist covers, and the playlist ref guard gets wired
Two dead-code warnings that were hiding real defects.

A local playlist's cover was written to one store and read from another. The
header menu persists through `cover_artwork_qt` to custom_playlist_covers.json
-- deliberately separate, since a `playlists` key inside the shared
custom_artwork.json is dropped by the other build's next write -- while the
detail document read `library.db`'s custom_artwork_path, which nothing in this
port writes. So a cover the user had just picked appeared in the sidebar and
never in the header, and has_custom_cover stayed false, which meant the menu
only ever offered "Add cover": Remove was unreachable. The document now reads
the same store the menu writes. The DB column survives as a read fallback so a
cover stored there by an earlier build is not silently lost, and the clear path
is kept and wired into remove_custom_playlist_cover -- without it, such a cover
stayed on screen with the menu insisting it had been removed.

PlaylistRef was a documented type guard nobody constructed. Nine call sites
hand-rolled it as is_local_id() then a separate parse::<u64>() -- correct at
each site, but it left "a ref classified by the wrong half" representable, and
that id-space hazard has already produced real bugs here: a LocalLibrary row id
is a small integer that parses as a perfectly valid Qobuz id, it just means a
different track. Classification now happens in one place, and a new call site
cannot forget the test.

cc
2026-08-17 14:40:40 -06:00
vicrodh 05c5799192 feat(qt): Detected device limit row, its refresh triggers and cache drop
The last unported row of Settings > Audio. Read-only by design: a Hz
dropdown is the documented root-cause trap, because it promises rates the
app cannot request when Qobuz sells four discrete tiers. The value is
Rust-composed from the probe and the row hides on an empty summary; the
fallback caveat renders only when the probe fell back to the common set.
No new msgids -- both strings already exist, translated, in all 8 catalogs.

Six refresh triggers, one more than the reference: startup, the toggle, a
backend switch, an output-device change, reset-to-defaults, and the device
refresh/release button. That last one is Qt-only and the easiest to miss --
it is the hotplug case, where the hardware behind a "System default"
selection changes without the selection changing.

Ordering is persist -> refresh -> publish, and the refresh goes BEFORE
apply_audio rather than merely before the handler's closing publish:
apply_audio publishes on its own way out, an earlier serialize that would
ship a stale summary. The symptom of getting this wrong is a row that is
only right after reopening Settings.

The boot trigger publishes the snapshot itself after awaiting the refresh.
The boot snapshot is spawned separately and can serialize first, which left
the row absent for a user who already had the toggle on until some
unrelated mutation happened to republish.

A cap change drops the tier-keyed audio cache, symmetric with the streaming
-quality change: the cache is quality-blind, so bytes fetched under the old
tier would keep serving and the feature would be inert on anything already
played. The first refresh of a process is exempt -- see the CapChange
classification, which exists because clearing there wiped the disk cache on
every launch.

cc
2026-08-17 11:09:37 -06:00
vicrodh 3aed6d3ea6 fix(qt): every local play goes through the funnel, which now owns the tier
play_resolved_offline_aware was meant to be the single point every local
play passes through, but the quality was a PARAMETER of it, so seven
callers re-resolved the same value and five bypassed it outright.

The five bypassers are a live bug with nothing to do with #638. MyQBZ (x3),
playlists and the gapless prefetch called the core directly with None, None
for the offline handle and the row sink, and 0 for the resume position. So
playing a collection or a playlist ignored the offline cache entirely: a
downloaded track re-downloaded, and failed outright with no network, while
never feeding the offline-cache progress row. Gapless had the same hole
under a comment claiming this port opens no offline index, which the funnel
above it already falsified.

The funnel now resolves the request tier itself through
local_playback_quality, which reconciles the streaming preference against
the detected device cap. It costs two uncontended lock reads, so there is
no argument for hoisting it back out. quality_state drops its own cap-free
copy and calls the same function: a badge that reports a tier the play path
did not request is worse than no badge.

route_play_to_peer deliberately stays at the call sites. play_queue_track
needs the peer route BEFORE its local-file branch, and the MyQBZ collection
play does not early-return on a routed play because its recency bookkeeping
runs either way; hoisting it would force a tri-state return and weld a
routing decision onto a quality one.

cast_qt::effective_cast_quality still reads the UNCAPPED preference. The
local DAC is not in a cast's signal path, and capping it there is #638 in
reverse: a 48 kHz DAC silently limiting a 192 kHz DLNA renderer. A source
audit test pins the exact call-site set, since no runtime test can reach it.

Known limit, documented at the funnel: the cap governs what is REQUESTED.
An offline copy already on disk is served verbatim, because downloads are
always taken at the top tier.

cc
2026-08-17 11:09:26 -06:00
vicrodh f75e1a4f47 feat(app): add frontend-agnostic device_cap module (#638 fix 3)
The local output-device quality cap lived in the Slint binary crate, which
is why the row never reached the Qt frontend: it is app-glue in a crate Qt
cannot depend on. Added to qbz-app, which already depends on qbz-models,
qbz-audio, tokio and log, so no new dependency edge. The Slint copy is left
untouched and keeps its own now-stale version.

Two behavior changes over the original, both deliberate:

Tier boundaries are tightened. A tier is a CEILING the service may deliver
at, so naming one the device cannot play defeats the cap: an 88.2 kHz DAC
must not resolve to Hi-Res (deliverable at 96) and a 176.4 kHz DAC must not
resolve to Hi-Res+ (deliverable at 192).

refresh() now returns a CapChange so the caller can decide about the audio
cache without a racy before/after read. The first refresh of a process is
Seeded, not Changed: the cap cache starts empty, so at boot None -> Some
looks like a user change, and the invalidation it triggers unlinks every
cached audio file on disk. A naive comparison wiped the whole playback
cache on every launch with the cap enabled.

A generation counter, re-checked while holding the CAP write lock, refuses
a superseded commit. Without it "enable (slow probe) then disable" could
let the stale probe resurrect a cap the settings say is off; checking the
counter before doing the work left exactly that window open.

refresh() also takes the active backend, so a "System default" selection
resolves through the backend that is playing instead of always PipeWire.

cc
2026-08-17 11:09:14 -06:00
vicrodh bad0e3aed3 fix(qt): search album cards read the nested audio_info and dates too
Sweep after the award-grid fix, comparing every album-shaped surface against
the reference's canonical reader (`album_map::map_album`).

Search was the one live gap left, and it is not a guess: `search_albums`
returns the very same `SearchResultsPage<Album>` that `get_award_albums`
does, and that endpoint demonstrably answers with the nested fields only.
Its mapper read `release_date_original` and `maximum_bit_depth` flat, so the
same missing date and missing badge the owner saw on the award grid were
sitting in search results.

The three chains are shared helpers now (`album_release_date`,
`album_audio_parts`, `album_quality_tier`) rather than a third hand-rolled
copy — the next album surface gets them by calling, not by remembering.

Everything else in the sweep is already correct or matches the reference:

  - nested-aware already: album page cards, artist, artist scene, library /
    favorites, label, myqbz builder, playback
  - flat in BOTH frontends, so shared debt and not a port gap: the album page
    HEADER (album.rs:275 reads flat too), the album info modal, and
    purchases
  - every Track reader is fine by construction — `Track` has no `audio_info`
    and no `dates` fields at all

cc
2026-08-16 21:10:58 -06:00
vicrodh 5d1448b23e feat(qt): pin the award page's controls in a nav bar
Search, shuffle and the award picker are one right-aligned cluster in a
pinned 44px bar now, given the ArtistView jump-nav treatment: its own fill,
a 1px bottom hairline, the page's 32px rhythm, and a slot in the flow that
reserves its height so the hero does not jump when the bar is lifted out.

Order inside the cluster is left-to-right from "narrow this" to "leave
here": the search and the shuffle act on the page you are on, the picker
takes you off it.

Carries the rounded-corner rule this codebase has hit twice already
(ArtistView.qml:2795, the Discover full-bleed toolbar): pinned at the pane's
top edge the bar's square corners poke out past the shell's rounded bezel,
so the top corners round — but only WHILE it is at the top, driven off the
live y. Mid-page the bar is surrounded by content and a notch there is worse
than the square corner it fixes.

The picker still tracks the OPEN award: `currentIndex` is the document's
`catalogIndex`, so navigating to another award moves the selection with you
instead of stranding it on the one you came from.

cc
2026-08-16 20:12:45 -06:00
vicrodh c2218dcdb2 fix(qt): read the nested audio_info and dates on album cards
The award grid drew cards with no release date and no quality badge while
Discover's looked fine, and the reason is the same one twice:
`map_flat_album` read only the FLAT fields, and /award/getAlbums answers
with the nested ones. The reference's `album_map::map_album` has read both
shapes all along.

Three fallback chains, ported from it:

  - date: nested `dates` (original > download > stream), else the flat
    `release_date_original`
  - bit depth and sample rate: `audio_info` first, else the flat pair
  - tier: falls back to the hi-res FLAGS (`hires || hires_streamable`) when
    no depth arrived at all. A payload can say "this is hi-res" without
    saying how, and the old match answered "" — no badge — for exactly that

Also `> 16` rather than `>= 24`: a 20-bit master is hi-res.

cc
2026-08-16 20:12:45 -06:00
vicrodh 2d33a4fdfb feat(qt): search, load more and random on the award landing page
Three controls so the landing is usable without bouncing through "See all".

Search sits in the section header and shares the LISTING's query — one
award, one filter — so what you type here is still in force behind See all.
Client-side over the loaded set, like the listing and like the reference.

Load more is a BUTTON, not infinite scroll. The listing sub-view owns the
scroll-driven paging; this page has the "Other awards" rail under the grid,
and auto-loading would push that rail out of reach forever. Suppressed while
a query is active, for the listing's reason: paging in rows the filter then
hides is work with no visible result.

That button needed the preview cap gone. The landing document published
`take(PREVIEW_PAGE_SIZE)`, so "load more" would have fetched rows the view
threw away. The first fetch still asks for one preview page, so the first
paint costs what it did before; the cap is now only on the REQUEST, not on
what the document carries.

Random plays one album out of the LOADED set (the LibraryToolbar precedent:
pick a visible row, `QbzPlayer.playAlbum`). Not the whole award — the rest
is not here to pick from, and reaching for it would put a blocking fetch
behind a button that should feel instant.

Every mutation republishes BOTH documents now: they share one accumulating
album list, so a page loaded or a query typed on either side is visible on
the other.

No new msgids — "Search albums & tracks…", "Load more" and "Loading…" are
all in the eight catalogs already.

cc
2026-08-16 19:54:15 -06:00
vicrodh f046bb8c1f fix(qt): album cards get their genre and a localized release date
The card was never the problem: AlbumCard.qml renders genre and the date as
hover meta and draws the quality badge, and AlbumCollection passes all
three. The DOCUMENT arrived incomplete.

`home_qt::map_flat_album` — the mapper behind Discover, the label pages, the
award pages and DiscoverBrowse — never wrote `genre` at all (it fell through
to `..Default::default()`), so that line was empty on every card it feeds.
And it took the first four characters of the release date, so those surfaces
showed "2021" where the rest of the app shows "Sep 2, 2021".

That inconsistency is the actual finding: five mappers already localize
through `qbz_text_utils::dates::release_label` and four sliced the year, and
the comment on `map_recent_album` — directly below the worst offender —
claims the discover cards localize it "exactly" the way it does. They did
not. The album-page and artist-page release cards had the same slice and are
converted with it.

Two year sites are deliberately left alone: `album_qt.rs:959` and
`myqbz_builder_fetch_qt.rs:64` produce an `Option<i32>` used to sort and
group, not to display. A localized string there would break the ordering,
which is exactly the kind of blanket sweep this is not.

cc
2026-08-16 19:53:59 -06:00
vicrodh 6dcfbc77c9 chore(qt): clear six dead-code warnings, each verified before deleting
- label_qt: an unnecessary `mut` — the closure takes its Vec as a parameter
  and captures nothing mutably.
- sidebar_qt: `SidebarLocal.hidden` was write-only. NOT a bug: the list is
  filtered on `!p.hidden` before the struct is built, so the field could
  only ever be false. Hidden local playlists were, and are, excluded.
- local_playlist_qt: `queue_track_for_row` / `plex_key_for_row` duplicated
  `playlist_qt::row_to_queue`, which is both live and better — a local
  detail adopts its rows into that shared page and `row_to_queue` types
  them from `row.source`, the guard that keeps a library rowid off the
  QConnect wire as a catalog id. `CURRENT_QUEUE` stays: their sibling
  `local_picker_ref_for_row` is its live reader.
- cover_artwork_qt: `prefer_playlist_cover` had no possible call site — the
  one consumer that would want it returns a Vec of cover refs, not a single
  fallback string, so it applies the same rule itself.
- playback_qt: `enqueue_track_list` was an append-only alias of
  `enqueue_track_list_mode`, orphaned by the "Play all next" fix. A second
  name for the append is what let the mode-less call site look correct.

Also carries playback_qt's half of the local-feed play routing (the sibling
of `library_qt::feed_track_to_queue`): a local or Plex feed row is resolved
as a LOCAL queue track instead of falling through to the Qobuz builder,
which would have rebuilt it with the same numeric id as somebody else's
song. It rides here because the two changes share the file.

The four warnings left are the ones awaiting a ruling: PlaylistRef and its
parse (a documented type guard nothing constructs) and the two local
playlist custom-artwork writers (Qt stores playlist covers in a JSON
sidecar while it READS the library.db column the Slint writes).

cc
2026-08-16 18:56:55 -06:00
vicrodh 1f636679d5 fix(qt): "Play all next" enqueues the artist instead of replacing the queue
The artist ⋯ menu offers "Play all next", "Add all to queue" and "Shuffle
all". The first was wired to `playArtistTop(false)` — which the bridge's own
doc calls "Play all": it REPLACES the queue and starts playing. There is no
plain "Play all" row in that menu, so there was no way to get the intended
behaviour, and asking to queue an artist next wiped whatever was queued.

The invokable takes a mode now and both rows route through the shared
insertion funnel: "next" inserts at the cursor, "queue" appends. That is
what the reference does (`enqueue_artist_top_selected(.., next: true)`) and
what `playback_qt::enqueue_artist_top_mode` was written for — it had no
caller at all, which is how the miswiring survived.

cc
2026-08-16 18:56:38 -06:00
vicrodh dc2496e5c0 feat(qt): award pages, reachable from the album sidebar and from each other
Ports the Slint/Tauri award pair: the landing (gold laurel hero, magazine
line, follow heart, "Acclaimed Releases" with its count and See All, the
preview grid and the "Other awards" rail) and the paginated listing with
its client-side search. Routes "award" and "awardalbums", the label pair's
shape — one controller for both, since they share the hero, the follow
state and the identity.

The three rules from the Tauri review are in the module header because they
are the ones a re-implementation gets wrong: the grid is ALWAYS
/award/getAlbums (/award/page is user-scoped and may omit releases, so it
feeds the hero and nothing else); the favorite split is plural-read,
singular-write, because the backend derives `award_ids` from the write
kind; and getAlbums has no has_more, its `total` is a hint, so the only
sound predicate is `total > loaded`.

The album sidebar's AWARDS cards navigate now — with an id directly, and by
NAME through the resolver when Qobuz omits it, which it does. They were
also reading `album.awards` when the field lives on `album.header`, so the
block had rendered EMPTY for every album since it was written: an empty
Repeater draws nothing and reports nothing, and the sidebar's own gate
counted the same empty list. The LABEL card beside it had a "no label view
yet" note and no handler, years after the label view landed.

Beyond the reference: an all-awards dropdown pinned to the page's top-right,
so any award is reachable without first finding an album that won it. It
costs nothing new — the /award/explore crawl already existed to resolve
names to ids.

The award icon was lucide's MICROPHONE under the wrong filename. Search had
routed around it with `crown` and left a note; kiosk was missed and drew a
mic for "Most popular". The asset is the laurel the other two frontends use,
and kiosk now asks for the crown it wanted.

cc
2026-08-16 18:56:20 -06:00
vicrodh 60bf5b8365 fix(qt): "Not interested" actually removes the artist from Recommendations
The write existed — `dismissArtist` persisted to the reco-dismissal store
and toasted — but nothing READ it: `reco_dismiss_qt::ids_snapshot()` had no
consumer outside its own module, so the artist came back on the next
rebuild. The card's comment promising it would "leave the rails on the next
publish" was false, because nothing filtered on publish either.

The dismissal ids now join the exclusion set fed to `compose_artist_rails`,
so the composition choke point can backfill the freed slot from the pool
instead of leaving a hole, and `apply_dismissal` drops the card from the
cached rails on the CLICK — no optimistic flip in QML.

Scope is deliberate and recorded on the card: RECOMMENDATIONS only. The
same card is mounted by Search, Label, Artist Scene, Home and the Library
grid; dismissing from one of those persists and takes effect in
Recommendations, but the card you clicked stays where it is. Hiding an
artist app-wide is the blacklist — a different store and a different row.

cc
2026-08-16 18:54:28 -06:00
vicrodh 41c1b50d61 feat(qt): offline cache manager, and stamp the album identity on cached tracks
The last missing Settings surface: the OFFLINE CACHE group (Open manager /
Open folder / Clear all) and the manager view behind it — stats bar with
the GB limit, sort and failed-only toolbar, A-Z artist rail, album+track
rows with per-row re-download and remove. The engine was already here; what
was missing were four actions (remove_album, redownload_track, open_folder,
clear_all), the rollup and the view.

Two bugs the owner's smoke exposed, both older than this view:

ALBUM IDENTITY WAS BEING LOST ON EVERY ALBUM DOWNLOAD. A Track inside an
/album/get payload carries no nested album object, and `track_cache_info`
read exactly that, so `cache_album` wrote `album = NULL, album_id = NULL`
for every track it queued. Measured on a real index: 34 of 46 rows, three
complete albums. Not cosmetic — `album_id` is the key `remove_album`,
`redownload_album` and the downloaded-album registry look rows up by, so
those albums could not be removed, refreshed or recognised as downloaded.
The album document now stamps id and title onto every track that lacks its
own. The reference has the identical bug.

AND THE ROLLUP MIXED ARTISTS. Every row without an album_id collapsed into
one "__singles__" bucket titled from the first track and credited to its
artist, so three albums by three artists rendered as ONE album header while
two of the artists vanished from the rail. The key is a ladder now: album
id, else (artist, title), else a per-ARTIST singles bucket — two artists
can no longer meet in one group even with the broken data. Rows already on
disk recover their title from library.db, which does know it, so an
existing cache reads correctly without re-downloading.

Deltas from the reference, both because Qt is not Slint: covers are paths
(slint::Image is not Send, which is why it pre-decodes; Qt has no such
constraint) and selection is QML-local, this port's convention, which keeps
the document a pure function of the DB so a finishing download cannot fight
the user's checkboxes.

Clear-all confirms first — the reference fires straight from the glyph, and
undoing this one means re-downloading the whole cache.

cc
2026-08-16 18:54:16 -06:00
vicrodh ffa7cfd4c4 feat(qt): Library All local scope, and stop resolving local rows as Qobuz tracks
PARITY-DEBT #6, both halves. `all_local_scope` was read-only here and the
feed's "all" branch did not exist, so Settings > Local Library > LIBRARY ›
ALL was absent and the only local content in the All feed was hearted
items. `all_local_feed_blocking` ports the whole rollup — albums with the
Plex union, artists, tracks paged to the reference's 200k ceiling — over
the same queries the Local Library tabs run, so the two views cannot
disagree about what is in the library.

Two deltas from the reference, both because the Qt side already had the
better primitive: the local/Plex artist merge keys on
`normalize_artist` (diacritic fold — PARITY-DEBT #7, already fixed for the
Artists tab) rather than a raw lowercase, and the quality badge comes from
the pair every other local surface uses.

And the reason this could not ship as just a feed branch: `feed_track_to_queue`
did not look at the row's SOURCE. A local track carries its `local_tracks.id`
— a small integer that is ALSO a valid Qobuz track id — so clicking one
queued whatever Qobuz song happened to hold that number. The guard cannot be
"does it parse", it has to be the source; local and Plex rows now resolve
through `local_playback::local_queue_track` against a raw-row cache, the
same shape the Tracks tab keeps. A row that cannot be resolved is dropped
rather than played as somebody else's track, and the regression pins both
arms.

cc
2026-08-16 18:53:56 -06:00
vicrodh bbbac0a0ad feat(qt): auto-theme custom image picker, and retire the fossil settings notes
The reader had been there the whole time — `theme_qt.rs` builds
`AutoSource::Image` from `auto_theme_image_path` — with no way for a user
to write it, which made "Custom Image" a source you could select and never
supply. Adds the native picker (rfd, through the `settingsString` action
seam the folder chooser already uses), the "Detected: <desktop>" hint from
the shared detector, and the experimental caveat. Persisting the path also
sets source=image before regenerating, because the theme re-reads the prefs.
No new msgids: all four are already in the eight catalogs.

Also deletes eight header comments that were false, each verified against
the code before removing it:

  - LocalLibrarySettings: "no native folder picker" (library-pick-folder
    uses rfd) and "LibFolderEditModal not ported" (it is, and mounted at
    SettingsView.qml:330)
  - OfflineSettings + settings_qt/offline.rs: "Check now not shipped" (the
    button is right there)
  - DeveloperSettings: "in-app log viewer not ported" (LogViewerModal.qml)
    and "export always without auth" (the credentials switch is inline)
  - PlexSettings: "the PIN flow is not ported" (Generate/Link/Copy code and
    Open sign-in all present)
  - settings_qt.rs: "no JACK in the POC build" (the banner is in
    AudioSettings.qml), plus the wizard and settings export, both shipped
  - tray_qt.rs and auth_qt.rs: "the Qt port has no session_persist module"
    — false since the queue restore landed; this port flushes once after
    the event loop instead of from each quit handler

A "not ported" note that outlives the port is worse than no note: the last
round of recon believed two of these and planned around them.

cc
2026-08-16 18:53:37 -06:00
vicrodh 57fd17f7d3 feat(qt): raise the desktop now-playing notification
`system_notifications` was written by Settings and read by nobody: the
shared `qbz_media_controls::show_track_notification` had one caller in the
whole workspace and it was the Slint build's. The switch shipped on by
default and did nothing.

Ordering is load-bearing. The de-dupe runs FIRST — `push_now_playing`
reaches here from `refresh_now_playing`'s 23 call sites (resume, seek,
quality patch, every republish) and only a changed track id may pass — then
the pref read, then the peer guard inside the spawn, so a track a remote
QConnect renderer is playing is never announced.

The toast is NOT downstream of the MPRIS handle: a machine with no
session-bus MPRIS still has a notification daemon, and the reference
likewise raises it outside its `if let Some(mc)` block. Hanging it off that
early return would have made the switch dead again for a smaller audience.

Cover resolution differs from MPRIS on purpose: the notifier decodes by
CONTENT, so a cached copy is fine and saves a re-download, while a widget
resolves file:// through the mime database and rejects the cache's
extension-less .img. That split is the B11 rule.

cc
2026-08-16 18:53:21 -06:00
vicrodh 9899ad6b09 feat(qt): open the playlist importer from the Playlist Manager toolbar
The importer had exactly two doors, both inside the sidebar's playlist
popup, so the one view whose job is managing playlists could not reach it.

Placed before "New folder": that slot disappears in flat mode and a Row
skips invisible children, so a button after it would slide sideways every
time the mode toggles. No offline gate — the modal recomputes its own and
shows a disabled confirm with the reason, which beats a toolbar button
that vanishes when the network drops.

No new msgids: "Import" is already in all eight catalogs.

cc
2026-08-16 18:53:21 -06:00
vicrodh 16a25bbbe6 fix(qt): pin the Local Library tracks scrollbar to the view edge
The bar sat inside the tab's 32px content inset AND carried its own 4px
margin, leaving it 36px from the window with an empty channel beside it.
It now cancels the inset; the rows keep theirs.

The "By name" arm is untouched: there the alpha strip owns the right edge
and 22px is what clears it. The inset is a named property now because the
scrollbar has to cancel it — written twice, moving one silently misaligns
the other.

cc
2026-08-16 18:53:04 -06:00
vicrodh 13f1b106dc fix(qt): keep the Local Library select button clear of the search box
The expandable search keeps a 30px footprint and grows its inner field
LEFT (QbzLineEdit.qml, `x: root.width - width`), so anything positioned
before it in the row is covered while the search is open and cannot be
clicked. On the Tracks tab the multi-select button led the row and was
exactly that.

Moved to the end of the group — the slot the Albums tab already puts it
in, so the two tabs agree as well.

Swept the other nine expandable searches: in all of them the field is
already the first child of its row, and the tree rail's box width is
computed to stop at the third button. Tracks was the only case.

cc
2026-08-16 18:53:04 -06:00
vicrodh 3bbe7a039d fix(qt): album info opens for every album, not only numeric ids
The guard had its predicate backwards. `album_id.parse::<u64>()` rejected
the four-in-five Qobuz albums whose id is alphanumeric ("vy32kidwrer67",
114 of the 139 ids in the captured fixtures) while admitting local library
row ids, which are exactly what it claimed to be blocking.

A rejected open returns before touching the bridge, so the modal kept
whatever document was there: the previous album's, or — once the QML
started holding its overlay until the ids matched — an empty card. Neither
said a word above debug level, which is the whole of "only the first album
info of a session works".

The published document now stamps `albumId` with the id that was ASKED
FOR rather than the one /album/get echoed back. The QML compares those two
with exact string equality, so any divergence blanked the card silently;
a mismatch now logs instead.

Regression tests cover the four real alphanumeric ids, the barcode-shaped
minority the old guard did accept, and the local/Plex keys the guard
exists for.

cc
2026-08-16 18:52:52 -06:00
vicrodh 7d98ad044f feat(purchases): the Qt frontend — list, album detail, routing and gating
Two screens, a controller and a bridge. `QbzPurchases` publishes one JSON
document per screen plus a revision counter, so QML re-parses even when the text
is byte-identical.

- The list: two tabs with per-type counters, a server-side search on a 300 ms
  debounce, and a toolbar that DIFFERS by tab — Albums gets group, sort,
  direction, two filters and grid/list; Tracks gets group and the filters only.
  Building the Albums toolbar on both would have invented two controls.
- The detail: a format dropdown whose selection re-scopes every downloaded mark,
  the progress section and Add-to-Library together; a determinate bar counted in
  TRACKS, never bytes; goodies as an album-level action counted separately.
- The album download is the frontend loop, not the Rust command the reference
  registered and never called. Sequential, concurrency 1, cancellable, and a
  failed track does not abort the album.
- Both prefs are read at runtime, not merely written. `show_purchases` is the
  master gate and `nav_tb_purchases` only relocates the entry to the title bar;
  both default false, so Purchases still ships hidden.
- Scanned local rows keep their raw `qobuz_purchase` source so the gold badge
  survives in list mode as well as grid, while still classifying under the
  Offline source filter. Remote feed rows deliberately stay `qobuz`: stamping
  them would badge every purchase the user merely OWNS as locally downloaded.

Defects fixed after the screens were written, none of which any test could have
caught:

- `QbzPurchases.boot()` was called nowhere. Without the Qt-thread hop every
  publish is a silent no-op, so both screens would have stayed permanently empty
  with nothing logged. The cause was an ownership gap rather than the code:
  `Main.qml` belonged to no lane because the plan listed the files each lane
  CREATES rather than the files it TOUCHES, and "belongs to nobody" is
  indistinguishable from "done" until something runs.
- A second download of the same album nested inside the first. The store rewrites
  its destination to the album subfolder after the first successful track so that
  Add-to-Library adds the album rather than the whole music root, and the download
  planner read that value first — producing `.../Album [q]/Artist/Album [q]/...`,
  one level deeper each time. The picked ROOT and the resolved ALBUM FOLDER are
  now separate fields with names that cannot be confused, with a regression test.
- A tab counter could stick at zero forever with its list plainly full: the two
  ids calls each swallow their error, and the reference's backfill from the list
  response was missing.
- Cancelling made the whole progress block vanish instead of showing the
  cancelled state, whose msgid was already translated in all eight catalogs.
- Add-to-Library re-walked the entire library when the folder id it needed was
  already in hand.
- A live language switch and a logout left the previous state behind.

Deliberate divergences from the contract, each recorded with its reason: the page
header is a plain `fontSection` title with no icon tile, because nothing else in
this application announces itself over a large coloured square; purchase download
status travels inside the album document rather than on the shared cache channel,
which is keyed by track id alone and would let the two features overwrite each
other on the same row.

Gate: release build clean, QML audits 278 files / 37 bridges, `cargo test -p
qbz-qt` 398 passing, offscreen smoke green with zero QML complaints.

Not verified, and cannot be from here: every populated screen. This account owns
nothing, so only the empty states have ever rendered.

cc
2026-08-16 11:54:05 -06:00
vicrodh 475f261dc1 feat(purchases): shared backend for the Qt port, with tags, covers and goodies
The frontend-agnostic half of Purchases. Ported against the Tauri reference,
which is the only implementation ever validated by a real user; the Slint
controller was read for divergences only and never as a source of truth.

Context that sets the bar for all of it: Qobuz does not sell Purchases in this
account's region, so no populated screen and no download can be exercised here.
Anything merely plausible ships unseen. Hence the tests below are the deliverable
as much as the code.

Behaviour ported or corrected:

- Album "downloaded" now comes from the LOCAL REGISTRY. The reference derives it
  from `album.tracks.items`, but `getUserPurchases?type=albums` carries no nested
  tracks page (measured against a live account), so that predicate is
  unsatisfiable: no album card can ever mark as downloaded and "Hide downloaded"
  can never hide anything. `get_downloaded_purchase_album_counts` answers it
  instead, giving a reader at last to the `album_id` column and to the index that
  has always sat on it.
- `album_id` is written by every download path. It used to be set only by the
  album loop, so a release downloaded track by track produced rows the rule could
  never attribute. The `/track/get` payload each download already fetches carries
  the id.
- `remove_downloaded_purchase` keys on `(track_id, format_id)`, the table's actual
  primary key; erasing every format is now a separate, named call.
- `sanitize_filename` no longer panics. The 200 cap is in BYTES and `truncate`
  panics off a char boundary — reachable because the mapping keeps Unicode
  alphanumerics. Measured: the crash belongs to the THREE-byte class (CJK), where
  200 % 3 lands mid-character; two-byte scripts happen to land on a boundary. The
  cap stays in bytes so no already-downloaded path moves.
- The purchase path sends `/album/get` and `/track/get` UNSIGNED, matching both
  the reference and Qobuz's own desktop client, whose request helper takes a sign
  flag and passes it false for these and true only for `getFileUrl`. That
  signature is the entitlement proof and is untouched.
- The purchase-type guard runs before any I/O; a bogus type no longer issues a
  live authenticated GET before failing.
- Track subtitles survive. `PurchaseTrack` gained `version` and
  `build_purchase_album` maps it from the catalog track, closing a latent
  regression where every purchased track lost its edition.
- Downloads write tags, `cover.jpg`/`back.jpg` and goodies. All three are
  best-effort: the audio file is the deliverable and is already on disk and
  registered before any of it runs.
- `goodies` is deserialized leniently. Its populated shape has never been
  observed — it comes back empty on every album nobody owns — and `/album/get`
  is parsed with the strict structs, so an unexpected shape would take the whole
  album down rather than merely hide a booklet.

Riding along, both found while building the above and both unrelated to
Purchases:

- The metadata EDITOR silently discarded saves. `write_album_tags_to_files`
  picked its write target as "the primary tag, or else the first one I can find",
  and lofty refuses to WRITE a tag type a container only supports READING —
  silently, skipping it rather than erroring. A FLAC carrying an ID3v2 tag has no
  Vorbis comments, so every edited field went into that ID3v2 tag, the save
  dropped the lot, and the call returned Ok. The user pressed Save, saw a success
  toast, and the file never changed. Same defect, same fix, in the new purchase
  tagger.
- `fetch_asset_bytes` does not reuse the audio downloader. That one sizes its
  buffer from the response's own Content-Length and sets no total timeout, which
  is fine for a track from a trusted CDN and wrong for an arbitrary goodie URL on
  a machine that hard-freezes under memory pressure.

Tests: 277 across the four crates, covering every acceptance item the contract
set. The signature preimage character for character, the pagination loop
including the zero-total case, the sanitizer against the inputs that used to
panic, every deserializer default, and the wire envelopes against real bytes
captured from a live account. A download harness stands up a local HTTP server
and drives the pipeline end to end — real bytes over a real socket, a real FLAC
tagged and read back, a real database — and the two tag regressions were both
verified to FAIL against the old code before being accepted.

cc
2026-08-16 11:53:37 -06:00
vicrodh 9c67be4dcd fix(qobuz): give the offline-gate tests a rustls crypto provider
Both `client::tests::offline_gate_*` failed on every run, not intermittently:
reqwest is built with `rustls-tls-webpki-roots-no-provider`, so BUILDING a
client panics with "No provider set" until a process-level CryptoProvider is
installed. The application installs one at startup via
`qbz_app::ensure_crypto_provider`, but a test binary has no startup, and this
crate sits below `qbz-app` and cannot call it.

Confirmed pre-existing by stashing the unrelated work and re-running: same two
failures, same panic.

Worth knowing beyond these two tests: the panic fires at client CONSTRUCTION,
before any request and regardless of scheme, so it hits a plain `http://` URL
too. Any future tool or test that reaches for the download path without an
application around it will land on it.

cc
2026-08-16 11:53:08 -06:00
vicrodh f701bcd4db fix(qt): size-aware now-playing artwork, up to the mega tier
Restored queues and recently_played carried whatever variant the building
surface picked — 50px JPEGs measured in the wild — into the now-playing
bar, MPRIS, Discord and the immersive big slots. The np feed rewrites
sized Qobuz covers up to 600px; big slots (immersive panels, split,
preview overlay) resolve per size bucket through npArtworkPathLarge with
one re-resolve per tier crossed and never on shrink.

Registry-miss arm (restored sessions carry no ImageSet): rewrite to the
FULL requested bucket with a session negative cache, so a pre-mega album
pays one 404 per session instead of one per track change. And the mega
tier's wire suffix is the literal _max.jpg, not _3000 (404 measured
against the live CDN) — qobuz_cover_at_px now accepts and emits _max.

Local covers gain an on-demand 1600px tier (never upscaled); Plex big
slots tokenize at 1024.

km
2026-08-16 08:23:15 -06:00
vicrodh e6c058c2f2 feat(qt): immersive shader scenes — RHI items, scene menu, GL link pairing
Port the immersive GPU scenes: Plasma, Tunnel, Aurora, Ambient, Spectral
Ribbon and Line Bed as QQuickRhiItems/ShaderEffects under a pulse-driven
ShaderSceneLayer, plus the Qt-only Tunnel Flow (Tauri Canvas2D port) fed
by the Viz16 stream. Scene rows live in the header view menu (no Off row);
g cycles the ring. A fresh immersive open defaults to Split > Lyrics
(owner ruling).

OpenGL link fix (owner smoke: Tunnel black with "uniform _22 declared as
type buf and type buf"): every ShaderEffect fragment with its own uniform
layout now ships a paired vertex stage declaring the identical block —
the shared spectrum.vert only ever linked under Vulkan.

RHI hardening: render pass descriptors on the ping-pong targets (the
Tunnel Flow segfault / Plasma dead window), mirrorVertically tied to the
backend (!== OpenGL), ribbon sub-uploads carry setSourceSize.

km
2026-08-16 08:22:57 -06:00
vicrodh 8bd02e35d3 feat(qt): custom-theme editor, artist portrait, playlist rail, About/What's New and diagnostics
Five surfaces the Qt frontend was missing, landed together because they share
five wiring files (build.rs, main.rs, shell_bridge.rs, AppShell.qml,
HeaderBar.qml) and splitting them would produce commits that do not build.

ARTIST PAGE
- The Playlists section used a hand-rolled Rectangle delegate: a MouseArea with
  no onClicked, and a RoundedImage with no fit:, so the default crop blew an
  800x380 playlist banner up inside a 200x200 box and showed the middle 47% at
  2.1x. Replaced by the real cards/PlaylistCard.qml — already live on five other
  surfaces — mounted in a new views/PlaylistRail.qml modelled on SectionRail.
  ArtistPlaylist publishes the five roles the card reads.
- The portrait gains what the album cover has: the right-click menu the Slint
  reference already has (Add/Change/Remove image, Open in browser, Save as…) and
  the lightbox this port invented for albums and playlists. Custom images are
  keyed by artist NAME into the same custom_artwork.json the Slint build writes,
  so they round-trip between the two.
- CoverLightbox is now aspect-aware. It forced a square box on the premise that
  covers are square; artist portraits are not (1080x720 measured in the cache).
- Removing an override invalidates the pipeline's resolved path for that url:
  artwork_qt::cached_path consults the override FIRST, so coverMap held the
  custom file and the header kept showing the picture the user had just deleted.
- artwork_qt::local_path is the inverse of file_url. trim_start_matches("file://")
  is not: it leaves the percent-escapes in place, so any path holding % # or ?
  came back as a name that does not exist on disk.
- One new msgid, "View image", in the seven non-en locales — matching its sibling
  "View cover", since en carries none of that family and falls back to the msgid.

CUSTOM THEME
- The editor, the colour picker and the swatch grid, ported 1:1 from
  AppearanceSettings.slint and ColorPicker.slint. The 11 editable base tokens,
  their expansion into the full 55-value palette and the on-disk format all stay
  in the shared qbz-theme crate, so a theme authored in the shipping Slint build
  already rendered here and now round-trips both ways.
- Hue is normalised 0..1 at every Qt.hsva boundary; the reference stores 0..360,
  and porting the number instead of the domain would render every swatch red.
- The first-selection seed is captured BEFORE set_theme persists the slug,
  because current_slug() re-reads it from disk — a seed taken afterwards would
  resolve "custom", find no file and snapshot the default it was meant to replace.
- The in-memory base is dropped when the theme row enters "custom": it is
  memoized for the life of the process and filled at bridge construction, so a
  process that booted without the file would keep serving the OLED default even
  after the shipping Slint build wrote the user's theme.
- The disk write is debounced to drag-end and atomic; the reference persists on
  every drag frame with a truncating write, on a file the other build has open.

ABOUT + WHAT'S NEW
- Both modals, with their producers. render_markdown is transcribed line for line
  from the reference: h1 and h2 collapse to one level, an indent-0 bullet becomes
  a section plus a TOC entry, bold and backticks are stripped rather than styled.
  Text.MarkdownText is a different subset and would have rendered a different
  document from the same source. Pinned by tests, including the real 2.0.2 body.
- The codename is a single const, so the release checklist has one Qt-side place
  to bump.

DIAGNOSTICS
- The panel in Settings > Developer, the Report an Issue modal and its menu row.
- The log bundle carried eight hand-built header fields where the reference
  carries about seventy, so every issue filed from a Qt build arrived with no
  kernel, no distro, no install method, no GPU, no exclusive-mode / DAC /
  sample-rate state and no playback context. The report is injected INTO
  qbz_log::bundle rather than replacing it, keeping the <details> wrapper that
  makes a pasted bundle readable.
- The GTK / WebKit / GSK / GDK rows are CUT. In a Qt process those libraries are
  not mapped, so their runtime column can only read "—", and their saved column
  would come from a graphics.json this binary never reads or writes — foreign
  state presented as this app's configuration.

SHELL
- The four always-on-top overlays (toast, tooltip, art preview, immersive) relied
  on declaration order and carried no z, so any z-bearing modal buried them: a
  toast raised by a background event behind an opaque panel is never seen. They
  now sit in a band above the modals, keeping their relative order.
- build.rs asked cargo to watch ../../.git/HEAD, which does not exist in a git
  worktree (.git is a file). Cargo treats a missing rerun-if-changed target as
  always-dirty, so the whole build script — per-shader qsb, the recursive asset
  sweep, cxx-qt/moc codegen — re-ran on every cargo invocation.

Verified: cargo test -p qbz-qt 343 passed, cargo test -p qbz-library 83 passed,
qml_singleton_xref and qml_resolution_audit clean over 35 bridges and 265 QML
files, and qmlcachegen compiles every one of them. Smoke-tested by hand.

Contracts, recon and adversarial reviews:
qbz-nix-docs/qt-frontend/2026-08-14-six-item-round/

cc
2026-08-15 00:52:25 -06:00
vicrodh 4bfd8b30e2 fix(library): keep a track's rowid when re-indexing it
insert_track used INSERT OR REPLACE. SQLite resolves that conflict by DELETING
the old row and inserting a new one, which hands out a fresh rowid.
playlist_local_tracks.local_track_id is a foreign key onto local_tracks(id), and
foreign keys are OFF in this database (the only pragmas set are journal_mode and
synchronous), so the cascade never fires: the playlist row survives pointing at
an id that no longer exists and the INNER JOIN that reads it returns nothing.
The scan re-inserts every file — there is no mtime skip — so every library
rescan silently emptied Qobuz playlists of their local tracks.

The conflict that fires is not the obvious one, which is worth recording: the
table's UNIQUE(file_path, cue_start_secs) does NOT catch an ordinary track,
because cue_start_secs is NULL and SQLite treats NULLs as distinct. What catches
it is the partial index idx_tracks_file_nocue. Both identities are handled.

The row is now looked up and UPDATEd in place. Reproduced on a scratch database
before the fix was written, and the regression test was confirmed to fail
against the old code path.

First-class LOCAL playlists were never affected: local_playlist_tracks stores
local_path, not an id.

cc
2026-08-15 00:51:41 -06:00
vicrodh 0bba3d0cf4 fix(library): stop the direct tag write from clobbering per-track ARTIST
write_album_tags_to_files called set_artist(album_artist) unconditionally on
every file. Two irreversible outcomes, and a direct write has no undo:

- On a various-artists release the editor pre-fills the artist field from
  compute_track_artist_match, which returns None the moment two track artists
  disagree — so the field renders EMPTY and that empty string was written over
  the ARTIST of every file in the release.
- With a non-empty album artist, every guest credit was replaced by it.

The DB half of the same save was already careful: update_album_group_metadata
renames the track artist only where it still equals the value that was uniform
across the album. The file writer now agrees with it. A pre-pass reads the prior
artist from the files themselves — they are the ground truth about what is being
overwritten — and should_rename_artist renames only on an exact match with a
non-empty new value. ARTIST is track scope and the editor has no field for it.

The pre-pass is skipped when the album artist is blank, so the common path costs
nothing.

cc
2026-08-15 00:51:34 -06:00
vicrodh 8783031ea0 fix(library): group a titled disc folder into its release
In the DEFAULT (folder) grouping mode, a box set laid out the way our own
multi-disc guidance recommends — Disc NN - {subtitle}/ — showed up as one album
per disc.

album_root_dir climbs past a directory only when is_disc_folder accepts it, and
that predicate required the name to consist ONLY of disc tokens, digits and a
short modifier list. "Disc 01 - TV Series Soundtrack #01" tokenises to
["disc","01","tv","series","soundtrack","01"], has_extra_words hits "tv", and
the climb stops — so the group key becomes the disc directory and a 13-disc set
becomes 13 albums.

is_disc_folder now also accepts a name whose LEADING token is a disc designator
followed by a separator with whitespace on at least one side. That is disjoint
from the issue #147 false positives, which are album titles that END with a disc
token ("Now 75 - CD1", "100 Popular Classics, Disc 1", "Relaxation Disc1") —
their regression tests pass unchanged. The rule stays narrow deliberately:
"CD-Rom Favourites" and "Disc Jockey Anthems" are still album titles, because an
accidental merge of two releases is worse than the split being fixed.

The disc NUMBER is gated on the same predicate, so a box set with no DISCNUMBER
tag now recovers it from the folder name too.

Sidecar lookup follows the key, so moving the key would have orphaned every
.qbz.json saved while a disc was still its own album — album title, artist, year,
genre, catalog and all per-track overrides, silently. apply_sidecar_override now
tries the album root and then the file's own directory, and the CUE path goes
through the same function instead of its own single-directory copy.

Existing installs see the merge after a rescan; album_group_key is stored at
index time.

cc
2026-08-15 00:51:34 -06:00
vicrodh 8ff0adc149 fix(qt): convert the Slint colour literals that made scrims and shadows invisible
Slint writes #RRGGBBAA, Qt parses #AARRGGBB, so a literal copied verbatim does
not come out the wrong colour — it comes out transparent, because the alpha byte
is whatever the red byte used to be.

Seven sites across five modals. Every faked 32px drop shadow in the port was
#00000080 (alpha 00), and the scrims of LogViewerModal and LibFolderEditModal
were #000000bf, so those two modals had been opening with no dark backdrop at
all. The rest of the tree already spells the same scrim #bf000000.

Found by grepping for 8-digit literals of the shape #00xxxxxx. The gradient
stops in MixArtwork and LocalAlbumView that are legitimately transparent, and
the comments in the scene components that warn about this exact trap, were
checked and left alone.

cc
2026-08-15 00:51:18 -06:00
vicrodh 016eb3cd23 feat(qt): artist scene, musician page and the shared scene-discovery engine
Both views were absent from the Qt frontend, not partial. This lands them
together with the core engine they need and the two entry points that reach
them.

- qbz-core gains scene discovery ("artists from the same place"): a
  cancellable run with a phase for the progress label and a typed
  SceneDiscoveryError, so a view can tell offline, all-upstreams-failed and
  an honest empty result apart. CancelToken, ScenePhase and SceneDiscoveryError
  are re-exported for frontends.
- qbz-integrations: MusicBrainz location resolution plus its cache layer.
- qbz-qt: ArtistSceneView with its three scene components, MusicianPageView
  and MusicianModal, backed by artist_scene_qt.rs, musician_qt.rs and the two
  bridges. The five-branch musician dispatcher is wired to all six of its
  consumers. Both scene doors are live: the sidebar location link and the
  overflow row that shipped grey with action: "stub".
- Translations: the new msgids in all eight locales.

Three defects fixed on the way, all with the same signature — a comment
asserting a mechanism that does not exist:

- Volume opened at 100 %. The comment claimed the poll loop mirrored it from
  the engine; now_playing::set_volume has two callers and neither runs in a
  local session.
- mbAvailable was documented as "MusicBrainz enabled AND resolved to an MBID"
  while being assigned from the opt-in alone. Consumers now test the MBID.
- Session restore could not resume from a cold cursor ("cannot resume - no
  audio data available") for want of a has_loaded_audio() guard. Investigation
  in qbz-nix-docs/qt-frontend/2026-08-14-cold-cursor-resume.md.

Contract and evidence:
qbz-nix-docs/qt-frontend/2026-08-14-artist-scene-musician/00-CONTRACT.md,
which wins over specs 01..09 and records 20 deliberate divergences from the
reference in section 6.

cc
2026-08-14 19:25:11 -06:00
vicrodh b045f7023b chore(qt): vendor the country flag set used by the artist scene
265 SVG flags (1.1 MB) plus the upstream MIT licence and attribution in
crates/qbz-qt/third-party/. Kept as its own commit so the asset drop can be
rolled back or re-sourced without touching the scene code that consumes it.

cc
2026-08-14 19:24:58 -06:00
vicrodh 085c35876a revert(qt): make the content router synchronous again
An asynchronous Loader plus a router-level skeleton was meant to fix the dead
click on navigation. It made the app markedly worse and is reverted whole.

Async incubation is TIME-SLICED: it yields to the UI thread between slices, so
it trades a short freeze for a much longer wall-clock build, and the Loader
stays in Loading until the entire tree exists. For Discover > Home that is all
413 cards -- a one-second freeze became a five-second skeleton on the most used
route in the app. Measured after the fact, which is the mistake; a change that
moves WHEN something heavy is built has to be timed on the busiest route before
it is called a fix.

The placeholder is gone with it. It drew the same two text lines and row of
five cards for all 40 routes, so the swap moved everything -- the exact jump a
skeleton exists to prevent.

The dead click is real and remains unfixed. The durable fix is not here: stop
rebuilding these views on every navigation (keep the top-level tabs alive) and
build the heavy views incrementally. The reasoning is kept in the file so the
experiment is not re-run.

cc
2026-08-13 23:10:49 -06:00
vicrodh fda56f9ea2 fix(qt): pin the artist jump nav bar and round it at the pane edge
The strip scrolled away with the body; the reference clamps it to the top.
Lifted out of the flow with a slot holding its place, using the reference's
clamp verbatim -- the formula must not add the viewport y or the scroll is
double-counted and the bar lifts ahead of the body mid-transition.

Pinned at the pane's top edge it then showed the square-corner defect already
fixed in Discover: Qt's clip is a rectangular scissor that ignores radius, and
the shell hides its bezel nubs while the dynamic background shows through the
corners, so a full-bleed child has to round itself. Driven off the live y, so
the rounding exists only while the bar is actually at the top -- rounded
corners in mid-page would be a notch with nothing behind them.

cc
2026-08-13 23:10:40 -06:00