Commit Graph
100 Commits
Author SHA1 Message Date
vicrodh 3cfce8a769 feat(qt): the media-server settings panel, driven and verified in the window
ONE component instantiated twice, because it is one form twice. Every
difference between the protocols is a property set at the call site, so
they are visible in one place instead of drifting across two files:
"Test" proves the address for Jellyfin and only "a Subsonic server
answered" for Subsonic (which has no credential-free endpoint), the
password is discarded for Jellyfin and kept for Subsonic (no session,
md5(password+salt) per request), and the sync cost is stated up front —
45.8 s per 4924 tracks against 0.81 s per 6678 — so a long sweep does not
read as a hang. The credential never leaves Rust; the panel gets
`hasCredential`.

Assets: the colour marks for cards, the monochrome ones for rows. Both
mono files arrived as BLACK ink and are invisible on a dark theme
untinted, so they were normalised to the #ffffff master ink and joined
`icon_tint_qt::MASTERS` — the port's only shader-free way to recolour an
SVG per theme. The Jellyfin colour asset had a white rounded-rect plate
behind the mark; it was stripped, because every other brand mark here is
transparent and a white tile beside them reads as a rendering bug.
Rendered with Qt itself and looked at before any of it was wired.

CAVEAT, recorded in SourceIcon: the Subsonic mark is NAVIDROME's logo,
and `subsonic` covers Gonic, Airsonic, Astiga and Ampache too. `ping`
reports the server's own type and it is already persisted, so branching
later is cheap — a known wrongness, not an unnoticed one.

THREE BUGS THAT ONLY THE RUNNING WINDOW COULD SHOW, all found by
driving it under Xvfb rather than by reading the log:

1. `MediaServerSettings is not a type` — a .qml missing from build.rs is
   not in the QML module. It resolves fine on disk, compiles nothing,
   and blanks the WHOLE settings page at runtime. All three existing
   audits passed. `qml_module_registration_audit.py` is the missing
   check and is now in the gate.
2. The toggle would not move. The panel reads its state from the
   settings DOCUMENT, unlike Plex's toggle which reads a bridge
   property, so every mutation has to republish it — connect, enable,
   disconnect and the end of a sweep now do.
3. Every Jellyfin album in the grid drew the LOCAL HARD-DRIVE badge:
   `local_rows::badge_source` had never been taught the two words and
   folded them into `_ => local`. Not cosmetic — that string is what the
   source chips filter on.

`play_single_from_source` also grew the arm it needed: a media-server row
from the recently-played rail has no library.db row to mint a LocalTrack
from, so it resolves through `registry.tracks_of` instead.

END TO END, from the UI, against the real server: toggle on -> type the
address -> Connect -> "jellyfin sync: 500/4924 ... 4924 saved, 0 pruned,
4924 cached" -> Local Library shows 461 albums with their covers and the
Jellyfin badge. The cached quality distribution is identical to what the
server reported in the original protocol sweep.

428/428.

cc
2026-08-20 10:57:28 -06:00
vicrodh 63a4f2daf1 feat(qt): the Local Library union, the settings store and the sweep
Three pieces that only mean anything together: rows in the shared cache,
a union that shows them, and settings that decide whether either
happens.

THE UNION. `get_albums_metadata_page` grows a second, INDEPENDENT
attachment. `plex_cache` stays; `remote_cache` is the shared mirror, and
they attach separately on purpose — a missing Plex cache must not cost
the user their Jellyfin rows. `attach_best_effort` is extracted because
the failure mode of a botched ATTACH is a silently local-only library,
and a failure stays non-fatal: refusing to list any albums because one
server is off turns "Jellyfin is unplugged" into "your library is
empty".

The union takes an ENABLED-SOURCES list, not just a path. The mirror
holds every remote source, so the master toggle has to be a filter
rather than a detach: turning Jellyfin off must hide Jellyfin's rows and
leave Subsonic's alone. That predicate is interpolated into SQL, so it
is VALIDATED rather than escaped — only [a-z] survives, an all-invalid
list yields `0` (show nothing, the safe direction), and a test feeds it
`x'; DROP TABLE local_tracks; --`.

The remote group key is `<source>:<album id>`, matching Plex's
`plex:<hash>`. That namespaces two servers using the same album id AND
lets a source claim an album card that arrives with no source word,
which is every card that round-trips through a QML string property —
`recognises` grew that arm and `claim` strips the prefix, because
carrying it inward is how Plex ended up with two album keys only one of
its queries understood (IC-2).

THE SETTINGS. One store for both servers, keyed by a `server` column,
mirroring the shared-cache decision. What each protocol needs differs
and `is_configured` is the one place that knows: Jellyfin needs its
issued token, Subsonic needs a password because it has no session and
re-derives `md5(password + salt)` per request.

Two identifiers are minted once and persisted, and both are
load-bearing. Jellyfin's DeviceId, because re-authenticating under the
same one revokes the previous token — a constant would make two installs
log each other out, a fresh one per launch drops the last run's token.
Subsonic's salt, because rolling it per request makes every cover URL
unique and the artwork cache keys on the URL.

THE SWEEP. In the frontend, where the runtime and the progress UI are,
writing through the cache handle the SOURCE owns so sweep and reads can
never point at different users. Jellyfin gets progress and a delta path
(45.8 s for 4924 rows); Subsonic needs neither (0.81 s for 6678).
`prune_stale` runs only after a COMPLETED full sweep — a delta never
prunes, because it deliberately does not see unchanged rows and would
read every one of them as deleted. `last_sync_at` is stamped only on
success for the same reason.

Verified against the running binary, twice:

  source registry bound: local_tracks=617 plex=connected
    ephemeral_store=yes remote=none configured
  jellyfin: minted a device id for this install
  subsonic: minted a salt for this install

...and on the second launch, zero re-mints and the same values on disk —
which is the property that keeps a Jellyfin session alive.

qbz-qt 428/428, qbz-app 224/224, qbz-library 100/100, qbz-source 88/88.
Release build clean, zero warnings.

cc
2026-08-20 09:20:07 -06:00
vicrodh 51aa8ba081 test(source): walk the whole chain against both real servers
The unit tests prove each method in isolation against a hand-made row.
They cannot prove the thing that actually broke this project once: that
the pieces are CONNECTED. `source_wiring` shipped a registry whose
sockets were empty and every test stayed green, because a pure-function
test has no bound user and a structural gate only proves the OLD path is
gone.

So this walks it: sweep a real page off the server, write it through the
same mapping the sync will use, bind the source exactly as the app binds
it, then claim -> tracks -> meta -> artwork -> playback, and finish by
pulling real bytes through the url the source produced. Nothing is
asserted from a type; everything is asserted from a server's answer.

The assertion with the most teeth is the identity round trip. A queue
row as `cached_to_queue_track` builds it — namespaced id plus the
server's id in the hint — must claim back to the server's id, and the
same row must ALSO resolve with no hint at all, through the cache. Those
are the two shapes a producer can hand over, and a mismatch between them
is bug 2 wearing a new source's clothes.

`assert_serves_audio` issues a Range request, which is what the
progressive feeder does: it checks seekability, refuses a chunked
(transcoding) answer, runs the Subsonic error-envelope guard over the
body, and keeps the test to 64 KB instead of a 100 MB FLAC.

Against the bench, 3/3:

  jellyfin  swept 500 of 4924 -> album "...And Justice For All",
            9 tracks, tier "hires", cover image/jpeg (tokenless,
            self-keying), stream 206 audio/flac
  subsonic  navidrome openSubsonic=true, swept 500 -> album
            "POWERS OF VOICE (Disc 1)", 13 tracks, tier "cd",
            cover image/webp keyed on subsonic:al-…_0 rather than on
            the credentialed url, stream 206 audio/flac
  registry  each namespace routed to its owner, and a Plex id still
            goes to Plex

The third needs no server and runs everywhere.

cc
2026-08-20 08:01:47 -06:00
vicrodh b09f03b739 feat(source): JellyfinSource and SubsonicSource — §10's acceptance test, paid
Design 02 §10 predicted a fourth source would be "one impl plus one
registration line, zero edits in any view". Two of them landed, and the
prediction held: no arm in any view, none in the playback router, none
in the artwork pipeline, no entry in a url taxonomy. Every one of those
became structural during stages 3 and 4, which is what this commit is
the receipt for.

One honest correction to that claim, now written in sources/mod.rs: it
was true only AFTER stages 3 and 4. From stage 1 the seam existed but
playback and artwork never asked it anything, so a source added earlier
would have claimed correctly and then been silent and coverless.

`sources/remote.rs` holds what the pair shares — the cache handle and
the row mappers — so they cannot drift the way the three copies of
play_audible did. The two conversions that are easy to get backwards
(ms -> s, Hz -> kHz) live there once, with a test.

Identity, and it is the part with teeth. A queue row carries BOTH the
namespaced id the grid published and the server's own id in the hint,
and `claim` prefers the hint: rowids are reassigned by a cache rebuild,
the server's id is not, so a queue that outlives a re-sync still
resolves. Recognition is by source word or namespace bit ONLY — never by
the shape of an opaque id. A Jellyfin item id is a 32-char hex GUID and
a Subsonic one is base62, but neither is guaranteed, and inferring a
source from a string's alphabet is the class of guess the seam deletes.
Both sources are tested against every neighbouring floor: Plex's 1<<40,
each other's, the ephemeral store's 1<<48, and a plain local rowid.

The Subsonic brand spellings — navidrome, gonic, airsonic, astiga — fold
to ONE source. They speak one API and QBZ reads them through one impl,
so a row stamped with the server's brand must still resolve; returning
None would make it Unclaimed and it would refuse to play.

Artwork differs between the two, and only because the protocols do.
Jellyfin's cover url carries no credentials, so it is stable and its
cache_key can be the url itself. Subsonic's carries the salt and the
size, so its key is the opaque coverArt id instead — keying on the url
would re-download every cover whenever either moved, and would put a
credential in a cache key.

Both playback tickets are Stream with the bit-perfect contract spelled
out: `?static=true` for Jellyfin, `format=raw` for Subsonic, and tests
that fail if `audioCodec` or `maxBitRate` ever appear.

One test had to be rewritten rather than deleted: `from_word("jellyfin")
== None` was the example for "unknown words are not guessed into Qobuz".
The property still holds and is now spelled with "emby".

qbz-source 84/84, qbz-media-cache 11/11, qbz-qt still builds clean.

cc
2026-08-20 07:58:03 -06:00
vicrodh 0dfec607c2 feat(media-cache): one cache table for every remote server
The Local Library union works by ATTACHing a remote server's mirror and
UNIONing it with local_tracks, and today that mechanism names Plex
inside a shared crate: `get_albums_metadata_page(..., plex_cache_path)`
with `FROM plex_cache.plex_cache_tracks` hardcoded twice. Copying it per
source means three Option<&Path> parameters and six UNION arms, and
every future source pays again.

This is the generic form: one table with a `source` discriminator, one
ATTACH, one UNION arm. The schema is `plex_cache_tracks` with its
columns renamed — all 19 map 1:1 onto Jellyfin and Subsonic, which is
what made "generic" a measurement rather than a guess.

Plex is deliberately NOT migrated here. It is a live table with 17145
rows on the owner's install, and folding it in alongside two new sources
would make one commit where a regression could come from either. This
table proves itself with Jellyfin and Subsonic first; Plex moves in as
its own change with its own verification. The cost until then is a
second ATTACH in one SQL builder, which is cheaper than the risk.

Identity: the namespaced Local Library id is `FLOOR | rowid`, with
Jellyfin at 1<<41 and Subsonic at 1<<42 — disjoint from Plex's 1<<40 and
the ephemeral store's 1<<48, asserted against both. The payload is this
table's AUTOINCREMENT rowid, NOT a hash of the server's id. Plex had to
hash because its ids predate its cache, which is why resolving one costs
a table scan; a new table has no such debt, and at ~50k tracks a 40-bit
hash carries ~0.1% odds of a collision that would play the WRONG TRACK.

Writes are UPSERT on (source, item_id), never delete-then-insert: the
row id is published identity — it sits inside queue entries and
session.db — so a re-scan must not mint a new one for a track that was
already there. AUTOINCREMENT for the same reason: a freed id must never
be handed to a different track. Both are pinned by tests.

`prune_stale` keys on updated_at rather than a set of ids, so a 50k
sweep needs no giant IN(...) — with the documented precondition that it
only runs after a sweep that COMPLETED, since a dropped connection
halfway through would otherwise read as "the server deleted everything
it did not get to".

11 tests, including the id-namespace matrix against the two existing
floors and the untagged-track ordering (75 of 4924 measured Jellyfin
rows carry no track number and must not sort above track 1).

cc
2026-08-20 07:49:55 -06:00
vicrodh 0f41c44dac feat(subsonic): the protocol crate, with the HTTP-200 trap enforced in code
Stage 5's second protocol crate. One client for Navidrome, Gonic,
Airsonic, Astiga and Ampache — which is why the owner scoped this as
"Subsonic, not just Navidrome". Only Navidrome 0.63.2 was on the bench,
so the places servers are likely to differ are named rather than
smoothed over.

The dangerous property of this protocol is that it reports FAILURE with
HTTP 200. `error_for_status()` passes on a refusal. Three defences, all
in code rather than in a reviewer's memory:

- `parse_envelope` is the only way a body becomes a payload, and it
  checks `subsonic-response.status` first. It also accepts XML on the
  failure path, because the measured no-credential getCoverArt reply came
  back as XML DESPITE `f=json` — a JSON-only parser would report "not
  understood" for a perfectly clear "missing parameter: 'u'".
- `looks_like_audio` exists because a refused stream is a ~200-byte JSON
  blob served as the audio body. That is the one way this feature could
  reach the protected decoder with garbage.
- code 40 and 70 become their own variants, so a caller can act on
  "re-authenticate" without matching on prose.

Two mapping decisions worth stating: `duration` is SECONDS on the wire
and milliseconds in QBZ, and `bitDepth` is **0** for a lossy track where
Jellyfin reports null — both sentinels fold to None so downstream sees
one vocabulary rather than two.

The salt is FIXED per install, not rolled per request. Rolling looks
more careful and would make every cover URL unique per request, which
re-downloads every cover on every pass since the artwork cache keys on
the URL. It buys nothing: the password is not transmitted either way.
The consequence is recorded where it matters — a cover URL carries
credentials, so callers must key their cache on the opaque coverArt id.

`detect_sweep_mode` ASKS rather than checks the server's name: search3
with an empty query is 14 requests for this library and getAlbumList2 +
getAlbum is 675, but the fast path is the behaviour most likely to
differ on a server that was never tested, and probing one page costs
~100 ms.

14 unit tests + 6 live. The live suite runs in PARALLEL by design —
Subsonic auth is stateless, so unlike Jellyfin nothing here can revoke
anything else's session. Against Navidrome 0.63.2, three consecutive
runs, 6/6 each:

  ping ......... navidrome 0.63.2, openSubsonic=true
  wrong pass ... Unauthorized, and the raw request really answered 200
  sweep ........ Search3 detected; page 0 = 500 rows, 344 bit depths
  per-album .... fallback returns 500 album ids, 17 tracks in the first
  stream ....... 206 audio/flac, Content-Range total == the API's own
                 reported file size, so nothing was re-encoded
  cover ........ authed image/webp 69 KB; unauthed HTTP 200
                 application/xml 251 B, rejected by the guard

cc
2026-08-20 07:43:46 -06:00
vicrodh 910f7aec4d fix(jellyfin): one session per test binary — a repeat auth revokes the token
Reported as a flake: four live tests passed and one failed Unauthorized,
while other tests doing the same call succeeded. It is not a flake and
not the server being slow.

Jellyfin keys the SESSION on DeviceId, and a second AuthenticateByName
under the same DeviceId REPLACES the first — the earlier token starts
answering 401. Measured directly against 10.11.11 rather than inferred:

  same DeviceId:      auth -> T1, auth -> T2;  T1 = 401, T2 = 200
  different DeviceId: auth -> A,  auth -> B;   A  = 200, B  = 200

Five tests authenticating in parallel under one DeviceId were therefore
invalidating each other, and whichever finished first was left holding a
dead token. Running with --test-threads=1 hid it, which is why the first
run looked green.

The tests now share one lazily-initialised session, which is also what
the app must do. Two production obligations follow and are now written
on `auth_header` where somebody will read them: the DeviceId must be
stable PER INSTALL (persisted like local_plex::client_id — a constant
would make two QBZ installs log each other out, a fresh id per launch
drops the previous run's token and litters the server's devices page),
and the app must authenticate once and hold the token rather than
concurrently.

Verified in the parallel configuration the report used: 10 unit + 5 live,
three consecutive runs, 5/5 each time.

cc
2026-08-20 07:34:41 -06:00
vicrodh e578d8a54a feat(jellyfin): the protocol crate, verified against a live server
Stage 5's first half. Sibling of `qbz-plex` and deliberately shaped like
it, so `qbz-source`'s JellyfinSource will read like PlexSource:
frontend-agnostic, HTTP + DTOs, no cache schema and no settings store.

Three measured facts shape the file, and each one is a comment because
each one is a decision somebody could otherwise undo:

- `?static=true` is bit-perfect. Verified by md5 against the file on the
  server's own disk, not inferred from a content type. `is_direct_
  response` encodes the tell a transcode leaves — chunked with no
  Content-Length — so a server-side policy silently resampling a hi-res
  track is caught rather than played.
- Quality costs 45.8 s per 4924 tracks and there is no cheaper path.
  `Fields=MediaStreams` trims 29% of the bytes and saves nothing: the
  cost is server-side media-info hydration. The scan will own that
  knowingly, which is why `track_count` exists to give a progress
  denominator.
- Cover urls need no credentials, so unlike a Plex thumb they are STABLE
  and their cache key can be the url itself.

`stream_url` / `image_url` are free functions rather than methods. Two
reasons and both are load-bearing: `Source::artwork_token` may not touch
the network by contract, and a `reqwest::Client` cannot even be
CONSTRUCTED without a rustls provider — which a unit test does not have.

The mapper picks the audio stream BY TYPE, not by position: an embedded
cover rides in the same MediaStreams array as an `mjpeg` with BitDepth 8,
and taking [0] would badge a 24-bit FLAC as 8-bit. A lossy row keeps an
absent bit depth rather than defaulting to 16, which would badge every
MP3 as CD quality.

10 unit tests, plus 5 LIVE tests that skip unless QBZ_JELLYFIN_URL names
a server. The unit tests prove the JSON shape is understood; only the
live ones can prove the endpoint still exists, the token still opens it,
and the stream is still direct. Against Jellyfin 10.11.11: 5/5 — 500-row
page with 221 real bit depths of 4924 total, a Range request answered
206 with a `fLaC` bitstream, a tokenless cover, and a future-dated delta
returning zero.

cc
2026-08-20 07:15:20 -06:00
vicrodh 604f336e24 fix(qt): wire the source registry to its stores — it never was
This is the regression report from the previous build, and the cause is
that stage 1 built three injection sockets and nothing ever plugged into
them. `set_creds`, `set_ephemeral` and `bind_user` had zero call sites
in the entire frontend.

It stayed invisible for two stages because nothing needed them:
`claim` is pure, and `PlexSource::tracks`/`meta` read plex_cache.db
through qbz-plex's own global. The first consumers that DO need them are
playback and artwork — stages 3 and 4 — so routing those onto the seam
turned a latent gap into a live break:

- every Plex row: "plex: not configured (no Plex credentials
  configured)", so Plex playback died and every Plex cover in the Local
  Library grid resolved to ArtRef::Unavailable and vanished;
- every LOCAL row: an unbound DbPool refuses all reads, so LocalSource
  found no track and local playback died too;
- ephemeral rows had no session store to resolve against.

Wired now, each to the ONE authority that already existed: PlexCreds
over local_plex's settings store (read per call, never cached, so a
connect/disconnect mid-session is picked up), EphemeralTracks over the
session store, bind_user as the first statement of
bind_per_user_stores, teardown on logout, and the unbound-window
fallback so pre-login reads resolve the way local_state::with_db already
resolves them.

Also wired: the album identity mode. The grouping IS the query, so a
metadata-mode user's album play would have resolved a different track
list than the grid rendered.

`report_wiring` logs one line at bind time asking each socket a question
only a wired one can answer. The failure this fixes is silent by
construction — an unbound pool and a disabled Plex look identical to a
user with no local tracks and Plex switched off — and it stayed hidden
because the tests are pure-function tests and the structural gate only
proves the OLD path is gone, never that the new one is connected.

Verified against the running binary, not the test suite:
  source registry bound: local_tracks=617 plex=connected
  ephemeral_store=yes

qbz-qt 423/423, qbz-source 59/59.

cc
2026-08-20 02:12:17 -06:00
vicrodh 68235a4012 refactor(qt): the Local Library art index carries an ArtRef, not a string
Design 02 §9 stage 4, the Local Library half. `local_state.art_index`
becomes `HashMap<String, ArtRef>`: the row mappers register what the
row's OWN source says its artwork token means, at the last moment that
provenance still exists, and the windowed pipeline reads that answer
instead of re-deriving it.

`artwork_qt::classify` is gone from every caller outside artwork_qt
itself. It recognised a Plex thumb only because `local_plex::
is_thumb_path` was bolted onto it, so a token from any future source
that starts with `/` — a Jellyfin `/Items/<id>/Images/Primary` — matched
its filesystem-path arm and the pipeline opened it from disk: blank
cover, no error, nothing in the log. That is bug 3.

Producers wired: both row mappers, the folder tree (two sites), the
ephemeral pane, the album-detail header and the Artists rail.

The Artists rail needed real work rather than a cast. Its portrait comes
from one of three chain tiers (custom image, Plex portrait, borrowed
album cover) while `MergedArtist.source` describes the artist's LIBRARY
provenance and is legitimately "mixed" — which is not a source a token
can be interpreted by. `AlbumCredit` now carries its album's source word
so a borrowed cover keeps that cover's provenance, and `MergedArtist`
reports `image_source` beside `source`.

`fetch_plex_misses` carries `(artKey, fetch url, cache key)` instead of
collapsing the last two. They are not the same string — a Plex url is
re-tokenized every pass and a Qobuz one carries a size query, while the
key stays put — and collapsing them re-downloads covers already on disk.
`artwork_qt` grows `cached_path_for` / `download_pairs`, the
classification-free entries, sharing one download loop with
`download_missing` rather than a second copy of the pool and the
error-page guard.

Still on the old path and NOT in this commit: the Home / queue /
now-playing surfaces, where the string arrives as `HomeCard.art_url` or
`QueueTrack.artwork_url`. Those need IC-7 (HomeCard has no source field)
and the eight hardcoded `"source": "qobuz"` QML payloads first.

423/423.

cc
2026-08-20 01:44:31 -06:00
vicrodh ffb35085c9 feat(source): each source interprets its own raw artwork token
Design 02 §9 stage 4 needs the art index to carry an ArtRef instead of a
String, and the producers of that index are the row mappers, which hold
no MediaRef — the windowed grid pipeline is keyed by art_key (§3.4). The
two ways to give them one were both bad: sniff the string later (that is
`artwork_qt::classify`, i.e. bug 3), or claim per visible row and let
the source look the token up again, which for Plex is one plex_cache.db
query per row on a cold grid — on the exact grid whose scroll cost is
the open perf item.

`Source::artwork_token` is the artwork analogue of `claim`: the source
says what its OWN token means, at the moment provenance is still known.
It is pure — no network, no DB, no filesystem — because the token is the
whole input.

This is what makes stage 5 free. `classify` recognises a Plex thumb only
because somebody bolted `local_plex::is_thumb_path` onto it, so a
Jellyfin token (`/Items/<id>/Images/Primary`) reads as a filesystem path
— it starts with `/` — and the pipeline opens it from disk: blank cover,
no error, nowhere to look. Dispatching by owner removes the guess.

Three impls, each carrying the arm of `classify` that was really about
it: Local (file:// and absolute paths, plus the CDN url an offline row
can keep), Plex (the two declared thumb prefixes, Unavailable rather
than None when there are no credentials), Qobuz (CDN url, cache_key on
the RAW url so downscaling does not re-download per size class).

An unregistered source returns None, not Unavailable: a row whose source
word this build does not know has no art to wait for, and Unavailable
would park it in the retry-when-connected bucket forever.

59/59.

cc
2026-08-20 01:35:47 -06:00
vicrodh 22033672a1 fix(qt): claim the row before the Qobuz funnel, and before casting it
Two remaining stage-3 consumers.

`play_resolved_offline_aware` is the QOBUZ entry — the tier walk, the
offline cache, the quality request — and every MyQBZ and album play arm
sent its anchor there regardless of who owned the row. A local,
ephemeral or Plex first track resolved and then failed AT THE PLAYER
with "No Qobuz client available", which `myqbz_play_qt`'s own header
documented as open. It was also unsafe: the offline cache is keyed on
the raw u64, and a local_tracks rowid or a Plex rating key shares that
small-integer space with older Qobuz ids, so a collision played the
wrong track rather than failing cleanly (#638 fix 3's sharp edge).

The funnel now claims the row first and hands a non-Qobuz one to
audible_qt, which fixes all six call sites without touching a signature.
The claim is gated on the core's cursor already sitting on that track —
true for every caller, since they all set the queue and then play its
anchor — so a caller that plays some other id behaves exactly as before
instead of guessing.

`cast_qt::resolve_local_path` becomes a PlaybackTicket consumer (IC-10).
It was an ephemeral test plus a bare db.get_track on a QueueTrack.id,
which is only sometimes a library.db rowid: a Plex row carries a
namespaced id and an offline row carries the Qobuz catalog id, so both
missed and reported "Local file not found" — an answer naming the wrong
problem. Now File/DsdFile is servable and everything else is a named
refusal, which covers every remote source by construction rather than
the one word somebody thought to list. The Plex arm stays an early
refusal so it does not pay a network round trip to build a url it would
throw away.

Structural gate: no plex_resolve_track_media / plex_rating_key left in
qbz-qt outside comments. Four source-word matches remain on purpose —
local_queue_track STAMPS the word (producer side, not consumer),
play_single_from_source looks up a ROW rather than playing it,
cast's early refusal above, and batch_all_qconnect_castable, which asks
"does this row have a real Qobuz catalog id?" — a different question
from ownership, and one SourceId cannot answer since qobuz_download maps
to LOCAL while being castable.

423/423.

cc
2026-08-20 01:28:26 -06:00
vicrodh a55e426330 refactor(qt): collapse the three audible steps into one PlaybackTicket match
Design 02 §9 stage 3. `local_playback::play_audible`,
`local_album_actions::play_audible` and `local_ephemeral::play_file` were
three near-copies of the same routine, each matching on `track.source`
by hand, and they had already drifted:

- `local_playback` carried the `plex_rating_key` ladder — one
  engineer's reconstruction of a five-id table nobody wrote down, whose
  fallback is right for the MyQBZ path and wrong for the LocalLibrary
  one. `local_album_actions` held a copy of it, kept in sync by comment.
- both handed `track.id` to `play_local_file` for anything not Plex,
  which is wrong for OFFLINE rows: their `QueueTrack.id` is the Qobuz
  catalog id while the `library.db` row id rides in
  `source_item_id_hint`, so the lookup missed.
- the CUE fast path existed in two shapes. `local_playback` compared
  track ids, and since every virtual track of a CUE album is its own
  row that test only passed when replaying the same virtual track — it
  never fired for the case it exists to serve. `local_ephemeral`
  compared file paths, which is the right question, but only for rows
  in the session store.

`SourceRegistry::playback` now claims the row once and `audible_qt`
performs the ticket. The CUE test becomes exact for every source:
`audible_qt` records what its last `play_data` actually read, so "is
this container already loaded?" is answered against a real path with no
source lookup on the hot path.

`play_current_if_local` stops matching `Some("local") | Some("plex")`
(IC-12). Six source words occur on a queue row and the four it missed —
`user`, `ephemeral`, `qobuz_download`, `qobuz_purchase` — all name
files on disk, so auto-advance onto one fell through to the Qobuz
tier-walk, died with "No Qobuz client available", and
`auto_skip_unavailable` then declined to skip because that error is not
terminal-unavailable: one ephemeral track could stop playback dead.

The whole-body fallback in the streaming arm checks Content-Type and a
minimum length before the bytes reach the player. Plex cannot trip it,
but a Subsonic server answers a refused request with HTTP 200 and a
~200-byte JSON error envelope (measured against Navidrome 0.63.2), and
that must never reach the decoder.

Deleted: `play_local_file`, `play_plex_track`, `plex_rating_key` and its
three tests (the same cases are already pinned on `PlexSource`),
`local_ephemeral::play_file`, and the duplicate `play_audible`.

cc
2026-08-20 01:23:40 -06:00
vicrodh 7113ef5794 feat(source): add PlaybackTicket::Stream and emit it from PlexSource
Design 02 §3.3 predicted a `Stream` variant "when the documented
progressive-streaming follow-up lands". It landed first:
`local_playback::play_plex_track` already resolves the part URL and
Range-streams it through `qbz_player::remote_stream`, keeping the
whole-file download only as the fallback for a server that refuses
Range. Stage 3 cannot route playback through the ticket while the
ticket has no way to say "stream this".

`PlexSource::playback` now calls `plex_resolve_part_url` instead of
`plex_resolve_track_media`: the ticket resolves a LOCATION, it does not
fetch a body. Dropping the old `resolve_track_media` fallback is not a
behaviour loss — both functions run the same metadata request, the same
parse and the same part-key extraction, and only then does the second
also GET the body, so every way the first can fail the second fails
identically. Carrying the url on the ticket also removes the duplicate
metadata round trip the fallback performed.

`duration_secs` comes from the queue row rather than the core's current
track: a caller may be priming a row that is not current yet, where the
core would answer about a different one.

The url embeds credentials (Plex's token, and Jellyfin's / Subsonic's
when they arrive), so `Debug` redacts the query string while keeping the
origin — telling a LAN address from a plex.tv relay is exactly what the
perf log lines exist to answer. Three tests cover the redaction and the
`play_id()` arm.

cc
2026-08-20 01:10:30 -06:00
vicrodh 321d8fe753 fix(qt): align the playlist manager's card grid with its own headings
The grid delegate carried x: 32 on the delegate ROOT. A ListView owns its
delegates' position, so an x set there is not reliably the one that survives
layout, and it did not: the cards sat about 24 px left of "Folders (12)" and
"Playlists (9)", which PmPageHead draws at 32 through its own Column. Headings
at one inset and cards at another, on the same page.

The list and tree delegates right below it already use the shape that works —
a full-width delegate with the inset on the child — so the grid now does too.
The inset lives where the view has no opinion about it.

cc
2026-08-19 23:56:08 -06:00
vicrodh 7e233104f5 feat(import): playlist files, JSON, ListenBrainz and Last.fm as import sources
Implements qbz-nix-docs/deferred-2.0.3/playlist-importer-expansion-design.md,
whose §11 the owner ratified 2026-07-31. PlaylistSource is an enum, not a trait
(ADR-007): the set is closed, there is no plugin story, and every arm resolves
to the same ImportPlaylist the URL scrapers already produce — so the matcher,
the 2000-track split, the sink and the summary never learn a second kind of
source exists.

import_prepared_playlist takes the playlist BY VALUE and
import_public_playlist becomes a shim. That kills the double scrape the Tauri
re-fetch-on-execute left behind, and it is not optional for the new sources:
the rfd bytes of a picked file are gone by execute time and the dialog cannot
silently reopen. It also closes a real race — open() resets the session, so a
modal reopened between the Import click and the task's read handed a detached
task None.

FILES read the track list ONLY. The paths inside are never opened, copied or
added to the Local Library, and the modal says so in a persistent row rather
than a tooltip. Content sniffing beats the extension, with an HLS guard because
.m3u8 is also the stream-manifest extension, and a utf8-then-Windows-1252
fallback because .m3u and .pls predate UTF-8 and "Björk" has to stay matchable.

JSON locates the track list BY SHAPE. Depth is a DoS bound, not a locator: the
array sits at 0, 1, 2 or 3 levels depending on the export, and a hard two-level
rule drops an ordinary GraphQL envelope. Container demotion keeps a discography
from importing album names as track titles. Walls at 16 MiB, depth 8, 2M nodes
and 10k tracks.

SERVICES are public reads with no authentication; a connected account only
prefills the field. No qbz-integrations edge — its ListenBrainz client returns
tracks with no duration and drops the playlist title, both verified in its
source, and a direct GET /1/playlist/<mbid> reads all three in one request.

A Last.fm STATION IS A RADIO, and ?page=N is not pagination. Measured against a
live profile: pages 1 and 2 overlapped and batch sizes varied (20, 20, 36). The
first cut fetched 20 pages, never hit its empty-page exit, and returned 469 rows
holding 198 distinct tracks. It now dedupes as it draws, stops after two dry
draws, and stops at MAX_STATION_TRACKS = 200 — the number shown in "Found N
tracks" has to be one we can deliver, and a station is dynamic enough that a
bigger single import buys little (owner ruling: no false expectations of
"traeré casi 500" when we bring half). A failed draw no longer fails an import
that already has rows; Last.fm rate-limits a burst of these.

The summary reports THREE DISJOINT NUMBERS that sum to the total. skipped is
now counted from the matches that found nothing, not inferred as
total - matched, which folded duplicates into failures: that same import told
the user 271 tracks were skipped when 255 of them had matched perfectly well
and been deduplicated before the add.

23 new strings across the seven translated locales; en is the sparse fallback
catalogue and needs none.

cc
2026-08-19 23:55:56 -06:00
vicrodh c11bdf0654 feat(qt): bring back the New playlist modal, and delete the shortcut
The sidebar "+" called create_playlist(), which made a playlist named literally
"New Playlist" and navigated to it. Four of the modal's five fields had no other
door, and one had NO door at all:

  name          hardcoded          reachable in Edit playlist
  description   always empty       reachable in Edit playlist
  folder        always root        reachable in the sidebar row menu
  public        always false       NOWHERE
  offline-only  from connectivity  NOWHERE for an online user

That last row is why this counts as a regression rather than a rough edge.
offline_only decides whether the playlist is a local_playlists row
(local:<uuid>, library.db, no Qobuz account, never uploaded) or a Qobuz entity,
and the shortcut derived it from connectivity alone — so someone with a Qobuz
session could not create a local playlist without pulling the network cable.

Ported against CreatePlaylistModal.slint plus on_create_playlist
(main.rs:21107) and on_submit (:21496). The submit branches where the reference
branches, and a creation forced local by being offline KEEPS the flag, as the
reference's own note says, so Edit can unmark it later to enable "Upload to
Qobuz".

Two calls made here:

- Create rides the QbzPlaylistEdit singleton rather than getting its own, where
  the reference splits them. Same domain — a playlist's own metadata — with two
  documents that never interact, and a second QObject is a second boot() to
  forget; that bridge's header already documents a missing boot() swallowing
  publishes silently and forever.
- The folder picker hides on the offline-only arm. folders_qt::move_playlist
  takes a u64 and a local:<uuid> has none, so the reference shows the control
  and discards the value.

The shortcut is deleted rather than left beside the modal: a second creation
path that skips it is the fork the port rule forbids. All thirteen strings were
already in the catalogues.

cc
2026-08-19 23:55:25 -06:00
vicrodh e1876c15f0 feat(qt): the artist page's radio disc goes live, with a start-up indicator
The disc was btnEnabled: false with a comment saying neither engine had a seam
on a Qt bridge. Both do now. ArtistPageView.slint:426-461 opens a two-row
dropdown — QBZ Radio (the local qbz-radio pool builder) and Qobuz Radio
(/radio/artist) — and that is what this restores. The smart arm already
shipped behind Discover's spotlight RADIO card and simply was not wired here;
the Qobuz arm is new (get_radio_artist + the same enrich_radio_tracks the album
radio uses, because its track objects are just as bare).

Both labels already exist in all eight catalogues, so no new msgid.

AND THE WAIT IS VISIBLE NOW. A radio is a fetch, an enrich and a queue write;
until the first track resolves nothing on screen moves, so the click read as
dead. QbzCircleAction gains the `loading` arm the reference already has
(CircleAction.slint:18/:77) and QbzHome.radioPending carries the KEY of the
radio being built — "album:<id>" or "artist:<id>" — so the Discover rail spins
only the station that was clicked instead of all six.

The spinner rides QbzShell.pulseMs, NOT a RotationAnimation. Qt Quick has no
partial redraws, so a continuous animator presents the whole window at display
rate (~1.2% GPU per present on the owner's hybrid laptop); a 60 Hz spin for the
seconds a radio takes is exactly the bill the pulse law refuses. 12 degrees per
~30 Hz tick, and the Connections is disabled unless the button is spinning and
on screen, so at rest it writes nothing. theme/QbzSpinner.qml still uses a
RotationAnimation and is deliberately not reused.

On the cards, `loading` also pins the hover disc up: it only exists on hover,
so it vanished the moment the pointer left after a click, which is what made
the click look swallowed.

A RadioGuard disarms the key in Drop, so a bad id, a network error and an empty
result all clear it. A disc spinning forever is worse than no indicator.

cc
2026-08-19 23:55:07 -06:00
vicrodh e816436fd0 feat(qt): remember where each page was scrolled, across back and forward
The port of the block Slint repeats in 27 views (NavState.restore-scope /
scroll-restore / report-scroll). A .slint file cannot hand a component a
reference to a Flickable, which is why the reference inlines it; QML can, so
controls/ScrollMemory.qml exists once and 31 containers mount it.

Two divergences from the reference, both forced by the same fact — tabs are not
history entries in this port:

1. THE SCOPE IS NAMED BY THE VIEW, not derived from the entry by a scope_for
   table. Slint can derive it because its entries carry the tab
   (Favorites { tab }); here an entry is a bare route id, so a derived scope
   would read "library" for all five Library tabs and pour the Albums grid's
   offset into whatever tab mounted. Letting the reporter name its own scope
   buys tab granularity for free, and the mismatch case degrades the right way:
   an unmatched scope restores NOTHING instead of restoring the wrong thing.

2. THE RESTORE RE-ASSERTS instead of firing once. A Qt item view resets
   contentY to 0 whenever its model is replaced, and several pages assign their
   model from a binding whose inputs arrive over the network after the mount —
   the album page rebuilds listCells from three deferred rails, so it hands its
   ListView a new array up to four times in its first seconds and snaps back to
   the top over a restore that already ran correctly. The offset is held and
   re-applied until a 2.5 s deadline, or until the user takes over (a wheel, a
   drag or a flick raises `moving`, and from then the position is theirs).

Diagnostics are off by default: QT_LOGGING_RULES="qbz.nav.scroll.info=true" for
the QML half, RUST_LOG=qbz=debug for the stamp/arm half.

cc
2026-08-19 23:54:46 -06:00
vicrodh 47cab502f4 feat(qt): remember the immersive shader scene, not just the panel triple
"Remember the last immersive view" covered view_mode / mode / split_panel and
NOT the scene, so Plasma, the Spectral Ribbon, the Line Bed and Tunnel Flow
were exactly the views it could not remember. Worse, apply_open force-reset the
scene to Off on EVERY open edge, which is Slint's behaviour
(main.rs:10300-10301) and is only correct because Slint never stored one.

The scene now persists through a custom WRITE on the property, so every writer
funnels through it: the menu rows, the `g` ring and ShaderSceneLayer's
shader-error latch. That last one persisting Off is intended — a scene whose
effect failed to compile on this box must not be what the next open restores.

The open edge restores it behind three gates: the default-view pref is
"remember", the box is on the shader tier (off-tier the scenes render BLACK, so
restoring one would open immersive onto a black rectangle), and the stored
number is a scene this build ships. Any of them failing falls back to the Off
reset, which stays the fallback rather than disappearing.

The pref key is new to the shared ui_prefs.json and unknown to the Slint typed
struct. Worst case there is that a Slint write drops it and this falls back to
Off; the document is not poisoned.

cc
2026-08-19 23:54:09 -06:00
vicrodh 221572b36e fix(qt): hydrate the view a session restore lands on
"Startup page = where you left off" opened Library on an EMPTY page: the feed
was still [] until the user clicked Library in the sidebar. Same hole for
Mixtapes and Collections.

navigate_to is not the only door. A restore mounts its view by calling
nav_qt::record directly from on_session_entered — it must not clear the landing
tab or the detail latch of a session that has not started — and record is
history bookkeeping, nothing more. The per-view lazy load lived inside
navigate_to, so the restore path never ran it.

Split that load into hydrate_view and call it from both doors. Home never
showed the bug because load_home_once is its own unconditional phase of shell
entry; `local` is absent from the arm on purpose and is not a gap, since
LocalLibraryView loads its tab from Component.onCompleted.

cc
2026-08-19 23:53:49 -06:00
vicrodh d1d96e6918 fix(qt): the sidebar playlists menu stops going translucent under ambient
Sidebar.slint:920-922 gives that panel the sidebar's OWN ambient treatment
(surface-card at 50%), and the port copied it. That made it the one menu
surface in the app that reads through the artwork while the dynamic background
is on. Every other menu and flyout paints flat and opaque: QbzContextMenu and
therefore CardMenu, SidebarRowMenu, ViewModeMenu and the audio flyout, plus
NavFlyout, SidebarFolderFlyout, QconnectFlyout, GenreFilterPopup and
LocalFilterPopup. Swept all of them; this was the only outlier.

A menu is text being read against art that moves underneath it, not chrome, so
the outlier loses.

cc
2026-08-19 23:53:35 -06:00
vicrodh da8fc598f8 fix(qt): the immersive trigger only moves right on macOS
9c47d5b2e moved it unconditionally, and it had no business moving on Linux. The
whole reason for the move is that on macOS the band's left edge is the native
traffic lights' corner (x ~ 7-85, the pill's exact spot). Nothing else has
traffic lights, so nothing else had the problem — the control just relocated on
platforms nobody asked about.

Gated on `QbzShell.isMacos && !QbzShell.systemTitleBar`, the same predicate the
view's old `chromeLeftInset` used, so the trailing position appears in exactly
the configuration that motivated it. Everywhere else the trigger is back at
x = 0, where it has always been.

Positioned with `x` rather than swapped anchors: toggling between anchors.left
and anchors.right means clearing one with `undefined` on every flip, and this is
one expression either way.

Built and smoked on Linux (gate green). macOS behaviour is unchanged by this
commit — it was already the trailing one there.

cc
2026-08-19 18:42:26 -06:00
vicrodh 3366cc3997 fix(qt): Metal sides with OpenGL on the RHI mirror, not with Vulkan
The rule was derived from two data points and generalised into a negation:

    owner's Vulkan + mirror=false -> upside-down   (Vulkan needs the mirror)
    Xvfb Mesa GL   + mirror=true  -> upside-down   (GL does not)

"mirror on everything EXCEPT OpenGL" fits both, and it was wrong about the one
API nobody had run. On the Mac mini M2 the Line Bed comes out upside-down, so
Metal wants mirror=false like OpenGL rather than =true like Vulkan — the
generalisation reached a third platform on the strength of a shape it had never
been tested against.

Kept as a NEGATION with Metal excluded rather than rewritten as "Vulkan ||
D3D". Naming the APIs that DO mirror would silently stop mirroring on any enum
this file failed to list: an unknown member is `undefined` in QML and compares
false, so a typo or a future backend would degrade to "no mirror" with no error.
This way only Metal's behaviour changes and every other backend keeps exactly
what it was verified with.

All four RHI scenes carry the line, because the convention belongs to the
graphics API and not to the scene — a per-scene split would be the surprising
result, not the expected one. Only LineBed made it observable: it is the one
directional scene, so a flip reads instantly, where Plasma and TunnelFlow are
near-radial and hide it. RIBBON IS THE ONE TO CHECK NEXT — it is a spectrogram
with a time axis, so if the convention really is per-API it should have been
flipped too, and if it now looks flipped the rule is per-scene after all.

The three sibling headers said "mirror on Vulkan/Metal/D3D" and were corrected
in place; they had become comments that contradict their own code.

Built and smoked on Linux (gate green). The observation this fixes is the
owner's, on the Mac.

cc
2026-08-19 18:11:37 -06:00
vicrodh 9c47d5b2e7 fix(qt): the immersive view trigger moves beside the window controls
It sat at the band's LEFT edge, which on macOS is the traffic lights' corner —
x ~ 7-85, the pill's exact spot. The band was therefore being inset sideways to
make room for one 44px control, and that inset is what read as wrong: the shell
HeaderBar's rule is right for the shell, where the lights head a full toolbar
row, and wrong here, where the row is three floating glass pills over artwork.

Moving the pill instead of the row is the smaller change and the better one. The
left half of the band goes empty and the lights get that corner to themselves;
the search field is CENTRED on the band rather than laid out after the trigger,
so nothing else shifts. Every other platform is unaffected — the trigger simply
changed sides.

Its right margin RESERVES the capsule's expanded width (112) plus a 12px gap
rather than anchoring to the capsule's live left edge. The capsule grows
leftward on hover, so anchoring to it would slide this pill sideways every time
the pointer crossed the other one — motion with no meaning. Reserving the
maximum keeps both still. The radius becomes height/2, the same silhouette as
the capsule it now sits next to.

An earlier attempt dropped the WHOLE band below the lights; it is reverted here.
It moved the search field and the capsule down to solve a problem only the
trigger had.

The capsule stays. Worth recording why, since it looks redundant on macOS and is
only half so: its fullscreen toggle writes `Window.visibility`, which the green
light already does — but its X calls `QbzImmersive.open = false`, which EXITS
IMMERSIVE, and no traffic light can do that. It is one of three exits (Escape
and the player bar are the others) and the only visible one in the chrome.

Built and smoked on Linux, built on the Mac mini M2. The macOS geometry needs
eyes on a Mac.

cc
2026-08-19 16:31:16 -06:00
vicrodh d1fd99f947 refactor(i18n): qbz-i18n owns its catalogues; macOS immersive band drops below the lights
QBZ-I18N WAS NOT FRONTEND-AGNOSTIC, whatever its own header said. No slint or
tauri entry in its Cargo.toml — and eight `include_str!` reaching into
`crates/qbz-ui/translations/`. Deleting the Slint crate, which is planned before
the Qt release, would have failed to compile `qbz-i18n` and with it `qbz-qt`,
i.e. the entire app, over a data directory nobody read as a dependency. The
extraction that created this crate took the code and left the data behind.

The eight .po files move to `crates/qbz-i18n/translations/` (git tracks them as
renames, so history follows) and the includes become `../translations/`.
`qbz-ui/build.rs` borrows them back through `../qbz-i18n/translations` for as
long as that tree exists — the direction of the dependency is what matters, and
it now points the way the deletion needs.

MACOS: THE IMMERSIVE BAND GOES BELOW THE TRAFFIC LIGHTS, not beside them. It
was inset to their right with the shell HeaderBar's rule, which is right for the
shell — the lights sit at the head of a full toolbar row there — and wrong here,
where the row is three floating glass pills over artwork and a 44px pill parked
against the lights reads as debris.

Vertical clearance comes from `SafeArea.margins.top`, the same question the left
inset asks in the other axis: with ExpandedClientAreaHint the client area covers
the titlebar region and SafeArea reports how much of it is unsafe, with a 28px
floor for a 0 reading. The band keeps `leftMargin: 24` on every platform, which
puts the trigger's leading edge in the same 20-24 band the lights start in, and
the 16px gap under them is the band's own margin reused rather than a new
number.

The view trigger matches the cluster's width on macOS (SafeArea left margin,
78 floor): stacked, a 44px pill under a ~78px cluster reads as misaligned even
with the left edges lined up. Its radius becomes height/2 rather than a constant
18, or the widened pill would have been a rounded rectangle beside three
circles. Every other platform keeps the natural 44px, and the glyph was already
centred so it needed nothing.

The menu opens downward from a band that now has more room under it, so the
flyout has more clearance than before rather than less.

Built and smoked on Linux (gate green) and built on the Mac mini M2 (3m32s, no
errors). The macOS geometry itself needs eyes on a Mac.

cc
2026-08-19 16:03:02 -06:00
vicrodh 1352e499f9 fix(qt): the hand-written RHI items now compile on macOS
`build_rhi_items()` compiles the four QQuickRhiItems in `cxx/` with its own
cc::Build, and its macOS arm had never run: those files landed in e6c058c2f on
2026-08-16 and the Mac's last binary was from the 10th. The first build there
failed on all four, and the failure reads like a code error rather than a
toolchain one:

    cxx/linebed_item.h:24:10: fatal error: 'QtCore/QByteArray' file not found
    cxx/plasma_item.h:16:10:  fatal error: 'QtGui/QColor' file not found

Homebrew ships Qt as FRAMEWORKS, and two things follow. `include_paths()`
yields `<libs>/QtCore.framework/Headers`, which cannot satisfy
`#include <QtCore/QByteArray>` — inside that directory `QtCore` is the umbrella
HEADER FILE, not a subdirectory — so frameworks need `-F`, not `-I`. And the
private tree holding `<rhi/qrhi.h>` is not under `QT_INSTALL_HEADERS` at all
there (that prefix carries no `QtGui/`); it lives at
`<libs>/Qt<Module>.framework/Headers/<version>/Qt<Module>`.

Reproduced outside cargo with the exact flags cc-rs emits: the `-I` form fails,
`-F <libs>` compiles clean. The branch keys off `CARGO_CFG_TARGET_OS` rather
than `cfg!()`, because a build script runs on the HOST and the two differ under
cross-compilation. Every path still comes from `qmake -query`, so nothing is
hardcoded and a CI runner needs only Qt on PATH.

The Linux arm is byte-for-byte unchanged. Verified both ways: `cargo check` here
and a full release build on the Mac mini M2 (Qt 6.11.1, Homebrew) — 3m56s, no
errors, binary produced.

The header comment that claimed macOS used "the same layout" is corrected in
place; it was right that the arm was never compiled and wrong about why that
was safe. CI notes for the Qt track — which has no workflows yet — are in
qbz-nix-docs/cicd/qt-macos-build-notes.md.

cc
2026-08-19 14:27:51 -06:00
vicrodh 931cb06d38 feat(qt): ease the visualizers on the pulse, and make Plasma react
TWO FINDINGS, both of which falsified something that was being assumed.

FLUIDITY IS NOT FRAME RATE. Driven at QBZ_PULSE_MS=10 the shell presented ~100
frames/s — qt-batches confirmed it, and the script now prints the pulse it ran
with so two logs cannot be confused — and the bars looked exactly as they had at
30. They would: every published frame was applied as a STEP, so the extra
presents repainted the same discrete values three times over. What the Slint
build does differently is not rate, it is `animate bar-h { 90ms }` — it moves
BETWEEN values.

So the interpolation is back, ON THE PULSE, and it costs nothing. The old
interpolator was expensive because of its private 16 ms Timer, not because it
interpolated: it presented at its own rate, unsynchronised with everything else.
Easing on the pulse presents exactly as often as stepping on the pulse did — one
repaint per period either way, only the value differs. Self-parking survives: a
converged ease writes nothing and schedules no repaint. The price is lag, and it
is the honest one — k = 0.45 is ~83% there in three pulses (~100 ms), matching
the reference's 90 ms, against 46 ms of FFT window and 77 ms of Rust smoothing
already in the chain.

The from/to/progress triple is gone with it, and its consumers moved to `cur`.
Note for the next reader: `SpectrumBand` drove all three of its modes from
`Connections` on those properties, and a Connections handler naming a property
that no longer exists is NOT a compile error — QML warns at runtime and simply
never fires. That is how the whole band went dead at once; grep for
`on<Name>Changed` when removing a property, not only for direct reads.

PLASMA WAS DRIVEN BY THE SLOWEST SIGNALS IN THE PIPELINE while the fast ones
travelled beside it unconnected. `transientAmp`, `bandsLo` and `bandsHi` were
published, parsed and stored, and never handed to the item — the shader declared
them and received zeros — so everything ran off the energy bands, which decay
x0.85 per frame (~473 ms) against the bars' x0.65 (~178 ms).

Worse, `beat` stops being a beat on dense material: it decays x0.88 per tick, so
above ~3 hits/s it never falls far enough to come back as a spike and settles
into a shallow ripple around 0.7. A splat multiplied by it becomes a steady
glow, and the busier the music the flatter the picture — the most frenetic input
produced the least motion. `beatAc` subtracts a slow density floor, so a hit
reads as a hit at any tempo.

The scene itself was rebuilt around the reference the owner filmed (Butterchurn
on a percussion-heavy track). Its recipe is three parts: one thin annulus
injected per frame that the feedback warp marches inward, so the concentric
rings ARE the last N frames of audio laid out in space; retention high enough
for that to stack (this held 0.85, where a trail died in ~15 frames and nothing
ever accumulated); and angular structure from reading the spectrum BY ANGLE.

But a CLOSED ring was wrong, and the owner named why: a perfect inner
circumference reads as a drawn shape, hard-edges the field, and at this
retention successive rings pack into one solid disc — which is what killed the
ambient feel. The annulus is gated by angle now, so a hit paints arcs the
conveyor carries inward, the circle never closes, and the gaps stay open for the
background. Noise bends, frays and thins those arcs so they are made of plasma
rather than of compass.

The worms are back at full strength with tighter cores and a contrast curve — a
raw gaussian is all shoulder and reads as haze — and their orbits now reach past
the arc band instead of being trapped inside it. Colour comes from ONE
album-derived triad, so everything used to be a shade of the same hue; a
Rodrigues hue turn splits it into worms, arcs and an ambient wash that sit apart
whatever album is playing. That wash is what stops the background being void for
the foreground to be a cut-out against. A soft limiter replaces the hard clamp:
at 0.945 retention a clamp pins the middle to white and the structure vanishes.

Release build clean, offscreen smoke green, 421/421.

cc
2026-08-19 13:53:57 -06:00
vicrodh 730ae6998e feat(qt): windowed View-all artwork, a real per-rail default, Play later, MyQBZ tabs
VIEW-ALL PAGES GOT THE LIBRARY > ALL TREATMENT, in both halves.

PlaylistBrowse windowed nothing: every card mounted, and a View-all of
playlists runs to hundreds, each a PlaylistCard carrying a four-tile collage.
AlbumCollection had solved that for the album pages and this one never got it.
The band is sampled on a timer rather than bound to contentY — a direct binding
re-evaluates every delegate on every scroll frame — and out-of-band cards keep
their footprint as bare Items, so the scroll geometry and the infinite-scroll
trigger never move.

Covers arrived in ONE batch: Rust downloaded every missing cover of the page and
then republished the whole document to attach the paths, so a page had no
artwork until it had all of it, and the republish rebuilt every delegate on
arrival. Both browse pages now ask for what is near the viewport and take the
answers one key at a time through the existing sidebarArtworkWindow ->
libraryArtworkReady seam. `attach_card_art` stays — it is a pure disk-cache
lookup, so anything already downloaded still renders on the first frame with no
request. The two now-dead republish helpers are gone with it.

The AlbumCollection change is ADDITIVE for its nine hosts: those that pass no
`flick` never report, the map stays empty and `artOf` falls through to the
`artPath` they already publish.

ITEMS PER CAROUSEL now has a real default instead of a lie. The first entry read
"All", which was wrong twice over: an "all" in Qobuz terms is what the View-all
page holds, and /discover/index hands each rail a different count anyway —
measured on a live response, new_releases 26, ideal_discography 26, playlists 20,
most_streamed 15, and qobuzissims / album_of_the_week / press_awards 10. No
single label could be honest about all of them, so the default became a real
uniform cap of ten and the entry says so. That is a behaviour change and not
only a label one: the rails that used to arrive at 26 now arrive at 10, and the
select is how a rail gets more. Ten therefore stops being a separate option
because it IS the first one, and the number comes from Rust so the label and the
cap cannot drift. The select also went 82 -> 124 wide: "Default (10)" elided to
"Defaul…" in both the closed control and the popup.

PLAY LATER on the album page's track rows. It was off because
`enqueue_album_track` had only two arms — "next" and "everything else appends" —
so "later" and "queue" both plain-appended and offering both would have been two
menu entries doing one thing. The third arm lands on the manual block's tail via
`add_track_later`, which is the whole distinction, and the QConnect route stops
collapsing "later" into "queue" (route_track_to_peer has had a real later arm
all along). Only then does the entry turn on.

MIXTAPES <-> COLLECTIONS as a tab bar, the Discover/Library pattern where the
tabs stand in for the page heading. They are separate ROUTES sharing one
component, so a tab click navigates rather than flipping a local property: the
sidebar highlight, the back stack and the `kind` Binding then agree on where the
user is, and the page transition is the one every route change gets.

Eight locales. Release build clean, offscreen smoke green, 421/421 and 216/216.

cc
2026-08-19 13:53:30 -06:00
vicrodh e9f09a59a4 feat(qt): opt-in — a click on a section row opens its first tab
Settings > Appearance, OFF by default. With it on, clicking Discover lands on
Home and clicking Library lands on All, instead of only opening the section's
flyout and waiting for a pick.

The rule lives in NavFlyout because the catalog does: "the first tab" is
`entries[0]`, and routing it back through `activate` means the My QBZ section —
whose entries are plain views carrying no tab — needs no special case. Both
hosts call it from their row's click next to the open they already do, so the
sidebar and the title bar cannot drift.

The pref rides `settingsJson` like the two Purchases toggles beside it, so
nothing is wired beyond the write and the republish every settings toggle
already does. The read is a guarded parse: a bare JSON.parse in that binding
throws on the pre-publish frame and would take the whole nav catalog — and
therefore both hosts — down with it.

Eight locales. Release build clean, offscreen smoke green.

cc
2026-08-19 09:34:05 -06:00
vicrodh 81e172457f fix(qt): migrate playlist_meta, break a router binding loop, zebra the playlist
RECENTLY PLAYED PLAYLISTS STAYED EMPTY THROUGH EIGHT TRACKS, a restart and a
manual refresh — while the plays were sitting in the database the whole time.
Read off the live file rather than reasoned about: `playlist_play_events` held
3, 9 and 10 rows for three playlists, so the context edge was working perfectly;
`playlist_meta` was empty, and its `.schema` said why — no `covers`, no
`own_image`.

`covers` and `own_image` were added by inlining them into the CREATE. `CREATE
TABLE IF NOT EXISTS` does NOTHING when the table already exists, so anyone who
had run the previous build kept the six-column table, every upsert failed on
"no such column: covers", and the rail JOINs meta against events — so it
rendered its empty state with the evidence one table away. The failure was
invisible by construction: swallowed into a warn, and the UI's empty state is
also its correct state for a user who has played nothing.

A brand-new table is only new ONCE. The moment a build runs anywhere its schema
is legacy and every later column is a migration, so the pair moves to the same
idempotent ADD COLUMN the album history already uses, and the upsert's failure
becomes an error rather than a warn. Verified against a copy of the real
database: the columns land and all 22 events survive. A regression test builds
the exact pre-covers schema, records a play into it, migrates, and asserts the
upsert works and the old event still counts — it reproduces the bug without the
fix, and it also asserts a second `init_schema` is a no-op, since every launch
calls it.

The meta is written by `playlist_qt::load`, so the three playlists already in
the events table surface as soon as each is opened once.

BINDING LOOP IN THE ROUTER, and this one was self-inflicted: "QML Loader:
Binding loop detected for property source". `_stampRoute` runs inside the
`source` binding, and it read the last path from a `property string` and then
wrote it — a binding depending on a property it mutates. QML detects the cycle,
warns, and stops re-evaluating, which is a broken router and not merely a noisy
log. The holder becomes an object whose MEMBER is mutated: no change signal, so
the dependency is satisfied once and never fires again.

(The second warning on the same file, the `when` loop on the nav-flyout
`activeTab` Binding, is untouched by any of this work and predates it. Left
alone deliberately — it is a warning on a working feature, and rewriting that
Binding is its own change.)

ZEBRA on the playlist detail's track rows, like the local album page. TrackRow
already owns the stripe, so it is one property.

Release build clean, offscreen smoke green, 421/421 and 216/216.

cc
2026-08-19 09:01:32 -06:00
vicrodh 907d366d71 fix(qt): Library artwork loaded one row; add Refresh content to the configurator
LIBRARY ALL / ALBUMS LOADED EXACTLY ONE ROW OF COVERS and then nothing until
the user nudged the scrollbar. The grid and the list reported their artwork
window on contentY, model, width, visible and completion — but NOT on height,
and their height is gated on `visible` (`height: visible ? parent.height - 16 :
0`, the 2026-08-17 change that stops a hidden body from refilling). QML gives no
ordering between "the `visible` change handler runs" and "the `height` binding
that depends on `visible` re-evaluates", so when the handler wins the reporter
reads height 0 and computes `lastRow = ceil(0 / cellHeight) + 1 = 1` — a window
of exactly `cols` items. One row. Nothing fires again until a scroll, which is
why moving the scrollbar "fixed" it.

Playlists were unaffected and that asymmetry is the tell: PlaylistCollage
dispatches its own urls per delegate, independent of the window, while album
cells live off `artMap`, which only the window report fills. The control case is
in the same tree — LibraryAlbumsList, the playlists list and the Artists rail
all report on height and none of them has the defect. The two bodies that lack
the handler are exactly the two whose height is gated on visibility; `width` had
one because width always changed, `height` never did before the gate.

REFRESH CONTENT in the Discover configurator. The index is fetched once per
shell entry (`HOME_LOADED` latches and `reload_home` is never called again), so
a session shows the page it opened with — no new catalogue rows, and none of the
plays made since, because the recently-played rails are built from the local
history in that same pass. Everything else in that modal re-renders from the
cached candidates, which is precisely what cannot produce fresh ones. The
invokable already existed for the error-state retry; this is the door. The Reset
button's markup became a shared FooterButton so the pair cannot drift.

cc
2026-08-19 08:37:44 -06:00
vicrodh f0d753a751 fix(qt): fade on tab switches, playlist recents artwork, per-rail sizes, wheel
Four defects from the smoke of 26ceeab82.

TAB SWITCHES DID NOT FADE. The page fade hangs off the router's Loader
`source`, and a tab switch inside a view never touches it — so Discover's four
tabs, Library's and Local Library's cut hard while a route change faded, which
is the same event from where the user sits. `activeTab` is the one name every
tabbed view uses for this (the nav-flyout Binding already routes through it),
so one Connections in the router covers all of them; `ignoreUnknownSignals`
leaves the untabbed views alone. No watchdog and no opacity assignment there: a
tab body is built synchronously in the same turn, so `restart()` alone — which
sets the page to `from` — is the whole transition. The `running` guard keeps a
tab stamped right after a route change from restarting that route's fade.

RECENTLY PLAYED PLAYLISTS DREW PLACEHOLDERS. The rail published only
`artwork_url`, taken from the detail page's `cover_url` — and that field is
empty for every playlist WITHOUT a graphic of its own, which is most of them
and is exactly why the detail page falls back to a collage. A local playlist has
no Qobuz graphic at all, so it could never resolve one. The meta store now keeps
the member covers and an own-image flag beside the url, and the card gets both
arms it already knows how to draw: contain-fitted single graphic, or the mosaic.
All of it rides `art_url` and lets `attach_cached` fill the path —
`artwork_qt::cached_path` already classifies http, Plex and on-disk paths, so a
custom cover and a local file resolve through the same pass as a Qobuz url.

ITEMS PER CAROUSEL WAS ONE NUMBER FOR THE WHOLE PAGE. Tauri's was per rail
(`HomeSettingsModal.svelte`), and the global was a simplification that lost the
point of the feature: a 25-wide "New Releases" beside a 10-wide "Recently
Played" is why anyone opens it. Storage is now a row per section — absent means
the default, and setting a rail back to the default DELETES its row, so the
file only carries what was actually changed. The select moves into each row of
the configurator, and the cut moves into `order_by_prefs`, keyed by the PREF id:
`mostStreamed` resolves to a per-tab variant candidate, so the section's own id
is not always the id the user configured, and that loop is the only place both
are in hand.

OPEN OVERLAYS LET THE WHEEL THROUGH. A MouseArea consumes presses and NOT wheel
events, so with the configurator open the pointer still scrolled the page
underneath — over the panel it moved the panel's own list and the page behind it
at once. These overlays are plain Items rather than Popups, so nothing grabs for
them and each has to stop the wheel at its own surface: the configurator, the
genre popup, the playlist-category popup and the Local Library funnel.

Release build clean, offscreen smoke green, 421/421 and 215/215.

cc
2026-08-19 07:34:28 -06:00
vicrodh 26ceeab828 feat(qt): mixed playlists, page fade, category filter, per-context recents
Six parity fronts against the Slint/Tauri references, plus the applied-filters
tooltip the app never had.

MIXED PLAYLISTS — the bug. A mixed playlist is a QOBUZ playlist carrying local
and Plex rows through library.db's playlist_local_tracks / playlist_plex_tracks
sidecar tables. This port could WRITE those rows (the picker) and COUNT them
(the Playlist Manager badge), but playlist_qt::load built its rows exclusively
from the Qobuz API response, so a track added to a playlist never appeared on
the page it was added to. Two comments elsewhere claimed the merge already
happened; they were describing the reference. Ported literally:
read_sidecar_rows_blocking (healing + bulk Plex resolve) and interleave_rows
(absolute-slot claim, same-slot collisions all emit), merged in load, with the
merged queue adopted as the open-detail snapshot.

Routing had to split with it. local_playlist_qt::open_id() was the one
predicate for every action, and a mixed detail adopts that same snapshot — so
it now means "a local:<uuid> detail OR a mixed one", which is right for
play/shuffle/row-play and wrong for the writes. Remove and reorder route on the
new local_detail_open() instead; without that, removing a local row from a
Qobuz playlist wrote into the local-playlist repo, found nothing, and reported
success. Removal by source is a third arm: the Qobuz arm parses the display id
as a u64 playlist_track_id, and a local row's display id is a library.db rowid
in the same numeric space. Reorder is refused on a mixed detail for the same
collision, in Rust and in the view.

PAGE FADE. 300 ms OpacityAnimator on the mounted page, OutCubic, honouring
reduceMotion. Zero added latency: the router's Loader is synchronous, the UI
thread is frozen through the mount and no frame exists in which a fade-OUT
could be shown, so the page snaps transparent in the same turn as the rebuild
and only the reveal is animated. An Animator and not a NumberAnimation because
it runs on the render thread — measured 0.53 vs 0.11 of progress with the GUI
thread blocked — and not on the shared pulse because the pulse arrives on the
very thread the mount blocks.

The first cut faded a veil coloured from the pane, and that was wrong in a way
that only shows in the configuration the app actually runs in: AppShell paints
the pane surface-main@0.22 while the ambient background is on, so a veil at
full opacity covered 22% of the page and the fade read as a flicker. Fading the
page has no such failure mode and keeps the ambient art visible.

SPLASH COPY. The login screen was already 1:1 (18 msgids, verified by diff).
The splash was missing the legal disclaimer the reference pins to the bottom of
the screen, drew "QBZ" / "QOBUZ PLAYER" as bare literals (English in all eight
locales), and hardcoded the dark palette — black on the other 34 themes.

CATEGORY FILTER on the Home playlists rail. Slugs now travel per card and the
offered set comes from the same /discover/index response, so the filter is
client-side over the cached 40 with no extra request. The selection lives in
Rust because HomeView is destroyed on every navigation; the reference keeps its
own for the same reason. New trigger + popup, and a `leading` slot on
QbzSectionHeader to hang it off the title line.

APPLIED-FILTERS TOOLTIP. A third QbzTooltip mode taking [{group, values}],
below the control with an upward caret. Overflow is staged summary and never
scroll — the overlay is enabled:false by construction, so a scrollable tooltip
could not be scrolled. Wired where a filter is INVISIBLE (collapsed to a count
or a glyph): the genre chip in four hosts, the Home tags, the Local Library and
Purchases funnels, the Artist Scene genres.

RECENTS BY CONTEXT. QueueTrack already carried context_kind and exactly one
function threw it away: record_queue_track wrote both album histories on every
track start whatever the user had put on, so one 40-track playlist filled a
24-entry album rail with that playlist's contents. album_play_events gains an
additive context_kind column (the other frontend names its columns on insert,
so it writes '' and '' counts as an album — no history reinterpreted), plus a
new playlist_play_history.db and a "Recently Played Playlists" rail, default on.
A separate file rather than a field in recently_played.json, which is a
destructive round trip between frontends: serde drops what it does not know and
the next write puts the truncated object back.

ITEMS PER CAROUSEL, recovered from Tauri and never ported to Slint. It lives in
a NEW TABLE of discover_prefs.db, not in the prefs blob: to_json rebuilds the
object from the keys the running binary knows and save overwrites the whole
column, so a new key would survive only until the other build's next toggle.
Default is uncapped, i.e. today's behaviour.

Two DiscoverySectionId arms were added to the frozen Slint tree, obliged by the
shared enum's exhaustive matches — same two-line follow MostPlayedAlbums took.
Not verifiable here: compiling that crate pulls qbz-ui.

Also fixes two recommendations_qt tests that had been red since c02e7713d,
which turned the two Weekly rows into ordering slots that deliberately do not
self-hide while the assertions kept the pre-split behaviour. Verified by stash
that they fail without any of this change: the code was right, the tests stale.

Eight locales updated. Release build clean, offscreen smoke gate green,
qbz-qt 421/421 and qbz-app 215/215.

cc
2026-08-19 07:00:35 -06:00
vicrodh 047db8ea9f perf(qt): recycle the album page's track list, on a uniform-cell ListView
DID NOT DELIVER ITS GOAL. Read this before extending it: the wheel scroll on
a 189-track album is essentially unchanged. The page is correct and the
structure is better, but the theory that motivated the work is not supported.

What the work assumed: the ceiling was the `Repeater`, which has no windowing
and so built all 189 track blocks — each a Column with a disc-header Loader, a
work-header Loader (both gated on DATA, never on the viewport, so every act and
scene header in a Wagner box was alive) and the band-gated row. Roughly a
thousand structural items before counting anything visible.

What it is now: one ListView over a UNIFORM 10px cell tape. Each piece of
content owns a run of cells, its visual item lives in the first cell of the run
and paints over the inert ones — 50px row = 5 cells, 40px header = 4, rail =
29. Uniform cells are the point: measured in a standalone probe, a ListView
with variable delegates ESTIMATES contentHeight and ignores an explicit
override (5703 then 5396 against an exact 5400), which makes the scrollbar
thumb resize and skip as sizes are discovered. With uniform cells,
count * 10 is exact from the first frame, so the scrollbar the owner had just
called the best gesture on the page keeps its behaviour.

Falling out of the design, replacing machinery that was reverted a commit ago:
`cacheBuffer` incubates upcoming delegates asynchronously in idle time, so a
heavy rail is built before it is visible without a hand-rolled band, a settle
Timer, or an unload grace — and without the "reach the bottom, then wait" that
made the owner reject that approach.

Two correctness items that came with it:
  * `header` is a ListView property. The page's own `header` had to become
    `albumHeader` or every `header.x` binding inside the view would silently
    resolve against the view's header instead. One handler was missed in the
    rename and QML refused to create the component at all — the whole page
    rendered black, and no static gate caught it because the failure happens at
    instantiation.
  * `recycleActive` on the shared TrackRow, default true, gates the row's
    Connections. Without it a pooled row keeps listening and consumes a cache
    or favourite settle signal meant for a different track. Every other host
    is unaffected by the default.

WHAT THIS RESULT MEANS, for whoever picks it up: delegate count and
construction cost are NOT what makes this page feel heavy. Removing ~950
structural items changed nothing the owner could feel, which points at
per-frame cost instead — consistent with his own observation that the page is
sluggish ALWAYS, not only while it loads, and with the documented finding that
this shell's bill is full-window presents rather than scene work. Do not
re-attempt a structural rewrite here on intuition. Take the QSG_RENDER_TIMING
split (polish/sync vs render) on this exact album first; the earlier reference
scene measured polish=0ms, render=2ms, swap=12-24ms.

Design and probes came from an independent review; the rebase onto the current
tree, the black-screen fix and the API verification are mine.

cc
2026-08-18 23:28:25 -06:00
vicrodh 3fdccae22e Revert "perf(qt): the album page's rails exist only near the viewport, and never build mid-scroll"
This reverts commit 5b99a6698.

The gating worked and the numbers were real — the scrollbar drag went from the
worst gesture on the page to the best — but it bought that with a delay the
owner will not take: once you reach the bottom, the three rails now take a
visible moment to appear, which never used to happen. The build has to wait for
the flick to come to rest (that wait is what fixed the wheel-scroll spike the
gating itself introduced), so "arrive at the bottom, then wait" is inherent to
this design, not a tuning mistake.

Reverted to a known-good baseline rather than tuned, because the replacement
makes it moot: converting the page to a ListView puts the rails in `footer:`,
which the view instantiates as it approaches — near-viewport building without a
hand-rolled band, a settle timer, or an unload grace.

Kept in the history rather than dropped: it is what established that the rails
were fully built on mount whether or not anyone scrolled to them, and the
"builds synchronously in one frame, on the GUI thread" lesson is what the
ListView work has to respect too.

cc
2026-08-18 22:18:44 -06:00
vicrodh 313a7e7e4e fix(qt): the local album page gets its zebra, across the whole row
The shared TrackRow has had a `zebra` flag all along, off by default, and the
Qobuz album page turns it on. The local one never did — and turning it on at
the call site would not have been enough either, because LocalTrackRow wraps
the shared row without forwarding the flag.

Forwarding it is still not the fix. TrackRow tints its own background
rectangle, which is `parent.width` wide, and this wrapper hands the shared row
`root.width - 26 - 46`: a 26px gutter for the source glyph and 46px for the
context menu the wrapper draws itself. A stripe painted from in there stops
72px short and leaves both controls outside it — which is what the smoke
caught. The stripe is therefore painted by the wrapper, full width, declared
before the shared row so it lands underneath and takes no pointer events.
Same colour literal and same parity rule as rows/TrackRow.qml, so a local list
and a Qobuz list stripe identically.

cc
2026-08-18 22:15:33 -06:00
vicrodh 5b99a6698e perf(qt): the album page's rails exist only near the viewport, and never build mid-scroll
The three SectionRails at the foot of the album page were gated on `visible:`
alone, and in QML `visible: false` stops an item PAINTING, never building. With
data present all three were fully constructed the moment the page mounted —
three horizontal ListViews of AlbumCards, every cover a RoundedImage with two
render targets — whether or not the user ever scrolled down to them. On a long
album nobody reaches the bottom of, that is pure cost, and it sits in the SAME
Flickable as the track list, so those layers are in the scene for every frame
the list scrolls.

Each rail is now a fixed-size `RailSlot` with the real rail behind a
`Loader { active: inBand }` — the shape `rowSlot` in this file already uses.
Three things this had to get right:

  * The slot reserves 286px whether or not it is built (28 header + 12 spacing
    + 246 card band, the figure RailSkeleton already stands in with). A slot
    sized to its Loader would change the page length as rails build and slide
    the content under the user mid-scroll.
  * The band reads the slot's OWN `y`, which the Column assigns from preceding
    siblings only. The variant measured against a parent-derived height is a
    dependency cycle; it cost a day on 2026-08-18, because QML breaks the loop
    silently and every slot then reads as in-band.
  * An inline `component` does not share the enclosing document's id scope, so
    `page` / `pageFlick` / `root` are not resolvable from inside RailSlot. The
    geometry and the cover map are handed in as properties instead. Left as it
    was, the failure would have been silent: a band evaluated against zeroes.

Two follow-ups from the owner smoke, both about WHEN the Loader flips:

  * UNLOAD IS DELAYED 600ms. This page grows in stages (primary document, then
    deferred rows), and while the list is missing the page is short enough to
    put these rails inside the band. They began building, the rows landed, the
    page grew by thousands of pixels and the Loader tore them down again —
    and since a ListView creates delegates through incubation, tearing down
    mid-flight is exactly the "Object or context destroyed during incubation"
    warning that appeared three times, once per rail, on every album open.
  * BUILD WAITS FOR REST. A Loader creates its content synchronously, in one
    frame, on the GUI thread. Building the instant a rail crossed into the band
    put that spike inside a wheel scroll — measured by the owner as the wheel
    getting worse exactly as the scrollbar got much better, which fits: a drag
    ends before the spike lands, a wheel scroll does not. Creation now waits
    for `flickY` to stop ticking. That signal works for the wheel and the
    scrollbar alike, unlike `Flickable.moving`, which a scrollbar writing
    contentY directly does not necessarily raise. Already-live rails stay live:
    this gates the build, never the keep.

cc
2026-08-18 22:15:24 -06:00
vicrodh 77e9feccff feat(qt): album header — 260px cover, no divider rule, a gradient that lands mid-toolbar
Four owner-requested changes to the Qobuz album page, validated by smoke.

1. THE DIVIDER RULE goes invisible rather than away. The gradient band sizes
   itself off that item's `y` and the whole column below is positioned after
   it, so deleting the item would move the page; only the paint was unwanted.

2. COVER 224 -> 260. Now one constant: the literal was repeated at five sites
   — the real header, its skeleton twin, and the two text columns that measure
   `parent.width - 224 - 32`. Changing only the header would leave the
   skeleton at 224 and make the page jump 36px when the real one replaces it.
   The header Row has no explicit height, so it grows on its own.

   The URL tier is unchanged (`best()`, per the 2026-08-16 ruling that no
   existing surface is re-tiered DOWN); what decodes is RoundedImage's scaled
   derivative at the drawn size, so 260 over 224 is ~35% of a small image.

3. THE ATMOSPHERE REACHES MID-TOOLBAR — the 8px spacer below the divider plus
   half the 52px toolbar band — so it dies across the quality chip and the
   search box instead of stopping at the divider. A named constant rather than
   a `mapToItem` chain: that chain crosses three nested containers and goes
   stale when an ancestor moves without notifying.

4. THE FALLOFF IS PROGRESSIVE. The tint held FULL strength to 0.82 and died
   inside the last 18%, drawing a visible seam — and extending the band made
   it worse, not better, because the falloff is a FRACTION of the height: the
   solid part grew while the fade stayed as short. The page-surface overlay
   underneath started at 0.82 too, putting a second seam on the same line.
   Both now decay over the bottom ~60% on an ease-out curve, stepped through
   explicit alphas because a GradientStop interpolates linearly between the
   two colours it is given.

cc
2026-08-18 20:25:36 -06:00
vicrodh c02e7713d9 perf(qt): stop republishing the Recommendations document for artwork
Covers that resolved after the document was published forced a second
`publish_snapshot`, and a republish hands QML a brand-new array:
`QQuickItemView::setModel()` then resets each rail's scroll offset and tears
down its QQmlDelegateModel. So every landing batch rebuilt all nine rails'
delegates, which is what a screen capture of a cold Recommendations load
shows — the rows visibly reassembling while covers trickle in, filling out of
order both between rows and within a row.

The fix is the pattern this port already applies twice: covers travel as a
url-keyed PATCH and the document is left alone. `foryou_qt::emit_foryou_art`
is the precedent for the For You rails, and `myqbz_bridge` cites it verbatim
for MyQBZ; the reco rails were the remaining holdout.

The download path is deliberately untouched. This changes only how a resolved
path is ANNOUNCED, never what is fetched or cached, so every other consumer of
the shared image cache — the now-playing bar, the queue, the `recent` rails —
sees exactly what it saw before.

Two traps this had to close, both already paid for once on the For You side:

  * `sectionArtOf()` falls back to `item.artPath`. `recoArt` only ever holds
    Recommendations urls, so for a Home / Editor's Picks / For You card the
    lookup misses and the binding returns what it always returned. No surface
    that reads a baked `artPath` can lose its cover to this — by construction,
    not by care. The pinned rail, fed by its own store, is untouched.
  * `anyItemArtPending` now consults the map. A patched cover never gets its
    `artPath` back through a republish, so the bare "artUrl set and artPath
    empty" predicate would be true forever and pin the 900ms skeleton pulse
    Timer on.

`refreshRecoArt()` on mount is the third: HomeView is destroyed on every
navigation while this document is published once per session, so a fresh mount
must be re-handed what already resolved or the covers come back blank.

Weekly's two rails still republish their own narrower property; their blast
radius is two rails, not nine, and widening this change was not worth it.

Verified by owner smoke: the three checks (nothing else lost its covers, the
rails stop reassembling, covers survive navigating away and back) all pass.

cc
2026-08-18 20:25:22 -06:00
vicrodh 101d0e25d4 perf(qt): one rebuild per query in the local grid, and reveal covers in order
Two changes to the local albums grid, both BUILT BUT UNSMOKED — Local Library
is best-effort for the next release, so this is committed to preserve it
rather than because it was validated.

1. ONE REBUILD PER QUERY. `rows` and `groups` are two bindings over one query:
   the host derives `albumsVisible` from the search box and `albumsGrouped`
   FROM `albumsVisible` (LocalLibraryView.qml:378-380). With a handler each,
   a single keystroke rebuilt twice, and the first of the two was WRONG as
   well as wasted — it chunked the new rows against the previous groups.
   Turning grouping off did not save it either, because `groupRows` returns a
   fresh `[]` every time and a new array is still a change. Coalesced onto a
   zero-interval Timer: one rebuild per event-loop turn, reading both new
   values. `Component.onCompleted` stays synchronous so a mount does not show
   a frame of empty grid.

   `[coltiming]` logs every rebuild with its cost, so a smoke run can falsify
   the coalescing: one line per keystroke means it holds, two means it does
   not. Timed around the work, never from a deferred callback.

2. ORDERED COVER REVEAL. The cold-thumbnail pool starts work in window order
   but finishes out of order, since decode cost varies per cover, so covers
   appeared scattered across the grid. Ordering the emission in Rust would
   have made it worse: one slow decode at index 3 would hold 4..N behind it,
   adding latency instead of hiding it. Instead arrivals are applied behind a
   front that walks the window at a fixed rate; a cover landing behind the
   front is applied at once. The front advances every tick unconditionally,
   which is what makes a stall impossible, and a key with no ordinal is
   released immediately.

Note for whoever picks this up: this reveal covers the LOCAL path only. The
uneven fill visible in ordinary navigation is HomeView's For You rails, which
take a different route (`forYouArt` via `onForyouArtReady`).

cc
2026-08-18 19:15:06 -06:00
vicrodh ed081cac6b fix(qt): put the local albums scrollbar and A-Z strip on the real edge
LocalAlbumsTab pads its content 32px on the right, and both the scrollbar and
the jump strip lived inside that padding — so the bar sat 32px from the
window edge (52 with the strip up) while LibraryView.qml:1281 puts the same
control at 4, or 34 to clear its strip. Side by side the local grid's bar
reads as floating away from the edge.

The collection now takes two measurements, both from the WINDOW's right edge:
`scrollBarInset` (where the bar should land) and `hostRightInset` (how far
the component's own edge already is from it). The bar's margin is their
difference, which goes negative and overhangs — safe, because the component
root is a plain Item; the `clip: true` in that file is on the ListView, a
sibling. Both default to 0, so LocalArtistsTab and LocalFoldersTab, which
have not opted in, are untouched.

The A-Z strip moves out to 12 from the window edge, matching
LibraryView.qml:1269.

cc
2026-08-18 18:41:28 -06:00
vicrodh c0fe8a80d3 fix(qt): silence the TypeError flood the constant-cell grid introduced
Regression from the previous commit's first fix. Pinning the inner Repeater
to `root.cols` is what made recycling free, but it also means the tail slots
of a partly-filled row are now real, live cells with no album behind them.
The delegate anticipated that — `modelData` resolves to null and the cell
sets `visible: false` — and that is not enough: in QML `visible: false` stops
an item PAINTING, it does not stop its bindings EVALUATING. Every
`cardCell.modelData.id`, `.title`, `.artKey` on an empty slot therefore threw
once per evaluation, which during a scroll is a continuous log flood (the
same ten line numbers, over and over, seen in a smoke run).

`modelData` keeps its meaning — "is this slot filled?" — so `visible` still
works. The bindings now read `cardCell.slot`, which is the same object when
the slot is filled and a frozen all-empty object when it is not.
`artWanted("")` is false and `artPathOf("")` is "", so an empty slot stays
silent through the skeleton's probe arm too.

Deliberately NOT a Loader gated on the data: `active` would flip every time a
full row recycled into a partial one, rebuilding the AlbumCard — precisely
the construction churn the constant cell count exists to remove.

cc
2026-08-18 18:41:17 -06:00
vicrodh bd9aec1440 perf(qt): make the local albums grid actually recycle, and stop it asking
for the whole library

The owner's report is precise and is what this targets: the grid OPENS
fine; scroll, scrollbar drag and search are what feel sluggish, and "the
first two keystrokes hurt" — the ones where the filter still matches
hundreds. That shape is a model swap, not a slow filter.

UNSMOKED. Committed to save progress; no binary matches this tree.

1. FIXED CELL COUNT. `reuseItems` was already on the ListView and bought
   nothing, because the pool recycles the row SHELL while the inner
   `Repeater { model: modelData.items }` got a new array on every recycle
   and rebuilt its AlbumCards anyway. The card is where the cost is: ~29
   items, a RoundedImage with TWO render targets (its own layer plus a
   separate mask layer), image probes, a skeleton. The shell was reused
   and the expensive part was not. The Repeater now runs over `root.cols`
   — a constant — so cells survive recycling and a scrolled or refiltered
   row only reassigns `item`.

2. THE HEADER WINDOW BUG. `loIdx` fell back to 0 whenever the first
   visible entry was a group header, because headers carried no `base`.
   In grouped mode, scrolling with a header at the top of the viewport
   asked `queueWindowReport` for artwork from album 0 through the current
   letter — hundreds of covers dispatched and later evicted, per scroll.
   Headers now carry the flat index their group starts at.

3. SEARCH DEBOUNCE on the albums box, which the Tracks box in the same
   file already had. `albumsSearch` is not a query, it is the filter
   input: writing it per keystroke refilters 1267 albums, rebuilds the
   entry list and hands the ListView a new model, tearing down every
   delegate. Note the tradeoff — this ADDS 200ms before any work, so the
   box may feel more deliberate even as it stutters less.

Also here, from the album pages (lower priority per the owner — their
cost is paid once and the view is fine in use afterwards): the disc
header, which carried a CardMenu per TRACK, is Loader-gated; a genuinely
quadratic multi-disc scan in LocalAlbumView is hoisted out of the
per-row call; and the track rows are windowed. That windowing was
measured broken twice (a band computed against its own parent's derived
height — a dependency cycle) and the fix is unverified.

Credit where due: 1 and 2 came out of an independent review that also
judged the previous round's lazy menus and `reuseItems` "mostly beside
the main point". That is a fair call.

cc
2026-08-18 17:42:30 -06:00
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
vicrodh c2944db973 feat(qt): source-aware playback, session persistence and loading affordances
One batch, committed together because the changes interleave in main.rs and
splitting them after the fact would be a fiction.

Playback routing: a track now carries its source, so a local or Plex row played
from Discover stops being resolved against Qobuz first (the 404s and the long
silent wait before audio). Jellyfin and Navidrome land on the same seam.
Plex tracks stream through the player instead of downloading whole files first;
the feeder moved into qbz-player so both frontends can reach it.

Session persistence, both levels the port was missing:
  - The startup-page preference is finally read. It had been rendering and
    persisting a value nothing consumed. `last_view` is written in the SHARED
    ui_prefs vocabulary, not this port's route ids -- four of the six differ,
    and writing the raw ids would have silently broken the other frontend's
    restore. `on_session_entered` recorded "home" unconditionally, which also
    rewrote last_view on every boot, so the setting could not survive its own
    startup.
  - Queue and active track survive a restart. session_persist was tied to a
    frontend by exactly one type alias, so it moved to qbz-app generic over the
    adapter rather than being copied. Verified end to end: the snapshot restored
    here had been written by the Slint binary, and the exit flush keeps an
    un-consumed resume position instead of overwriting it with the engine's
    idle 0.

Loading affordances: the transport shows a spinner from the moment a play is
dispatched, and the restored session explicitly takes it back down -- a
restored track has no dispatch behind it and no audio ahead of it, so nothing
would ever have cleared it. Cortinilla results now navigate at album and artist
level.

Verified by the offscreen smoke gate and by driving a restore/quit cycle over
MPRIS.

cc
2026-08-13 23:10:32 -06:00
vicrodh 3b03682e09 fix(qt): pane corners stay round under the dynamic background
AppShell hides its BezelCorner nubs while the field is meant to show
through the pane's corners, and Qt's clip is a rectangular scissor that
ignores radius — so any full-bleed child at y=0 squares the pane. The
doctrine was already written in AppShell.qml ("a full-bleed pane child
must round itself; there is no mask that can do it for it here"); these
eight surfaces predated it or, in the two thin bars added with the
ambient port, ignored it.

- The seven fixed 56px headers (Home, Search, DiscoverBrowse,
  ArtistReleases, PlaylistBrowse, PlaylistManager, PlayHistory) take
  topLeftRadius/topRightRadius. Invisible with the background off, where
  the view root paints the same colour beneath. PlaylistBrowse's SECOND
  bar is the category strip at mid-page and keeps square corners;
  PlayHistory keeps its unconditional opaque fill, which is reference
  parity (:43/:34) and a separate question from the corner.
- LocalAlbumView's neutral header band is now gated on the background,
  1:1 with the Qobuz page's atmosphere (AlbumView headerAtmoOn,
  AlbumPageView.slint:168): the two dark backdrops clash and the field
  already provides one. That band had been square since it was written;
  the nubs had been hiding it, and they only stand down when the field
  is showing. Header text there is theme.textPrimary, not the Qobuz
  page's light-on-dark hdrStrong, so it does not depend on the band to
  stay readable — checked before removing the backdrop from under it.

Gate: cargo build -p qbz-qt, both QML audits, offscreen smoke 0
complaints.

cc
2026-08-13 14:33:35 -06:00
vicrodh 4e4decb1e2 docs(qt): release-2.1.0 placeholder with the GPU campaign note
The docs tree is not versioned, so the release-facing summary of the
repaint-pulse campaign lives here as a draft TO-DO for the first Qt
release, with a discreet pointer from the pulse property itself.

km
2026-08-13 13:09:03 -06:00
vicrodh eb80e2f367 feat(qt): animated now-playing indicator in track rows
Restores the eq-bars indicator behind the existing
play-indicator-animation pref (the toggle already lived in
AppearanceSettings; the pref was plumbed but unconsumed). The bars ride
the shared shell pulse, so they cost zero extra presents — the
affordable version of what used to be a display-rate animation.
Pref OFF keeps the static accent pill + ring, per TrackPlayCell.slint.

km
2026-08-13 13:02:10 -06:00
vicrodh a2a99a72cc perf(qt): bring the immersive onto the pulse (83% -> 25% GPU)
- AlbumReactivePanel: drop the eight 100ms Behaviors on 30 Hz data (a
  retriggered Behavior animates at display rate and every animation
  frame is a full-window present), and read the settle — not the
  publishing bridge list — in the energy formulas (the .length guard
  re-evaluated the visual bindings on every FFT publish).
- LyricsSyncEngine: the fast cadence rides the pulse; the 250 ms idle
  re-check keeps its own timer.
- EqualizerBars: the phase accumulates on the pulse and the 'active'
  gate is host-provided (invisible subtrees still dirty the window).

Measured on the owner's machine, immersive reactive panel open:
132 -> ~33 presents/s, 83% -> ~25% GPU.

km
2026-08-13 13:02:10 -06:00
vicrodh f46c5c95d4 perf(qt): one repaint pulse for the whole shell (GPU 95% -> 25%)
Qt Quick has no partial redraws: any dirty item presents the WHOLE
window, and on the owner's hybrid laptop each full-window present costs
~1.2% GPU flat (KWin composite + dGPU scanout; area-independent, while
the app's own render is 0.08 ms/frame). The bill was presents/s, and
the shell ran unsynchronised ~30 Hz clocks plus two leaks:

- QbzShell.pulseMs: ONE Rust-driven 33 ms edge that every continuous
  animator ticks on (knob QBZ_PULSE_MS). VizSettle now stashes on
  publish and applies on the pulse (its 16 ms interpolation timer is
  gone); AmbientField and ImmersiveAtmosphere ride the same edge.
- VizSettle writes NOTHING while not live: the always-mounted but
  invisible immersive panels were dirtying 28 bar rects per FFT
  publish (named via QT_LOGGING_RULES=qt.quick.dirty=true).
- AmbientField renders inline again: ShaderEffectSource
  scheduleUpdate() double-presents (minimal repro fbo_repro.qml in the
  investigation folder).
- Background mode 2 (ImmersiveAtmosphere) composites its four drifting
  layers + base + dim + scrim in ONE opaque ShaderEffect
  (atmosphere.frag, baked by build.rs); numeric parity vs the Image
  stack verified (atmo-shader-parity-check.py, delta 0.0). The
  four-Image stack stays as the software/failure fallback.

Measured on the owner's machine: 93-97% -> 25-26% GPU, ~34 W -> 12-13 W,
~33 presents/s in both background modes. Full story:
qbz-nix-docs/qt-frontend/2026-08-11-scenegraph-batches/GPU-COST-INVESTIGATION.md

km
2026-08-13 13:01:50 -06:00