Commit Graph
100 Commits
Author SHA1 Message Date
vicrodh 5160ddf6c1 fix(player): make song info responsive
cx
2026-08-22 22:26:32 -06:00
vicrodh 4e955a2b8b fix(queue): reuse resolved current cover
cx
2026-08-22 22:26:28 -06:00
vicrodh f9bc83ba8d fix(navigation): restore open media cascade
cx
2026-08-22 22:26:24 -06:00
vicrodh 04184d9d8f feat(navigation): add cascading local media menu
cx
2026-08-22 21:25:29 -06:00
vicrodh a2d0ada427 feat(queue): persist playback history across restarts
cx
2026-08-22 20:59:14 -06:00
vicrodh 5e4d7ca033 fix(queue): preserve metadata and polish extended view
cx
2026-08-22 20:58:46 -06:00
vicrodh c7038a6858 feat(queue): add extended queue view
cx
2026-08-22 20:01:47 -06:00
vicrodh eed377af1f perf(qt): optimize catalog views and gapless playback
cx
2026-08-22 18:50:21 -06:00
vicrodh e0e9566f81 perf(qt): window high-cardinality catalog views
cx
2026-08-22 16:16:36 -06:00
vicrodh 48c6f6854d fix(qt): harden remote playback transitions
cx
2026-08-22 15:42:06 -06:00
vicrodh c6d142ccb4 perf(qt): virtualize discovery and warm playback
cx
2026-08-22 14:35:36 -06:00
vicrodh 34d19211d5 perf(library): stop pulling embedded cover art just to read a track's tags
Opening a 247-file box set from a NAS took ~9.3 s. The instrumentation
added in the previous commit put the whole cost in one place:

  [ephemeral][perf] parallel tag reads: 9.268s for 247 files
  [ephemeral][perf] serial bookkeeping (artwork + ids): 29.4ms

Everything that is not a file read — artwork caching, id assignment,
grouping, sorting, serialising — is 29 ms. And parallelising the reads
across 8 threads had bought only 1.6x over the serial 14.7 s, which is
the signature of a BANDWIDTH wall, not a latency one: threads cannot help
when the bytes are the problem.

A FLAC keeps its cover art INSIDE the tag, so `Probe::read()` was pulling
multiple megabytes of picture data per file across the network in order
to recover a title, an artist and a track number. Those pictures were
then dropped on the floor: the cover shown for a block is resolved
separately, once per ALBUM, by `extract_artwork` / `find_folder_artwork`
— 13 reads out of 247 in this box.

`ParseOptions::read_cover_art(false)` on the two paths that only ever
wanted metadata (`extract_with_roots`, `extract_properties`).
`read_properties` stays ON: duration, bit depth and sample rate come from
the header and cost nothing. `extract_artwork` and `extract_artwork_bytes`
— the two functions that genuinely want the picture — are untouched.

MEASURED ON THE SAME FOLDER, SAME NAS, AFTER THE CHANGE:

  [ephemeral][perf] parallel tag reads: 922.9ms for 247 files
  [ephemeral][perf] serial bookkeeping (artwork + ids): 66.5ms

9.268 s -> 0.923 s on the reads, and ~14.8 s -> 990 ms for the whole
open. The prediction written before the run was "under 2 s", so the
mechanism is confirmed rather than merely consistent.

Worth recording plainly: the parallel-reads commit before this one was
NOT the fix. It masked a bandwidth problem with threads and made the
number look better while the app still moved the same bytes — and it left
me believing the remainder was the NAS's ceiling. What refuted that was
the owner pointing out that fooyin opens the SAME folder from the SAME
NAS almost instantly. The fix is not reading what nobody asked for.

Shared crate, so this also reaches the library scanner — which wants the
same thing for the same reason. qbz-library: 103 tests green.

cc
2026-08-22 11:32:27 -06:00
vicrodh 1c1ea00f6b fix(qt): break the activeTab binding loop, and time the ephemeral open
BINDING LOOP. The Binding's `when` read `viewLoader.item.activeTab` —
through `typeof`, which still registers a dependency — while the Binding
WRITES that same property. Every flyout navigation logged

  QML Binding: Binding loop detected for property "when"

Pre-existing, not from the two-phase commit: reproduced on the
pre-2026-08-21 binary by navigating the same way, where it logs from the
same Binding at its own line number. The guard now lives in a handler on
the Loader's `item`, which runs once per mount and registers no
dependency. Verified by log: 0 "Binding loop", and 0 QML complaints of
any kind on a full navigation pass.

EPHEMERAL OPEN — INSTRUMENTED, NOT SOLVED. Two timers split the work
either side of the parallel read phase, because the remaining ~9 s has no
confirmed owner. The owner reports fooyin opening the SAME NAS folder
near-instantly, which rules out the network as the ceiling and makes this
our code — the obvious suspect is that we read each file's tags in
`extract` and then open and read it AGAIN in `extract_artwork`, plus
decode and re-encode a thumbnail, where a grid-only player needs none of
that up front. That is a design change, not a tweak, so it is measured
rather than guessed at.

The numbers arrive on the next real open ("[ephemeral][perf] parallel tag
reads" / "serial bookkeeping"). I could not trigger one from the test
harness: the entry point is a native folder picker, and the rehydrate
path did not fire from the surface the harness can reach.

cc
2026-08-22 11:04:23 -06:00
vicrodh 2f990c0890 fix(local): the ephemeral pane names, illustrates and ORDERS its discs
Opening a box set through "Open ▾" — the quick path for something outside
the library — listed its 13 discs as Disc 1, 10, 11, 12, 13, 2, 3 …, each
with the same heading and the same cover. The Folders grid gets all three
right from the same files, which is what proved it was fixable.

THREE DEFECTS, one per symptom:

  ORDER. Blocks are grouped in a BTreeMap keyed on the STRING
  "{album}#disc{n}", which sorts lexicographically. The final
  `sort_by(title)` was meant to impose real order, but every block of a
  box carries the same title, so it tied on every pair and the string key
  showed through.

  TITLE. `album_group_title` is the group name with the disc suffix
  STRIPPED — identical for all 13 blocks. That stripping is what makes a
  box ONE album and must stay.

  COVER. `artwork_path` is biased to the album ROOT by the scanner
  (+5 bonus, strict >), so every block resolved to the same file.

The name and cover helpers now live in `local_rows` and are shared with
the album page's divider, which had grown its own copies yesterday.

AND THE ORDERING FIX HAD A SECOND ACT WORTH RECORDING: giving each block
its own title made `sort_by(title)` start to bite, and it sorted by the
DISC NAME — putting "Complete Song Collection 1" (disc 11) first.
Measured, not predicted. So the sort key is the BOX's title with the disc
NUMBER as the tiebreak: ordering discs by anything except their number is
the same mistake twice.

Verified against the owner's own box: blocks now run TV Series
Soundtrack #01, #02, Movie Saint Seiya OST, #03, Movie Kamigami…, #04 —
i.e. discs 1,2,3,4,5,6 in order, matching the Folders grid, each with its
own cover.

ALSO, the 15 s open: it is not painting, it is 247 sequential
`canonicalize` + tag reads over NFS. Only the READS are now parallel
(`std::thread::scope`, no new dependency, ≤8 workers); the CUE dedup, the
APE skip, id assignment, the artwork caches and the push order all stay
serial and in scan order, because ids are observable. Measured 14.8 s →
8.9 s on the owner's NAS. Better, NOT solved — something else holds the
remaining half and I have not identified it.

cc
2026-08-22 10:01:23 -06:00
vicrodh 9208513c51 fix(local): encode the disc cover url, and guard the first frame
Two defects in the multi-disc divider, both found by opening the owner's
own box set rather than by reading the diff.

1. THE '#' ATE THE PATH. The url was built by concatenating "file://"
   with the absolute path. This box's folders are named
   "Disc 07 - TV Series Soundtrack #05", and in a URL '#' opens a
   FRAGMENT — so the path truncated at "Soundtrack " and every disc
   logged "QML Image: Cannot open". The tree already had the right tool:
   `artwork_qt::file_url` escapes '#', '?' and '%' for exactly this, and
   the url is now built there instead of by hand in QML.

2. `root.doc` IS NULL ON THE FIRST FRAME, so `root.doc.discs` threw and
   left `discRows` undefined, which then threw again on every read of
   `.length` — 26 cascaded TypeErrors per open.

Verified on the real library after the fix: the dividers read "Disc 1 —
TV Series Soundtrack #01" and "Disc 2 — TV Series Soundtrack #02", each
with its OWN cover (mean pixel difference 92.9 between the two
thumbnails, i.e. genuinely different images), and the page opens with
zero QML complaints where it logged 38 before.

cc
2026-08-22 05:41:33 -06:00
vicrodh e5e19b9aa2 Revert "perf(qt): build only the discover rails that fit the viewport"
This reverts commit dc8cb1fa3.

It broke delegate creation on a real, populated Discover page. Measured
by running both binaries against the owner's own profile: the pre-change
build logs ONE (benign) QML line, this one logged FOURTEEN —

  7x HomeView.qml: Object or context destroyed during incubation
  4x HomeView.qml:511:27: QML Component: Cannot create delegate
  2x HomeView.qml:1379:39: QML Component: Cannot create delegate
  1x HomeView.qml:791:27: QML Component: Cannot create delegate

"Cannot create delegate" is not cosmetic — those cards do not exist. The
ramp opens an ~80 ms window in which rails are still being built, and the
home document republishes inside it (the artwork pass), which rebuilds
the whole Repeater and destroys ListViews mid-incubation. The old
one-pass mount had no such window.

The measured navigation win does NOT depend on this. The acknowledgement
frame comes from the two-phase route commit (5b274cab5), which is what
took a click from 590 ms of frozen window to 73-90 ms across five routes;
and the adversarial pass on the blank-pane question already refuted the
idea that these rails were what filled it — Library has no ramp and shows
the same shape. So this was cost without an isolated benefit, and it is
going out rather than being patched at 5am on a sleeping owner's tree.

If it comes back it needs a model-republish interlock (stop or complete
the ramp when sectionsModel changes) and a check against a FULL Discover
page, which is the case that fails.

cc
2026-08-22 05:13:53 -06:00
vicrodh fd25593e38 feat(local): a multi-disc divider that names and illustrates its disc
Opening a box set FOLDER whose discs each have their own name and cover,
the divider said "Disc 2" and nothing else. Switching the header to
METADATA grouping showed them correctly, which is what made it look like
data the app already had.

IT WAS NOT. In folder grouping a box is deliberately ONE album, so
`TrackRow.album` is `album_group_title` — the name with the disc suffix
STRIPPED, which is exactly what makes the box hold together. "Box (Disc
1)" and "Box (Disc 2)" collapse to "Box", and the only thing separating
disc 1 from disc 2 in the published document was an integer. (Metadata
grouping works for an unrelated reason: there each disc is its own album
row, keyed album||artist, so it keeps its raw tag and its own artwork.)

So the document now carries a small `discs` array — one entry per disc,
not per row: the divider is 2-4 items where the track list can run to
hundreds, and widening TrackRow to label three of them would have been
the wrong trade on the surface this view is most careful about.

The title is resolved most-trustworthy-first: the disc FOLDER's own
titled tail ("Disc 1 - Rheingold"), else the raw album tag when it names
something the group title does not already say — compared AFTER stripping
the disc suffix, so a tag of "Box (Disc 2)" is rejected instead of being
echoed next to the "Disc 2" label — else nothing, and the divider stays
the bare "Disc N" it has always been.

THE ART NEEDED A SECOND FIX, and an adversarial pass is what caught it.
The plan was to read each disc's cover off its first track, on the
assumption that scanned artwork is per-folder. It is not:
`find_folder_artwork` gives the album ROOT a +5 bonus against a strict
`>`, so `Box/cover.jpg` beats `Box/Disc 01 - X/cover.jpg` on every track
of every disc, and the thumbnails would have been N copies of one image —
which the "only draw them when they differ" guard would then have
suppressed, leaving the feature silently inert. `folder_artwork_in_dir`
is the unbiased sibling, used only where the caller already knows it is
asking about one disc. Nothing on the scan path changes and no re-scan is
needed.

Additive only in the shared crate: `strip_disc_suffix` and
`album_root_dir` are untouched — they are what keep a box set one album,
there is a regression test pinning that, and a per-disc label must not be
bought by breaking it. qbz-library: 103 tests green.

cc
2026-08-22 05:10:39 -06:00
vicrodh 0e5f160a7c fix(myqbz): stamp the cover when an item is added to a collection
`mixtape_collection_items.artwork_url` is a SNAPSHOT written once at add
time. The grid tile and the hero mosaic read that column and stop — only
the detail row has a backfill — so an item stored without a cover is
blank on those surfaces for the life of the row.

WHAT THE DATA SAID, against two hypotheses including mine. The owner
reported albums added "from Qobuz" arriving coverless, and the obvious
suspects were a narrow ImageSet read and the disco builder. Querying the
live library.db settled it: 8 of 35 rows have an empty artwork_url and
every one of them is `item_type='album' source='local'`, while every
Qobuz album row carries a url. So the confirmed gap is the LOCAL album
page's cassette button, which passed `artwork_url: None` outright — the
only call site in the app that could produce those rows. It now stamps
the album's own cover path, which is correct to store here: the mosaic
passes local paths through unrewritten, and that is what lets a
collection of local albums render with no network at all.

Also stamped, where the remote url was already in hand and only an empty
string was being sent: Search, Playlist, Library, Artist and Label track
rows. AlbumView takes the HEADER's url rather than the row's — album-view
track rows are published with an empty artUrl by construction
(album_qt.rs says so in the field's own doc), so the obvious edit there
would have been a no-op, and every track of an album shares its cover.

Four sites still send nothing and now SAY why in place: the three
now-playing bars and MixView have only a file:// cache path or no url at
all, and a cache path must never be stored — other machines read this
column. Those need a remote-url field on their documents first.

The `image.best()` widening in album_qt/bulk_tracks_qt rides along as
hygiene, not as the fix: `thumbnail.or(small)` was the outlier in a file
that reads every other cover through `best()`. An adversarial pass over
494 real ImageSets found ZERO albums lacking both fields, so it was not
what the owner hit — it is simply the read the rest of the file already
agreed on.

STILL OPEN: if the owner's two albums were Qobuz after all, their urls
ARE stored, and the symptom is on the render side — the mosaics have no
fallback where the detail row does.

cc
2026-08-22 05:10:17 -06:00
vicrodh ef333cd7ec fix(login): point a never-had-an-account user at the door that is for them
Local-only mode already WORKED — verified by driving a genuinely fresh
profile (no credentials, keyring unreachable): "Start offline" enters the
guest profile, Local Library opens with its proper empty state, Settings
renders in full, zero QML complaints. The capability was never the
problem. Two pieces of the screen were.

1. THE SIGNPOST WAS OFF FOR EXACTLY THE RIGHT PERSON. The callout with
   the caret pointing at the Start-offline link was gated on
   `hasPreviousSession && connectivity === 2`. `hasPreviousSession` is
   seeded from `load_last_user_id()` — None on a fresh profile — and only
   ever set true by a successful Qobuz login. So the one piece of UI that
   says "you can use this without an account" could not be seen by
   anybody who had never signed in. The gate was written for the
   returning-user-with-no-wifi case and the guest case was bolted onto
   the link alone.

2. THE COPY WAS FALSE. Once that box is visible to a first-run user with
   working internet, "No internet connection" is simply untrue, and the
   offline placeholder said the same thing after entering: measured on a
   fresh profile, the log read `connectivity Up, induced false,
   offline_session true` while the screen claimed the network was down.
   Both now branch on which of the THREE states this is — induced
   offline, no connectivity, or no account — and the captive-portal line
   is gated on connectivity too, so it cannot appear under a "No Qobuz
   account?" heading.

Two new msgids in all seven non-English catalogues.

Verified after the change on a fresh profile: the callout appears, the
link is reachable, and the placeholder reads "You're using QBZ without a
Qobuz account."

HARNESS NOTE for whoever tests this next: XDG_CONFIG_HOME sandboxes the
token FILE but NOT the system keyring, and a rejected token there calls
`clear_oauth_token`, which deletes the keyring entry for the real user.
Point DBUS_SESSION_BUS_ADDRESS at nothing so the Secret Service is
unreachable. (The file stays authoritative, so nothing is lost — but the
run is not clean either.)

cc
2026-08-22 05:09:56 -06:00
vicrodh d40fbb36d2 fix(source): a source word the caller supplied outranks a namespace bit
Four George Harrison albums would not play from a MyQBZ collection:

  ambiguous id "5099951336752" (Some(Album)):
  candidates [SourceId("qobuz"), SourceId("subsonic")];
  caller must supply a source

The caller HAD supplied one. The MyQBZ row stores `source = 'qobuz'` and
`RawRef::from_mixtape_item` threads it through correctly; nothing about
the storage or the call chain was wrong. The bug is that
`SubsonicSource::recognises` ORed a namespace-bit test ON TOP of the
source word, so it claimed an id it can never own while Qobuz claimed it
legitimately — two hits, and `claim` refuses to guess.

`5099951336752` is an EAN-13 barcode (EMI/Parlophone, "Somewhere In
England"). Checked arithmetically rather than assumed: bit 42 set,
nothing above the payload mask — exactly `RemoteSource::of_id`'s Subsonic
predicate. The reserved band is 4_398_046_511_104 ..= 5_497_558_138_879,
i.e. EVERY 13-digit barcode from 4398… to 5497…, which is all of
EMI/Parlophone's 5099… prefix. That is why four albums failed together
and why it is deterministic per album rather than flaky.

Two gates, applied to all three cached sources:

  - An explicit and DIFFERENT source word now returns false outright. A
    bit pattern must never outvote a caller who already said what this
    is.
  - A namespaced numeric is a TRACK rowid by construction, so the arm no
    longer fires in ALBUM position at all. That is the narrower and more
    honest statement, and on its own it would have spared this barcode.

Plex needed the same edit and not as tidying: its arm is the loosest of
the three (a bare bit test, no payload bound) and its Album branch
answers `Err(BadIdShape)`, which the registry propagates AHEAD of any
tiebreak — so a barcode with bit 40 set failed with a confusing *Plex*
error on an id Plex never owned.

WHY THIS WAS NEVER CAUGHT: every disjointness test used the barcode
"0060254702523", whose LEADING ZERO puts it below all three floors, so
the entire in-band class was untested. The new test uses the real id and
asserts up front that it still sits in the band, so it cannot quietly
stop covering the case. Verified the honest way — with the old predicate
restored the new test FAILS, with the fix it passes; 90 tests green, and
`namespace_bit_is_enough_to_recognise_without_a_source_word` (the Plex
mixtape-track case that must not regress) stays green.

cc
2026-08-22 04:17:57 -06:00
vicrodh e1a11bbc99 chore(i18n): the Font row says it takes effect at the next start
The msgid IS the English string, so adding "(requires restart)" to the
description re-keys the entry in all seven non-English catalogues. Same
wording Interface size two rows above already carries.

cc
2026-08-22 02:03:33 -06:00
vicrodh 750c59034b feat(qt): Excel-style multi-select — Shift ranges across every surface
Selection existed but had no keyboard: every click was a single toggle,
so picking 40 tracks meant 40 clicks. Slint and Tauri have had ranges
since 2026-06; this is that port.

NINE surfaces, not the five the track views suggested: Album, Artist,
Playlist, Label, Local Album, plus Library's tracks and albums and Local
Library's tracks and albums. Each carried its own hand-copied `selected`
map and its own `toggleSelected`, so the rule went into
controls/SelectionModel.qml and the maps stayed where they were — the
smallest change that stops it being copied a tenth time.

The rules are Tauri's `applyShiftRange`, unchanged: a plain click
toggles one row without clearing the rest, Shift fills the range from
the anchor ADDITIVELY (so re-dragging a range cannot eat the set) and
leaves the anchor where it is, and Ctrl/Cmd is the same as a plain click
— not a shortcut, but the behaviour both references already have,
because an accumulating click is what "add without disturbing" means.

Qt makes this CHEAPER than Slint, and the difference is worth recording:
Slint had no modifiers at click time and had to read a thread-local set
from a ModifiersChanged handler, with a documented stale-read edge case
when the window lost focus mid-chord. A QML MouseArea delivers
`mouse.modifiers` on the event itself, so the modifier and the click can
never disagree and no such seam exists here.

Three judgement calls, stated because they are not forced:

  - Library gets TWO models, one per kind. Its rows are mixed, and one
    anchor would let a Shift-click on a track sweep up the albums drawn
    between it and the anchor.
  - Ranges run over the VISIBLE rows. Local Library's search box is a
    view filter, and a range over rows the user cannot see is not the
    range they asked for — the same call `select-all` already made.
  - The Local Library tree rail keeps plain toggles. Its selection lives
    in Rust (QbzLocal.treeToggle*Select), so there is no QML map to hang
    an anchor off; teaching the Rust side about an anchor is its own
    change, and the file says so where a reader would expect otherwise.

The anchor is an ID, not an index, because every one of these views can
re-sort or filter under a live selection — an index anchor silently
points at a different track the moment the order changes. Slint's
selection.rs made the same call for the same reason.

Verified by driving the app: in Abbey Road, click row 1 then Shift-click
row 5 selects exactly rows 1-5 and leaves 6-8 untouched, with the bar
reading "5 selected"; the same in a playlist, additively over a row that
was already selected.

cc
2026-08-22 02:03:10 -06:00
vicrodh 7fc7605887 feat(search): the Tracks results use the app's full track row
Search already mounted the shared TrackRow, but had never switched on
any of its affordances: no artwork, no artist link, no album column. So
the one surface where people are most often hunting for an album gave
them no way to reach it except the context menu's "Go to", three clicks
deep, while every other track list in the app links straight through.

Turning the arms on is most of it. The missing half was in Rust:
`search_qt::TrackRow` carried `albumId` but not the album TITLE, and the
row draws `item.album` as the column's label and uses `item.albumId` for
the click — so without the title the column would render empty and the
link would be invisible. `track.album` was already being read one line
above for the id.

No layout work: TrackCols already reserves the album cell and its gap
behind the same `showAlbum` flag, so the widths redistribute themselves.

Verified by driving the app: a search for "beatles", the Tracks tab, and
a click on "Abbey Road" in a row opens Abbey Road (Super Deluxe Edition).

cc
2026-08-22 01:43:50 -06:00
vicrodh cf07f10a59 fix(settings): make the Font row actually change the font
It shipped doing nothing. Measured on the built binary: picking a face
persisted the pref and changed ZERO of 600600 pixels in the settings
label column. The row was a control that lied.

Two measurements say why, and both were run against this Qt build rather
than reasoned about:

  - `ApplicationWindow.font.family` propagates through Qt Quick CONTROLS
    only. In a standalone scene with a family set on the window, a
    `Label` resolved to it and a sibling `Text` stayed on the
    APPLICATION font. This tree is 1082 plain `Text` items against 13
    that name a family, so the app's typeface IS the application font.
  - The application font is read when an item is CONSTRUCTED. Driving
    PySide6 against the same Qt, `QGuiApplication::setFont` updated
    `QGuiApplication::font()` and left an already-built `Text` on its old
    family.

Together those close the door on changing it live, so the ordering in
main() is the feature: FontPreload.qml registers the bundled faces (a
family does not exist until its file is loaded, and `load()` returns
with its FontLoaders already in the database), the application font is
set from the persisted choice, and only then does Main.qml build the UI.
Swap any two of those and the setting silently does nothing again.

The row now says "(requires restart)", the same wording Interface size
two rows above already uses, in all eight catalogues.

"System" resolves to "" and takes no branch, so anyone who never opens
the row gets exactly the font this app rendered before — verified by
capturing the window on both settings: 0 log lines and an untouched
frame for System, 27065 changed pixels for Montserrat.

NOT ADDRESSED, and worth its own decision: those 1082 Text items have
never used the bundled Inter either. `Main.qml` has always named it, but
that only ever reached the Controls, so the app renders in whatever Qt
picks. Making "System" mean Inter is a one-line change and a visible
one, so it is left alone here.

cc
2026-08-22 01:31:42 -06:00
vicrodh bbe9f25689 refactor(settings): Plex gets the same header as Jellyfin and Subsonic
The three media servers sit one under the other and Plex was the odd one
out: a small-caps "PLEX" eyebrow with no brand mark in a 64px band,
against the 44px SourceIcon + 14px DemiBold title the other two use.
SourceIcon already draws the full-colour Plex mark untinted, so this is
the same header, the same metrics and the same glyph pipeline.

NOT folded into MediaServerSettings.qml: that component is one form
twice — address, account, password, connect, sweep — and Plex's body is
a different shape, a PIN pairing flow with a server picker and a
per-library table. Only the header is the same.

One affordance the other two have is deliberately not copied: the
"· <server name>" suffix. PlexFields carries no name — pairing stores a
url and a token — so the row would be permanently invisible. An empty
binding is not parity, it is a dead branch.

cc
2026-08-22 00:01:31 -06:00
vicrodh 002231b46b fix(settings): the Library > All select is the same size as every other
`sm: true` made this the ONE select in Settings drawn at the small size,
which reads as a rendering bug sitting directly under the full-size
Album grouping select. The control-alignment standard has one size for a
settings row.

cc
2026-08-22 00:01:31 -06:00
vicrodh f50c1b5188 feat(settings): the library folder table becomes its own page
The folder block — toolbar, add-path row, filter, the row-per-folder
table and the scan progress — is the tallest thing in Settings and it
sat at the TOP of Local Library, pushing Albums view, Library > All,
Maintenance, Danger zone and the three media servers off the first
screen. Behind a Manage row, the whole panel fits one screen again.

The shape is the one Blacklist already uses for its manager, and this
view is deliberately its twin: same 64px summary row, same metrics, same
route-recorded-by-the-caller contract.

Nothing about the table changed. LibraryFolderTable.qml is mounted
verbatim, still driven by the settings document and still acting through
the settingsString action keys.

Two things had to travel with it, because SettingsView used to supply
them and a routed page has no access to that:

  - LibFolderEditModal, or the per-folder pencil would open nothing,
    silently.
  - the refresh on entry. A view reached by Back runs no per-view load,
    so it asks for its own, where BlacklistManagerView puts its reload.

Verified in a running window: the route mounts, the seven folders render
with their per-row actions, and Back returns to Local Library with the
section intact.

cc
2026-08-22 00:01:13 -06:00
vicrodh 2e659d421c chore(i18n): the regrouped Appearance headers and the folder subsection
Five new group headers, the Font row's description, and the two strings
the Library folders subsection needs, in all seven non-English
catalogues. The English catalogue carries only overrides, so a msgid
whose English form IS the string needs no entry there.

Checked by switching a running app to Spanish: every new header renders
translated.

cc
2026-08-22 00:00:25 -06:00
vicrodh e90369d931 refactor(settings): regroup Appearance around what each row answers
Row wiring is untouched — every block moved verbatim and nothing here
changes what a control does. What changed is the grouping, so the group
order can no longer be read out of AppearanceSettings.slint:

  THEME                  the picker and the rows that exist only for it
  TYPOGRAPHY & LANGUAGE  Language, Interface size, Font
  NAVIGATION             was LIBRARY & VISUALS minus the visual rows,
                         plus the three Purchases/menu rows
  SEARCH                 new: Intelligent Search came out of THEME,
                         Immersive search out of TYPOGRAPHY & LANGUAGE
  PLAYER & VISUALS       was PLAYER VIEWS, plus the visual rows
  WINDOW                 WINDOW TITLE + TITLE BAR + WINDOW CONTROLS
  SYSTEM INTEGRATION     NOTIFICATIONS + SYSTEM TRAY
  RENDERER               unchanged

Four rows are now ABSENT rather than rendered-and-disabled, because a
control that cannot do anything should not be on screen: Purchases in
title bar (Show Purchases off), Window controls position (system title
bar on), Close to tray and Tray icon variant (tray icon off). Each keeps
its stored value, so turning the parent back on restores the answer
already given.

The My QBZ row loses its fixed logo preview: it renames the hub and
never picked an icon, so the image was decoration in a control column.

Verified in a running window, English and Spanish.

cc
2026-08-22 00:00:25 -06:00
vicrodh 3c4c2dae20 feat(settings): an app-wide typeface, picked from the fonts already shipped
Settings > Appearance > Typography & Language gains a Font row. The
catalogue is the SAME five the lyrics panel offers and the same slugs,
because they are the same files: everything is bundled in
qml/assets/fonts/, so a choice cannot fail on a machine that lacks the
face and looks identical everywhere. "System" resolves to the window
default, which is Inter.

It applies LIVE, with no restart: the family sits on the
ApplicationWindow, which every Text that does not name its own family
inherits.

The index rides its own qproperty rather than the settings document,
because the consumer is the root window and making it parse the whole
settingsJson on every republish to read one integer is the wrong trade.
Same shape as theme_filter beside it.

The loaders are lazy, exactly like the lyrics picker: a family nobody
selected is never loaded, and one that has not finished loading falls
back to Inter rather than rendering the app in a wrong face for a frame.

Only the first label is translated — a typeface name is a proper noun.

cc
2026-08-22 00:00:05 -06:00
vicrodh 58fa77a390 feat(qt): fade artwork in as it arrives
Artwork appeared in one step the instant its pixels were ready, which
reads as a glitch on a page where covers land a few at a time.

It belongs in this file and nowhere else: AlbumCard, PlaylistCard,
TrackCard, ArtistCard, SlimCard, LabelCard and RadioCard all mount a
RoundedImage, and 81 files in the tree do. Per-card animations would be
seven copies that drift, and would still miss the detail headers and the
rows.

The trigger is `ready`, not the path and not `status`. `ready` is this
file's readiness contract, MONOTONE WITHIN ONE SOURCE, which is exactly
what a fade needs: the derivative swap sends `status` back to Loading
mid-life, and fading again there would flash a cover already on screen.
A recycled delegate does re-fade, which is correct — it is a different
cover. The Component.onCompleted arm is not belt-and-braces: an
already-cached pixmap reaches Image.Ready DURING creation, before
onReadyChanged is connected, and without it that cover would sit at
opacity 0 forever.

Measured against the repaint doctrine rather than argued:

  at rest              0 presents/s (byte-identical frames over 3 s)
  during a page load   Home 5.7 -> 6.0/s, Library 11.1 -> 12.4/s

Qt advances every running animation on one tick and the window presents
once per frame however many items are dirty, so fifty covers arriving
together cost the same presents as one. OpacityAnimator, not a Behavior,
so the fade keeps advancing while the GUI thread is still paying off a
mount — which is precisely when covers land.

cc
2026-08-21 23:59:47 -06:00
vicrodh b970e8f853 perf(qt): mount the settings panel in view, not all nine
The nine panels were siblings gated only by `visible:`, so every mount
of Settings built the 30 KB Appearance panel, the 19 KB Plex panel and
the rest in order to show one of them. Measured before the change,
Settings was the slowest route in the app.

A Loader with `active:` is the fix that shipped for the Discover tabs
and it is the fix here. Measured after: built 326 -> 167 ms, to-idle
392 -> 224 ms.

The two overlay modals (LibFolderEditModal, DacWizardModal) are still
built unconditionally and are the next item of the same kind; they own
their own open state, so gating them is a separate change.

cc
2026-08-21 23:59:29 -06:00
vicrodh dc8cb1fa32 perf(qt): build only the discover rails that fit the viewport
2026-08-17 fixed the four Discover tabs, which were all instantiated
because `visible: false` hides an item without stopping QML from
building it. This is the same disease one level down and it is what was
left: a Repeater builds EVERY delegate in its model, so a ten-section
tab built ten rails, each rail's horizontal ListView then created its
own delegates during the first layout pass, and the viewport has room
for about two and a half.

That work is what `to-idle` was made of — the rails nobody could see
paying for their cards before the GUI thread could reach the event loop
and let a frame out.

The seed is one viewport and the rest arrive after the first frame. A
rail is ~328px with the Column spacing against a ~740px pane, so three
covers the fold; +2 per tick reaches even the twelve-section For You tab
inside ~80 ms. The `active &&` on the visibility gate is load-bearing:
a Column skips invisible children entirely, but an active:false Loader
is visible and 0px tall, which would reserve a spacing slot per pending
rail and collapse it as the rail lands.

Honest limit: this did NOT turn out to be what fills the blank stretch
between the acknowledgement frame and the content. Library has no such
ramp and shows the same shape, so that hypothesis is refuted and the
remainder is unattributed.

cc
2026-08-21 23:59:29 -06:00
vicrodh 5b274cab57 perf(qt): acknowledge a route change on the next presented frame
Measured by driving the real window and diffing the captured frames: a
click on Discover > Home left the window with ZERO pixels changing for
590 ms — the flyout did not even close. The click handler, the flyout
close, the sidebar highlight, the page blank and the whole mount all ran
inside ONE turn of the event loop, so the first frame that could be
presented was the one that already had the finished page in it.

The breakdown matters more than the total, because only one part of it
is what it looks like:

    navigate_to (Rust, GUI thread)      7.6 ms
    bridge queue hop                    1   ms
    the QML mount (`built`)           263   ms
    post-mount work before a frame   ~290   ms   <-- `to-idle`

`built` is under half of it. `to-idle` is the number that matches the
freeze, and any future work here should be judged on that one.

So the route is now committed in two phases: arm on the change, build on
the next PRESENTED frame (frameSwapped). Frame N carries the cheap half
— flyout gone, highlight moved, page blanked — and frame N+1 pays for
the mount. Acknowledgement measured at 73-90 ms across five routes and
repeat runs, against 590 ms before.

Two things this had to get right, both caught by capturing frames:

  - The backstop timer was a RACE at 32 ms. Render-at-next-vsync
    (16.67 ms here, per QSG_INFO=1) plus a QUEUED frameSwapped delivery
    lands in ~16-33 ms, so Home acknowledged at +90 ms while Library got
    nothing at all on the same build. 250 ms puts the guard outside that
    range; it now only covers the hidden-window case it was written for.
  - During the armed frame `viewLoader.item` is still the OUTGOING view,
    so the incoming route's discriminators were applied to it. The
    activeTab Binding wrote "home" onto LibraryView — which has an
    activeTab, so the typeof guard passed — emptying its body and
    restarting the page fade over the blank. Both Bindings now require
    the commit to have happened.

Not asynchronous: incubation is time-sliced and was reverted the day it
was tried; every view is still built synchronously.

cc
2026-08-21 23:59:10 -06:00
vicrodh a11b302e5d chore(qt): an env-gated probe for what a navigation costs the GUI thread
The route stopwatch in ContentRouter.qml starts when QML sees the new
route, so everything before that — the invokable, the prefs write inside
nav_qt::record, the per-view hydrate — was outside every instrument this
track has. That is not a small remainder: measured with this probe, a
navigation spends single-digit milliseconds there, which is what rules
the Rust side OUT as a cause of the dead click and points at the mount.

QBZ_QT_NAV_PROBE=1 turns it on. Unset it costs one OnceLock read per
navigation and emits nothing, the same discipline the qbz.nav.timing
LoggingCategory already follows.

cc
2026-08-21 23:58:52 -06:00
vicrodh 64f0382153 chore(i18n): the disc batch's strings, in all eight catalogues
481 entries across en, es, de, fr, pt, ru, ja and nl: the rip wizard and its
progress panel, the metadata modal, the per-disc blocks and the track filter,
every new toast, and the tooltips.

Spanish is Mexican/neutral and was scanned for voseo and Castilian before
landing.

cc
2026-08-21 18:58:33 -06:00
vicrodh 2273db9184 feat(qt): the disc surfaces — rip wizard, metadata correction, and a pane that
can be read

RIP WIZARD, three steps, each answering something the first version got wrong
by not asking. What will happen — which disc, how many tracks, and that the
output is FLAC, stated rather than implied. The tracks, editable, because a
CD-DA carries no titles at all so "named wrong" is the ordinary case and once
files are on disk it is a rename job. And what happens afterwards: a ripped
album the library cannot see is a folder full of files, so the destination is
compared against the registered folders — by path COMPONENTS, not by string
prefix, since /music-backup starts with /music and is not inside it — and the
right follow-up is offered, naming the folder it would re-scan. "Do nothing" is
an explicit choice rather than the silent default. The follow-up runs only on
success.

Tracks can be deselected. Everything is ticked on arrival and the user goes
from all to fewer; a wizard that starts empty makes the common case the one
that costs the most clicks. On the Rust side an absent `selected` means yes, so
an older form cannot silently rip nothing.

A PROGRESS PANEL behind the ripping chip, which is a door rather than a label:
it is the only thing on screen during a rip and the two questions it could not
answer are the ones that matter. A soft lock says not to eject — soft because
nothing here can hold a tray shut, so the honest move is to say it and keep
saying it. Per-track state with a bar on the row that is actually moving.
Stopping is offered, deletes nothing, and says so; a cancelled job does not
jump to 100 %, which is the one lie a progress bar must not tell.

CORRECT DISC DETAILS, for a CD and a SACD image and for neither on a folder,
which has no identity to write a correction under. The list shows what
distinguishes four pressings of one album rather than four identical titles,
and flags a track count that disagrees with the disc. Selecting is not
applying: the candidate is fetched in full and its track list shown, because
that list is the only thing proving a plausible title is the right pressing.
Applying renames the live session without renumbering ids — a re-open would
orphan a playing track — and writes a user row. Only MusicBrainz re-resolves
the cover; Discogs ids are not Cover Art Archive keys and a wrong cover is
worse than the old one. The correction can be dropped, because without an
escape a bad apply is permanent and that makes people afraid of the button.

THE PANE SPLITS BY DISC. A box set scans as one album — measured on a 34-track
set whose album_group_key is the box root for every file while the disc number
sits in the data unused — so the pane was a flat list 247 rows long. The key
carries the disc, unconditionally, because it round-trips through the play-album
lookup. Its own client-side filter over what is already rendered, since finding
one track meant scrolling past the other 246.

EPHEMERAL TRACKS LOSE WHAT CANNOT OUTLIVE THEM. queue_qt has published
isEphemeral on every row for a while and the queue panel respected it; the bar
never read it. The heart goes inert, the add menu keeps only its queue actions,
and the row menu comes back with those three and nothing else. Absent rather
than rendered-and-inert.

Also: a spinner on Open that blocks a second click onto a drive already being
read, held by a counting RAII guard because opening is a relay and the chip
must not blink off in the seam; the now-playing card routes a disc to the pane
it came from instead of to an album id no catalogue can open; the file pickers
use rfd, the platform's own dialog, which the POC-NOTE said was a five-line
change once the dependency existed — it existed, Settings had been calling it
from this crate for weeks, and on macOS the four Linux choosers do not exist so
Open folder and Open SACD image opened nothing at all, in silence; the empty
state stands down for the Open pane, which on a machine with no library drew
both fully painted; tooltips on every disc control through one hover() helper,
since two call sites had already mismatched their key, which does not fail —
the bubble simply never goes away.

cc
2026-08-21 18:58:22 -06:00
vicrodh b69a058bc3 feat(rip): a provenance log and the cover, beside the tracks
qbz-rip.log is deliberately NOT an EAC log. An EAC or XLD log is trusted for
what it PROVES — test-and-copy, a drive read-offset correction, an AccurateRip
cross-check against thousands of other rips of the same disc — and QBZ does
none of the three. A file that imitated the format without the method would be
worse than no file: it invites a claim this ripper cannot back, and the people
who read rip logs are exactly the people who check.

So the header says what it is and is not in the first block, not in small
print. What remains is true and useful: which disc (DiscID and TOC fingerprint,
both recomputable by anyone holding it), which drive, and per track the audio
md5 that already lives in the file's own STREAMINFO — verifiable with flac -t
without QBZ. Verified end to end: metaflac --show-md5sum prints exactly what
the log claims, and flac -t passes on every track.

"0 read errors" is a real promise and is stated as one: an unreadable sector
ABORTS the rip rather than being written as silence, which is stronger than a
burst-mode EAC rip. Where the OS did the reading there is no drive to name, and
the log says that instead of printing a volume as though it were hardware.

A partial rip declares itself partial: three tracks with no such line read as
the whole album.

cover.jpg keeps the source's EXTENSION — renaming a PNG to .jpg is how a
picture stops opening. Neither sidecar can fail the rip: the audio is the
deliverable, and a missing extra is a blemish on a folder of correct files.

Five tests, including that the log states all three things it does not do, that
the logged digest IS the file's own, and the calendar arithmetic that is the
only thing here that could be quietly wrong for years.

cc
2026-08-21 18:57:42 -06:00
vicrodh b38d6bd867 fix(player): attach a cover to queued rows that were enqueued without one
The sibling of patch_plex_quality, for the other thing a row can learn after it
was enqueued. A disc carries no artwork — it is fetched, and the fetch outlives
the click that started playback — so a CD queued at second two is still holding
artwork_url: None when the cover lands at second ten. Every consumer downstream
reads the QUEUE row (the now-playing bar, the miniplayer, MPRIS through
art_url_for), so patching the session store alone left all of them blank.

Rows that already have art are left alone: this fills a gap rather than
overwriting a cover somebody else resolved. It answers two questions
separately, because they drive different work — any patch makes the queue
panel's thumbnails stale, only the current one needs the now-playing stamp
re-pushed.

cc
2026-08-21 18:57:42 -06:00
vicrodh b6deec0aaf feat(integrations): search and fetch remote album metadata
The pure converters have been here for a while; what was missing was the thin
async layer that puts a client in front of them. It lives here rather than in
the frontend for the reason ADR-006 gives: which provider answers, how a query
is built and what an empty answer means are domain decisions, and a second
frontend must not re-derive them.

The Tauri reference is v2_remote_metadata_search / _get_album with its State
arguments removed. A provider that is disabled, rate-limited or simply silent
answers with an empty result set rather than an error, because "nobody has this
record" and "the server said no" look identical to someone staring at a list —
and the distinction that does matter rides its own flag.

Discogs needs no credentials wired: its client already goes through the proxy.

cc
2026-08-21 18:57:22 -06:00
vicrodh 57ae465668 feat(disc): remember a disc after it has been ejected
A disc is the one medium QBZ can play that leaves nothing behind: no path, no
row, no file. Everything learned about it was thrown away when the tray opened
and re-derived from the network next time.

That is fine for names that came from MusicBrainz and not fine for names that
came from a human. One DiscID can name several pressings — this album answers
with four — so the moment there is a button to pick the right one, or a wizard
that lets a title be fixed before it is written, the choice has to outlive the
eject. Otherwise correcting a disc is a toy: fix it, take it out, put it back,
it is wrong again.

A CACHE, not a second source of truth, with one rule that makes it safe: a row
the user edited is never overwritten by an automatic lookup. put_auto refuses
to touch an edited row and only put_user can replace one, enforced in the store
rather than at the call sites so a future third lookup path cannot forget.

Identity is the TOC fingerprint: pure geometry, no network, available before
anything has been looked up, and identical on both platforms. It names the
GEOMETRY rather than the pressing, which is the point — "for this geometry the
user chose pressing 2" is the fact worth keeping.

Global rather than per-user, because which record is in the drive does not
depend on who is logged in. Seven tests, including the one this exists for.

disc_identity carries WHICH disc is open for the two features that name the
medium rather than the session; nothing there persists.

cc
2026-08-21 18:57:22 -06:00
vicrodh 8d4ed8e959 feat(disc): read a CD on macOS, through a reader split by platform
The disc feature was built entirely on Linux in a day, and the result did not
COMPILE on macOS at all: qbz_disc::cdda was cfg-gated to Linux while qbz-rip,
cdda_qt and audible_qt used its names unconditionally. Any macOS build has been
broken since qbz-disc landed.

ONE SET OF NAMES, two implementations that share no code. Linux talks to the
drive through ioctls and reads raw sectors; macOS does not have to, because the
OS mounts the disc and exposes one AIFF per track plus a .TOC.plist. qbz-rip,
audible_qt and cdda_qt needed no change, which is the test of whether the seam
was in the right place. Every other platform compiles and reports "no drive".

VERIFIED against the owner's Tool — Fear Inoculum, the USB drive moved from the
Linux box to the Mac mini:

  - the plist's Start Block includes the 150-sector lead-in that the kernel
    omits. Subtracting it reproduces the ioctl's numbers exactly, so both
    machines compute DiscID BeNBMsD8Du5NO2W61Yk.B2jwwIs- and fingerprint
    38bef21351f7fca3 — which is what lets a disc corrected on one be found on
    the other;
  - all seven tracks read on macOS md5 to exactly what metaflac reports for the
    FLACs the Linux path ripped from the same disc hours earlier.

TWO THINGS ONLY MEASUREMENT CAUGHT.

Byte order. "AIFF is big-endian" is true of the format and wrong here: macOS
writes AIFC with compression sowt, which is twos backwards and means the
samples are already little-endian. An unconditional swap produced audio that
passed every cheap statistic — 97 % non-zero, full-scale peak — and matched the
Linux rip nowhere. The COMM chunk is read and the swap is conditional.

Track resolution. audible_qt rebuilds its TocTrack from a CdRef, which carries
a device and a sector range and no track number, so it passes zero. Matching on
the number meant every CD track on macOS answered "could not read the disc"
while ripping, which passes a real number, worked — two paths disagreeing with
only one of them exercised. Resolution is by start_lsn, the address the seam
actually carries, with a regression test on a synthetic volume that fails with
NoDisc under the old rule.

One transient disagreement is recorded rather than smoothed over: reading all
seven tracks back to back once produced a different digest for the last track,
and three further reads matched. The read is not perfectly reproducible on a
marginal region, which is exactly the caveat this approach already states.

cc
2026-08-21 18:57:03 -06:00
vicrodh 70defb2e86 fix(dsd): the SACD stereo area is byte-interleaved, not block-per-channel
Reported: a SACD image played as white noise with the music distorted behind
it, identically in all three DSD modes. That the three agreed was the clue —
DoP and native never touch the PCM filter, so the damage had to be upstream of
the delivery choice, in how the bits are pulled out of the image.

THE LAYOUT WAS DECIDED BACKWARDS. The first measurement converted twelve
seconds and looked at where the energy landed: block-per-channel put 23 % above
the audible band, byte-interleaved 0.2 %, and the reasoning was that real DSD64
must carry a large ultrasonic component. That argument does not hold for this
pipeline. DsdPcmConverter low-passes and decimates to 88.2 kHz, so it REMOVES
sigma-delta noise shaping by design — a correct decode has nothing above
24 kHz. The 23 % was aliased garbage from bits read out of order: the symptom
was being read as the proof. The ratio does not discriminate either, since
scrambled bits and shaped noise both put energy up high.

Re-measured with a statistic that can come out differently: decode with the
real converter, then ask whether the audible band has the shape of music and
whether the two channels are a stereo pair at all.

  layout             spectral flatness      L/R correlation
  BlockPerChannel    0.038 … 0.107          -0.17 … -0.06
  ByteInterleaved    0.0036 … 0.0093        +0.24 … +0.48

Tracks 1, 4 and 13 of disc 1 and track 5 of disc 2. A negative correlation
between the channels of an orchestral recording is not a subtle hint, and a
spectrum ten to twenty times flatter is noise.

Two earlier instruments are recorded in the doc as non-discriminating rather
than deleted quietly: the energy ratio, and the variance of the raw stream's
64-bit local mean, which answers the same for clean and scrambled bits because
the local mean of a byte-level mix of two correlated channels is still roughly
the audio.

Also lands a real framing bug found on the way, which was NOT the cause: the
packet descriptors carry a frame_start bit that the reader ignored, and frames
begin 0, 672 or 1344 bytes into a track's payload depending on the track. With
byte interleaving an even offset is harmless, which is why it hid; an odd one
would swap the channels. FrameSync makes the reader hunt for the boundary, and
keeps the unsynced path so a probe can reproduce the broken stream.

cc
2026-08-21 18:56:40 -06:00
vicrodh cd8f8e6443 fix(qt): the CD cover arrives, and it stops blocking the disc from opening
Reported: the CD opened with its names but no artwork. The log said why,
because the previous commit taught it to — "no cover art (500 Internal Server
Error)" — which is a better failure than the silent one it replaced, and it
pointed straight at two separate bugs.

WRONG KEY. Cover art was asked for by RELEASE, and a release is one pressing.
The owner's Fear Inoculum answers 500 for its release and 200 for its RELEASE
GROUP, which is the album as a work and where the canonical art lives. The
lookup now asks for `release-groups` too and tries the release first, the
group second.

WRONG PLACE. The fetch was awaited BEFORE the session was published, so a
click produced ten seconds of nothing — measured: the Cover Art Archive took
9.4 s to answer, because it redirects to archive.org. The tracks now land
immediately and the art is patched in when it arrives.

Patching it in needs `replace_tracks_preserving_ids`: re-running `open_tracks`
would renumber every row from the synthetic floor, and the queue may already
hold the old ids — a playing track would lose its source mid-song. A late
cover for a session that has since been closed or replaced is dropped rather
than painted onto whatever is open now.

cc
2026-08-21 09:08:21 -06:00
vicrodh a922bdd316 feat(qt): disc covers, a corner close, and an icon for Rip
Three things the owner asked for after driving the first build.

COVER ART. A SACD image carries none, but the file it was downloaded as
almost always sits beside one — `cover.jpg` next to the `.iso`, or one level
up when the rip is split into "Disc 1"/"Disc 2" folders, which is the shape of
the owner's Rheingold. `find_folder_artwork` already knows that walk, so this
reuses it rather than growing a second rule about where covers live.

CLOSE moves to the pane's top-right corner, for all three media. It used to
sit in the action row beside Shuffle — a destructive action one pixel from
"play", which is where a hand goes without looking. It is also OUTSIDE the
Flickable now: scrolling a 24-track disc must not carry the only way to close
the session off the top of the screen.

THE RIP ICON is a disc with a folder, drawn as a Lucide-style outline rather
than traced from the owner's PNG. The argument is the size it renders at: at
15px a solid, detailed glyph turns to mush while an outline still reads, and
the comparison was rendered at both sizes before choosing.

Adding it also found a gap in my own tooling. `icon_tint_qt.rs` re-tints from
MASTERS at theme-change time, and `masters_cover_every_shipped_glyph` failed
the build for a glyph that was baked but had no master — six files written,
one line forgotten. `bake_icon.py` now registers the master itself, because a
step that has to be remembered is a step that eventually is not.

cc
2026-08-21 09:07:59 -06:00
vicrodh bbce651d79 feat(qt): a Rip button on an open CD, and it asks where to put the album
The button lives on the ephemeral pane's header and appears only for a
PHYSICAL disc — an image is already a file, so offering to rip one would be
offering to copy something the user already has. Whether the session is a disc
is DERIVED from its rows rather than passed in: a flag somebody has to
remember to set is a flag that eventually is not.

The destination is always asked. The app knows several plausible music folders,
which is exactly why it must not pick one — a ripper that decides where your
music goes is a ripper you clean up after.

While a rip runs the button gives way to its progress ("3/7 · 45%") and a
spinner: one control, one state, rather than a button that looks pressable
during a job it cannot start twice. The bar is per-DISC and not per-track,
because a listener watching a 79-minute album wants to know how far the ALBUM
is, and a per-track bar that resets seven times reads like a stall.

Driven end to end on the owner's Fear Inoculum: seven tracks written into
"Tool - Fear Inoculum/", every one decoding clean and seekable, all tagged,
no `.part` left behind.

Four new strings in all eight locales.

cc
2026-08-21 02:27:40 -06:00
vicrodh 5d3d51a882 feat(rip): rip a CD to tagged FLAC, bit-exact and seekable
New crate `qbz-rip`: CD audio in, tagged FLAC out. Frontend-agnostic — it
knows discs, samples and files, and reports progress through a callback so a
UI can drive it without this crate depending on one.

`flacenc`, pure Rust: no libFLAC to build, nothing extra to bundle into
Flatpak, Snap or AppImage, and macOS gets it for free. `lofty` (already in the
tree) writes the Vorbis comments.

`RipSource` is the portability seam, and it is the only part that differs
between platforms: Linux reads sectors through ioctls, while macOS MOUNTS an
audio CD and presents each track as an AIFF under /Volumes — so there a "read"
is a file read. Everything after the seam is shared.

Two defects were found by ripping a real disc and looking at the result, and
neither would have failed a test:

  1. The tags were never written. Tagging happened on the `.flac.part` file,
     and lofty identifies a file by its EXTENSION — so it wrote nothing, and
     said nothing. Tagging now happens after the rename, with a test that
     reads the bytes back and asserts the strings are in them.

  2. The files could not be SEEKED. `flac -d --skip=2:00` returned
     FLAC__STREAM_DECODER_SEEK_ERROR and `flac -t` warned 3102 times over 3103
     frames. The cause is precise: a stream's last frame is short (12 709 620
     samples at a 4096 block leaves 3828), flacenc reports that honestly as
     min_blocksize 3828 / max 4096 — but in FLAC `min != max` MEANS
     variable-blocksize, and a decoder reading one expects SAMPLE numbers in
     the frame headers while the frames correctly carry FRAME numbers.
     libFLAC's own encoder writes min == max for exactly this reason. Two
     bytes of STREAMINFO, and now: zero warnings, seek works.

Verified against the drive, not inferred: 176 400 bytes decoded out of the
FLAC are byte-identical to the same sectors read straight off the disc. The
whole album ripped from the UI — seven tracks, 436 MB, every one decoding
clean, tagged with TITLE/ARTIST/ALBUM/TRACKNUMBER/TRACKTOTAL/DATE/ALBUMARTIST,
and no partial file left behind.

Filenames are sanitised for the strictest filesystem the library might be
copied to, including Windows' reserved DEVICE names and its habit of silently
dropping a trailing dot — which would turn two tracks into one collision.

cc
2026-08-21 02:27:24 -06:00
vicrodh 50469ccc57 chore: lock the new disc dependencies
sha1 and base64 for the MusicBrainz Disc ID, and the qbz-disc path entries.

cc
2026-08-21 01:34:35 -06:00
vicrodh 452589ddaa feat(qt): open a SACD image and play it — the disc names itself
`Open > Open SACD image…` picks a .iso, reads its stereo area and publishes it
as the ephemeral session. Nothing lands in library.db.

Unlike a CD, a SACD needs no network to be named: the Master TOC carries the
album title and artist, and the area TOC a title per track. The owner's disc
opens as "Wagner: Das Rheingold", 24 tracks, every one with its German title
and "Berliner Philharmoniker, Herbert von Karajan, etc." — all of it read off
the image.

Routing is one line in `LocalSource::playback` and none in the player: a
`sacd:` reference rides the existing DsdFile ticket, because `open_dsd` is the
funnel all six player-side DSD constructions already go through. Teaching it
there is what makes the PCM, DoP, native and gapless paths inherit disc images
for free.

Driven end to end on the owner's Rheingold: the tab reads "Wagner: Das
Rheingold 24", the badges read HI-RES / DSD64, and pressing play took the DoP
route —

  Player: DSD track — DSD64 via DoP (176400 Hz carrier)
  [ALSA Direct] DoP hardware configured: 176400Hz, 2ch, S32_LE
  Audio thread: DoP playback STARTED

which is the outcome that matters: the disc's DSD bits reach the DAC without
being converted to PCM at all.

Six new strings in all eight locales. Unsupported cases are named rather than
approximated — a multichannel-only area, a DST-compressed one and a file that
is not an image each get their own message.

cc
2026-08-21 01:34:23 -06:00
vicrodh 06405969e9 feat(disc): read a SACD image's stereo area, measured against two real discs
Adds an ISO 9660 walk (no mounting, no privileges) and a Scarlet Book parser
for the stereo audio area, plus the `DsdDemuxer` adapter that lets every DSD
delivery the player already has — PCM, DoP, native — play a disc image.

The parser exists because the previous account of this format, written in this
repo, was WRONG: it said the per-sector header was a fixed 8 bytes, derived
from looking at one sector. Every structure below was re-derived by a CENSUS —
3 066 893 sectors across both of the owner's Rheingold discs, zero exceptions —
and each claim is falsifiable rather than asserted:

  - byte 0 carries packet_count = (b>>5)&7 and frame_info_count = (b>>2)&7,
    so the header is 1 + pc*2 + fic*3 and takes the values 5, 8 or 10;
  - packet descriptors are BIG-endian, and the length field is ELEVEN bits,
    not the twelve every published layout shows. The first descriptor on both
    discs is 0x181B, whose low twelve bits are 2075 — larger than the sector
    it lives in. With eleven bits it is 27, and 5 + 27 + 2016 = 2048;
  - the proof of the whole decoding is the sum rule
    `header + SUM(packet lengths) == 2048`, which the parser CHECKS on every
    sector rather than trusting;
  - the area is uncompressed, proven from the data and not from a flag: the
    audio-packet bytes divide by 9408 (one uncompressed DSD64 stereo frame)
    exactly — 350 495 frames on Disc 1, 306 696 on Disc 2. DST is
    variable-length; those could not be integers.

Track geometry comes from SACDTRL1/TRL2, tied together by an identity that
holds for 44 of 44 tracks: start_lsn == area_start + start_time * 350. The
ISO 9660 extents mirror SACDTRL1 byte for byte, so the one-sector overlap
between consecutive tracks is the disc's own accounting, not a bug to fix.

The channel layout inside a frame was the last unknown, and three statistical
probes could not settle it — they were measuring the DSD noise floor. What
settled it was physics: converted with the real filter, block-per-channel puts
23 % of its energy above the audible band while byte-interleaving puts 0.2 %
there. A correctly decoded 1-bit stream MUST carry that rising noise floor,
and no filter puts it back once scrambling has folded it away.

Also in this commit, from the same measurements: `2C_TAREA.2CH` on Disc 1 is
recorded in FOUR extents, so `IsoImage::find` REFUSES a multi-extent file
rather than returning its first quarter as if it were the whole thing.

cc
2026-08-21 01:33:59 -06:00
vicrodh d9893b659a feat(qt): open the audio CD in the drive and play it
`Open > Open audio CD` reads the disc, builds an ephemeral session and lands
on its tab. Nothing touches library.db, which is the whole point of the
ephemeral path: a disc is content from outside the index that leaves with the
plastic.

It does not invent metadata. CD-DA carries no titles, and the disc measured
here reports its MCN as all zeros, so tracks are "Track N" and the album is
"Audio CD". A DiscID lookup is the obvious next step and is deliberately not
faked: a wrong title is worse than an honest number.

The feeder is the interesting part, and its liveness check took three designs
because the first two were wrong in opposite directions — both caught by
driving the app, neither by any test:

  1. `current_track_id() != play_id`: `play_streaming` posts to the audio
     thread and returns, so the id is not ours yet and the feeder retires
     before reading a sector. Nothing played at all.
  2. The same test with a wall-clock grace: a DEADLOCK. That field is stored
     only once the audio thread starts playback, which needs the initial
     buffer, which is what the feeder is filling. Waiting on it is waiting on
     ourselves; the grace expired and the track stopped mid-play.
  3. A feeder slot the audio thread plays no part in. A newer disc play claims
     it and the older thread retires on its next block. The acknowledgement
     path stays for "something else superseded us", but only AFTER the player
     confirms our id, and with no timeout — a timeout is what kills a healthy
     feeder.

A 16-bit WAV header, not `qbz_dsd`'s: that one is hardcoded to 24-bit, and
reusing it would either lie in the header or promote 16-bit samples to 24.
The shift is arithmetically lossless but it makes the DAC open at 24 bits for
a 16-bit source, which is precisely the silent conversion a bit-perfect path
must not contain. `play_streaming` also does not publish bit depth, so the CD
states 44 100/16 explicitly instead of inheriting the previous track's.

Driven end to end on the owner's disc: seven tracks with the right durations,
CD / 16-bit / 44.1 kHz badges, ALSA Direct opened at 44100 Hz in exclusive
mode, playback past 2:25 with no read errors, and two rapid skips retiring
their feeders in ~160 ms each.

Seven new strings in all eight locales.

cc
2026-08-20 23:29:21 -06:00
vicrodh 3ba476b43c feat(source): a CD track is a ticket of its own, not a file
`PlaybackTicket::CdTrack { device, start_lsn, sectors }`. No path-shaped
variant fits: a track on a disc is a device plus a sector RANGE, and the bytes
only exist while that medium is in that drive. `File` and `DsdFile` would both
hand it to something that opens it.

`LocalSource::playback` answers it FIRST, before the extension tests, through
`CdRef::is_cd_path` — the encoder's own predicate rather than a second
`starts_with` written out at the call site. That is deliberate: a hand-copied
prefix list is exactly how six bugs of the same shape shipped on 2026-08-20,
and a fold to a valid-looking answer is invisible to every test.

The match in `audible_qt::play_ticket` is exhaustive on purpose, so the new
variant broke the build until somebody decided what a CD means. It now means:
read the sectors and feed them as 44.1 kHz / 16-bit / stereo PCM.

`EphemeralLibraryState::open_tracks` installs a track list that did not come
from scanning a directory. `open_folder` cannot serve a disc — it takes a
`&Path`, rejects anything that is not a real directory, and derives every
field by reading files off a filesystem. A disc has no filesystem to read.
Ids come from the same synthetic range, so every playback caller keeps routing
them without knowing a disc exists.

cc
2026-08-20 23:29:00 -06:00
vicrodh db0999bf6f feat(disc): read CD-DA off a real drive, measured before it was written
New crate `qbz-disc`: table of contents and raw audio sectors through the
kernel's own cdrom ioctls. No libcdio — not because libcdio is worse (it is
better at RIPPING than this will ever be) but because this path PLAYS a clean
disc, and for that the kernel interface is enough while a native library would
have to be built into Flatpak, Snap and AppImage. Both need the same --device
permission in a sandbox; only one needs the library shipped.

Every claim below was measured on the owner's USB drive with Tool — Fear
Inoculum BEFORE a line of this crate existed, so the design answers evidence
rather than the other way round:

  - the TOC reads back 7 audio tracks, lead-out at LSN 356568, and the track
    lengths match the record exactly (10:21, 11:52, 12:44, 13:37, 10:05, 4:48,
    15:44 — 79:11 total);
  - CDROMREADAUDIO over 20 frames from LSN 4000 returned 47 040 bytes, 99 %
    non-zero samples, peak 14559: real audio, not silence;
  - the drive hands back LITTLE-endian samples. Neighbouring-sample deltas
    average 73.5 read as LE against 20 971 read as BE, a 285x margin, so the
    bytes go straight into a little-endian WAV with no swap. That is one
    drive, so `looks_byte_swapped` keeps the test available as a diagnostic
    instead of an article of faith;
  - the disc's MCN is all zeros, which is why identity is a digest over the
    track table and not the catalogue number.

What this is NOT is accurate extraction: no overlap synchronisation, no cache
modelling, no jitter verification. On a clean disc that is fine for playback,
and an unreadable sector surfaces as an ERROR rather than a hole full of
zeros — silence that pretends to be audio is the one outcome a bit-perfect
player must never produce.

`/dev/sr0` is nowhere in the code: drives are enumerated, because a box can
have several and /dev/cdrom is a symlink that may point at any of them. Data
tracks of a mixed-mode disc are skipped by their control bits.

`CdRef` is the typed reference (`cdda:/dev/sr0#285735+70833`) with ONE parser,
not prefix tests copied around — the scattered version is a known failure mode
here and cost a whole session on 2026-08-20.

11 tests, several asserting against that real table of contents.

cc
2026-08-20 23:28:28 -06:00
vicrodh 0e9721d24b refactor(qt): reorder the Local Library header and label the Open menu
Owner's layout: the two maintenance glyphs sit together and the action that
produces new content reads last —

    Local Library  (settings) (resync)  [folder] Open v

Open also stops being a bare glyph. A folder icon on its own reads as "browse
folders" when there is a tab literally called Folders two rows below, and this
control is the one door for every medium the pane can hold: a folder now, a CD
and a SACD image next. It is a 30px / radius-6 chip with icon, label and
chevron — the same small toolbar-control contract this header's selects use.

cc
2026-08-20 22:24:11 -06:00
vicrodh 9666ef129e feat(qt): give the ephemeral pane an album header, a shuffle and zebra rows
The pane opened on a bare title, a track count and the folder PATH — a
filesystem readout for something the user is about to listen to. For the
common case this pane IS an album page: one folder, one album, one cover.

It now reads like one. The cover is 224px, the size LocalAlbumHeader uses, so
an opened folder and an indexed album never present the same record at two
different scales; the cell collapses entirely when the session has no art
rather than reserving an empty square. The second line trades the path for the
total playing time, formatted by the same `total_duration` helper every other
Local Library surface uses. Play gains a Shuffle beside it, with the same
`(shuffle)` shape `play_album` has, and the Clear stays — it is the only way
to close a session, and with it the only way to make the tab go away.

The per-block header now draws only for MULTI-album sessions. Adding the big
header made it a stutter for single-album ones: the same cover, the same
title and the same count, twice on one screen.

Track rows get `zebra: true`. `TrackRow` already had the property; this pane
was simply the surface that never set it.

The shuffle's generator was replaced before it shipped. The first version was
a bare xorshift with a comment claiming a folder shuffle "is not a place that
needs a good generator" — measured over eight consecutive shuffles of a
ten-track album, one track opened FOUR of them. It is splitmix64 now, seeded
with a counter as well as the clock so two shuffles inside one clock tick
cannot repeat, and there is a test asserting no position is pinned over 2000
runs. Shuffle quality is something the listener sees directly.

cc
2026-08-20 22:23:56 -06:00
vicrodh 45c686fab0 fix(qt): take the view to the session you just opened, every time
Reported: opening a folder left the view on whatever tab was already showing.

The handler watched `ephemeralActive`, and that flag is ALREADY true when you
open a second folder over a first — or when the boot restore opened one before
you ever touched the view. So it fired exactly once in the life of the app and
then never again, which is precisely the symptom.

It now watches an open SEQUENCE that changes on every open. The discriminator
was already in the code and did not need inventing: `open()` and `open_path()`
pass a runtime to `scan`, `rehydrate()` passes None. Bumping only for the
former is what keeps a remembered folder from stealing the tab you actually
navigated to at startup.

Also restores `onEphemeralActiveChanged`, which I deleted by accident in the
label commit: a range replacement swallowed the handler sitting between the
two edits. It is the fallback that sends you to Albums when the session closes
under you, and without it closing a session left `activeTab` pointing at a tab
that no longer exists — a blank content area. It had been smoke-tested BEFORE
that edit, so the test was valid when it ran and stale by the time it landed.

Verified in the reported scenario: standing on Albums with one session open,
opening a different folder switches to the tab, the label changes and the
count follows.

cc
2026-08-20 22:23:34 -06:00
vicrodh ffc2d7d664 build(qt): gate on a fifth audit — every icon name must have a baked svg
`QbzIcon` does not draw an SVG by path: it picks a pre-baked, per-tint copy
out of `qml/assets/icons/<tint>/`. A name with no bake renders NOTHING. The
row keeps its 15px slot, the label sits where it always did, and no test,
audit or compiler says a word.

That is how three rows of the new Refresh menu shipped iconless through a
clean build, 434 green tests and four green audits: `"icon": "server"` looked
obvious and has no bake. It was caught by driving the app and LOOKING.

The audit checks literal `name:` / `"icon":` assignments against the union of
the tint directories, and it is deliberately a floor, not a ceiling: an icon
chosen by expression is out of reach for a static pass, and a first draft that
also flagged names absent from SOME tints produced ~90 findings, all noise —
amber/green/orange/favorite are status tints that hold a handful of glyphs by
design. An audit that cries wolf gets ignored, and then it protects nothing.

Proven in both directions before landing: with "server" reintroduced it fails
and names the file; without it, 101 names across 288 files pass.

The tool itself lives in qbz-nix-docs (planning/tooling stays out of the repo).

cc
2026-08-20 21:15:10 -06:00
vicrodh 85ece130e7 feat(qt): name the ephemeral tab after what is open, with no verb
"Open", "Now Playing" and "Playing: <album>" were all tried and dropped. A
verb is a claim the tab cannot keep: a folder can sit open while something
else plays, and then the tab and the now-playing bar name different tracks on
the same screen. The content's own name cannot go stale that way, and the tab
appears the instant you open something, so nothing has to explain it.

The chain lives in Rust so the tab and the nav flyout read ONE value
(`localEphemeralLabel`) instead of deriving it twice — the flyout does not
parse the ephemeral document and should not start:

  single album -> its title  (a folder IS an album, and so is a disc)
  otherwise    -> the medium's display name, what the pane's header shows
  neither      -> "Media"

Elided at 24 CHARACTERS, not bytes: `QbzTabBar` sizes a segment to its text,
so an unbounded folder name pushes the whole bar off screen — and slicing a
UTF-8 string by byte index panics mid-codepoint on a Japanese album title.
There is a test for exactly that.

New msgid in all eight locales. Verified on screen: the tab reads "Live 8".

cc
2026-08-20 21:14:55 -06:00
vicrodh cb4c03ca46 feat(qt): one Open menu and one Refresh menu in the Local Library header
The ephemeral pane used to be a third arm of the Folders tab, reachable only
through a folder-open icon buried in the tree rail — one arm of one mode of
one tab. It also HID the folder browser while it was open. Neither survives
the media this pane is about to hold (a CD, a SACD image).

Row 1 of the header now carries two menus, both `CardMenu` (the shared menu
surface, no new component):

  - `Open`, always present, is the one door for every medium. Only "Open
    folder…" is wired here; the disc entries land with their readers.
  - `Refresh` replaces the Plex-only re-sync icon (#573). Rows are APPENDED
    per configured source rather than rendered disabled, so a user with no
    media servers sees local folders and nothing else. It needed no new Rust:
    `plexAvailable`, `mediaHasJellyfin`, `mediaHasSubsonic`, `syncPlex()`,
    `mediaSync()` and the settings' own "library-scan" all already existed.

The ephemeral session becomes a tab of its own, present only while a session
is. Two failure modes are handled because the tab can VANISH under the user:
closing the session falls back to Albums instead of leaving `activeTab` on a
tab with no body, and opening one navigates to it — while the pane was an arm
of Folders that was implicit, and with a tab of its own, opening a folder from
Albums would otherwise do nothing visible.

`load_tab_impl` gains an "ephemeral" arm: nothing to load (the session is in
memory), but without the arm every switch to the tab logged "unknown tab",
which is what a real routing bug looks like.

Driven under Xvfb, not inferred: both menus open, the gated rows match the
configured servers, opening lands on the tab, the badges read DSD64, closing
removes the tab and falls back to Albums, and the folder tree is now visible
WITH a session open.

cc
2026-08-20 21:14:36 -06:00
vicrodh a0ed0d752f fix(player): cancel the DSD-to-PCM conversion when its track is superseded
The conversion thread relied on `push_chunk` returning `Err` to notice the
track had changed — the comment said so in as many words ("Reader gone (track
changed/stopped) — just stop"). That check could never fire: `push_chunk`
only extends a Vec and fails solely on a poisoned mutex, and `BufferState`
carries no reader-gone flag and no Drop.

So skipping a DSD track left the thread converting to the END of the file,
growing a buffer nothing would ever read. A 60-minute DSD64 album track is
~1.9 GB of 88.2 kHz/24-bit PCM held for nothing, on a box that hard-freezes
under memory pressure.

The fix reuses the contract that already exists for the CMAF feeder:
`register_stream_feeder` with the play generation, plus a cooperative check
once per converted block. Registration happens AFTER the spawn and carries
the generation the play was born under, so a supersede that lands in between
cancels the thread instead of registering it. Like the network feeder's own
cancel, it neither errors nor completes the buffer — the outgoing engine keeps
playing what is already buffered until the new source is installed.

Confirmed in a live session log, not inferred:
  [CMAF-STREAM] Cancelling feeder for superseded track 5672
  Player: DSD conversion for superseded track 5672 cancelled

cc
2026-08-20 21:14:20 -06:00
vicrodh 71f8d08970 refactor(qt): hoist the quality badge into one classifier and teach it DSD
`myqbz_builder_qt.rs` carried its own copy of the reference's
`quality::badge` plus three helpers, and its own note called hoisting them out
a named follow-up that "must not be done in a domain commit". This is that
commit: they move VERBATIM into `quality_qt.rs`.

The hoist is not tidying. Local Library never had those helpers, so it read a
DSD row's nominal 1-bit depth and 2 822 400 Hz bit rate literally: the CD
badge, and "1-bit / 2822.4 kHz" where MyQBZ printed DSD64 for the same file.
Six surfaces disagreed with a seventh about the same track.

`local_rows` gains a `detail_of` to pair with `tier_of`, and all SIX call
sites are routed through the pair — the pairing is what stops the two halves
of one badge from drifting again. `tier_of` keeps its extra "max" tier on
purpose: `LocalLibraryView.qml:435` filters hi-res on that word and
`QualityBadge.qml:66` folds it back to "hires".

Measured before/after on the same album: CD + "1-bit / 2822.4 kHz" ->
hi-res + "DSD64", on the grid, the rows, the album header and the playlists.

cc
2026-08-20 21:14:08 -06:00
vicrodh 6328994c00 fix(library): read "DSD" back out of the database as DSD, not Unknown
`LibraryDatabase::parse_format` is the inverse of `AudioFormat`'s Display,
which is what the scanner stores. It had no arm for "DSD", so every DSD row
came back out of the DB as `Unknown`: the album header printed "UNKNOWN" as
its format, the quality badge said CD (depth 1 IS a known depth below 24) and
the detail line read "1-bit / 2822.4 kHz". The now-playing bar was right all
along because it reads the decoder, not the row — which is exactly the clue
that separated the two data sources.

A fold to `Unknown` is a VALID answer, so nothing failed: not the build, not
`cargo check` (it cannot see the match at all), not a single test.

Three sites had the same hole and all three are fixed:
  - `database.rs`, the one that bit;
  - `local_plex.rs`, same fold for a Plex server serving DSD;
  - `media_servers_qt.rs`, worse than the others — its fallback is `Flac`, so
    a .dsf from Jellyfin or Navidrome was not losing its format, it was being
    mislabelled as lossless PCM.

The guard is a round-trip over EVERY variant rather than a "DSD parses" test:
adding a variant without an arm now fails here instead of shipping the same
silent loss again. Verified against the real rows: format is stored as "DSD",
so this is a READ-side fix and no rescan is needed.

cc
2026-08-20 21:13:51 -06:00
vicrodh a7b7c1b236 fix(qt): light the playing row for a downloaded track, in every track list
The now-playing affordance — the accent pill on the row's left edge and the
eq bars in the number cell — never appeared for an offline row on the very
view it was played from. `TrackRow` asked one question,

  QbzPlayer.npTrackId === item.id

and for a `qobuz_download` row the two sides come from different id spaces:
the queue id is the QOBUZ CATALOG id (the offline tier is keyed on it) while
every Local Library list draws the row with its `library.db` id. They never
matched, so the row sat dark while its own audio played.

The reference has a second published id for exactly this and the port never
carried it over: `NowPlayingState.local-track-id` (playback.rs:1978-1986).
This adds its twin, `QbzPlayer.npLocalTrackId`, and makes the row match
either.

Published for Offline rows ONLY, and that restriction is the safety of it:
`source_item_id_hint` is a shared slot carrying a Plex ALBUM key and a media
server's own item id for those sources, so publishing it unconditionally
would hand a list a number from another id space and light the wrong row.
It stays empty elsewhere, which makes the second test inert there.

Scope checked by driving the window rather than reasoned about: the pill and
the play glyph land on the right row for local user files, Plex, Jellyfin and
Subsonic both before and after this change — offline was the only source
whose id diverged. The other three surfaces that ask the same question need
no alias: the queue panel and the kiosk album carry queue ids, and the
purchases list carries catalog ids.

qbz-qt 431/431, 4 QML audits green.

cc
2026-08-20 18:16:45 -06:00
vicrodh 8c3514b899 fix(qt): play a downloaded album from Local Library through the offline tier
A CMAF download's `local_tracks.file_path` is the BUNDLE DIRECTORY
(`tracks-cmaf/<qobuz_id>/`, holding `init.mp4` and an encrypted
`segments.bin`), not a playable container. `LocalSource::playback` handed
it back as a `PlaybackTicket::File`, the read failed with EISDIR, and the
audible step reported it as

  audible: file not available at …/tracks-cmaf/266725026 (drive unmounted?)

— a message about a drive, for a bundle sitting on the local disk. The bar
adopted the track anyway, so the album looked like it was playing and no
audio came out.

Three funnels reach the audible step and only two excluded an offline row:
`play_current_if_local` (auto-advance) and
`playback_qt::play_resolved_offline_aware`. The Local Library PLAY funnel
did not, nor did its hand-copy in `local_album_actions`. Only qbz-core's
offline tier can decrypt the bundle, and the row carries the real Qobuz
catalog id precisely so that tier can be asked for it.

- `local_playback::play_audible` is now the ONLY copy of the audible step
  and routes an offline row to the offline tier; `local_album_actions`
  drops its copy and calls it.
- `LocalSource::playback` refuses an offline row with `BadIdShape` instead
  of emitting a ticket that lies. The test is on the row data, never
  `is_dir()`: a stat there would block for the mount timeout on the
  unreachable share the audible step's bounded probe exists to survive.
- Tests run the row through the real `local_queue_track`, because the two
  halves that must agree are its source word and the badge the router reads.

Verified by driving the window: album play, track-row play and skip-next all
log `[Core] track ... served from OFFLINE cache` and decode at 44.1 kHz /
24-bit; a plain user-file album still plays through the local audible step at
96 kHz / 24-bit. qbz-qt 431/431, qbz-source 91/91.

cc
2026-08-20 18:16:33 -06:00
vicrodh 900517db57 fix(qt): size the filter flyout to its content, not to 372px
The Slint original sized a fixed card around THREE source chips. This
port now has five — Plex plus the two media servers — and the row ran
to the card's edge, with the last chip all but touching the border.

The 372px becomes a floor and the width is derived from the widest chip
row plus the 16px padding on each side, capped so the card can never run
past the left edge. That also survives a UI-scale bump or a language
whose labels are longer, neither of which a fixed number can.

`implicitWidth` is safe to measure here: a Row sums its children and a
non-wrapping Text reports its natural width, so nothing in the chain
reads the card width back and there is no binding loop. An invisible
chip contributes nothing, so the card is narrower when a server is not
configured.

cc
2026-08-20 16:38:28 -06:00
vicrodh 021b5609e1 feat(qt): the Albums funnel survives navigation and restarts
The filter worked but forgot itself twice over: leaving Local Library
reset it, and so did closing the app. The first one is not a bug in the
view, it is what this port does — navigating away DESTROYS the view
(project_qt_nav_eager_tabs), so a `property var filter: ({})` cannot
outlive a trip to Discover.

Both follow from one move: the funnel lives on the BRIDGE, mirrored into
the shared ui_prefs.json. The bridge outlives the view, and the file
outlives the process, so one property buys both kinds of persistence.

`setFilter()` is the only writer, so a mutator cannot change the funnel
and forget to persist it, and the view re-seeds from a Connections on
the qproperty rather than only at construction — a republish has to
reach a view that is already open.

The saved funnel is pruned where the source GATES are published, not
somewhere else, so the two can never disagree: a restored `jellyfin`
tick with no Jellyfin configured would match zero rows while the chip
that clears it is hidden by the same gate — an empty grid with a badge
counting a filter the user cannot see. Only the media-server keys are
pruned; local/offline/plex are always meaningful words.

Verified: ticked Jellyfin, went to Discover and back (badge and grid
held), restarted the process (held), pressed Clear (ui_prefs went to
{}). The prune is reasoned, not exercised — both servers are configured
on this box, so no gate is off to test it against.

cc
2026-08-20 16:38:28 -06:00
vicrodh 1a2ebcf146 fix(qt): re-seed a line edit on the CHANGE, never on the focus loss
`QbzLineEdit` restored the owner's value with

    Binding { target: input; property: "text"; value: root.text
              when: !input.activeFocus }

and a Binding re-asserts whenever its condition flips, so BLURRING the
field wrote `root.text` back over whatever had been typed.

A SEARCH field never noticed: it fires `edited()` per keystroke, the
owner republishes, and `root.text` is already what the user typed. A
FORM field is the opposite — it reports on `committed()` and the owner
holds the value until Save — so `root.text` is still the stored value,
which for a password is the literal `""` that is deliberately never
prefilled. Every blur erased the field.

Measured in the media-server panel: username and password wiped each
other, so a Subsonic server could not be signed into by hand at all.
The stored state was in fact correct the whole time — `onCommitted`
fires on focus loss and captures the value before the Binding clears
the view — which is why this survived the Jellyfin round: the form
worked, it just did not look like it.

A republish IS a change to `root.text`; a focus loss is not. Reacting
to the change alone keeps the documented case (an external Reset
re-seeds an unfocused field) and drops the destructive one.

Verified in the window: password survives blurring into the username
box; the Tracks search keeps its query after a blur; the × still
collapses the expandable search with no stale query snapping back.

cc
2026-08-20 15:27:22 -06:00
vicrodh 4ba4d336d0 fix(qt): route every server album key to the local view, not /album/get
`open_album` asks `is_local_feed_id` whether an album id belongs to the
local detail view or to the Qobuz catalog, and that gate's whole
vocabulary was the literal prefix `"plex:"`. A `jellyfin:<itemId>` key
answered NO, went to Qobuz's `/album/get`, 404'd, and the view NAVIGATED
AND LANDED EMPTY — not only from the now-playing bar: every caller of
that router shares the gate (the queue, "go to album" in a row menu, the
Library ALL feed).

None of the other clauses caught it either: a Jellyfin item id is 32-char
hex, so it carries neither `/` nor `|`.

Spelled through `SourceId::from_word` instead of a second prefix list, so
the Subsonic brand spellings come along and the next source does not have
to remember this site. `qobuz:` stays excluded — it is a legal word and a
catalog id.

Verified on the bench: playing a Jellyfin track and clicking the album in
the bar opens KARAOKE FIRE!! with its 17 tracks and the playing row
highlighted.

cc
2026-08-20 14:49:28 -06:00
vicrodh 8b4e0e92b8 fix(qt): treat every server source as non-catalog, not just Plex
Three places still spelled "not a Qobuz row" as "local or plex", which
made a Jellyfin or Subsonic row fall into the catalog class:

- Library ALL feed stamped the album source from the ID PREFIX
  (`plex:`), so every media-server album came out as "local" and drew
  the local hard-drive badge. Reads the row's own `source` word now —
  byte-identical for local and Plex, correct for the rest. The same
  failure `local_rows::badge_source` had.
- TrackRow's `localSourceRow` gated "Add to playlist" and the offline
  download entry. A Jellyfin row counted as a catalog row, so the menu
  offered to send a namespaced synthetic id to the Qobuz playlist API,
  which adds an UNRELATED track. An allowlist, not `!== "qobuz"`:
  "offline" is an offline-CACHED Qobuz row whose id IS a catalog id.
- AlbumCard hid "Block this album" on local/plex only, so a server album
  offered to blacklist an id the Qobuz catalog never had.

`feed_track_to_queue` is taught the words too. Media-server TRACKS do
not ride that feed today (only their albums do), so that one is the
guard learning the vocabulary before it is needed rather than a live
fix — its existing test, which pins that a local id must never resolve
as the Qobuz track carrying the same number, now covers all of them.

cc
2026-08-20 14:12:53 -06:00
vicrodh 13e938727c feat(qt): merge media-server rows into both local search paths
`media_servers_qt::search_tracks` was written with the rest of the stack
and never called — the compiler's dead-code warning was the only thing
that said so. Both places that merge Plex now merge the media servers
too: the Local Library Tracks tab and the cortinilla's local section.

Two asymmetries are deliberate:

- Page 0 only, in the Tracks tab, for the same reason the Plex arm is
  page 0 only: these caches are bounded sets read whole, so re-merging
  them per page would repeat every remote row once per local page.
- BOUNDED in the cortinilla, unlike the Plex arm. The Plex cache is read
  whole because it always was; a media-server mirror can hold 50k rows
  and that path runs on every keystroke, so it takes the same limit the
  caller asked the local query for.

Verified on the bench: "totsugeki" in the Tracks tab returns Jellyfin
rows first, then the Plex ones.

cc
2026-08-20 13:49:45 -06:00
vicrodh 322b9374fb feat(qt): Jellyfin and Subsonic filter chips in Local Library
The Source row of the filter popup offered Local / Offline cache / Plex,
so a media-server library could be browsed but not narrowed — no Plex
equivalent existed for it.

Adds a chip per media server, each gated on a new bridge property so an
unconfigured server never shows a chip that can only ever return zero
rows (Subsonic stays hidden here, Jellyfin appears).

The fold in applyFilter is the part that is easy to get wrong: rows are
stamped with the BRAND the server reports — navidrome, gonic, airsonic,
astiga — while the chip is the PROTOCOL. Without folding those words to
"subsonic", ticking any source filter would silently drop every row of a
Subsonic library instead of filtering it.

Verified against the live Jellyfin bench: with the chip on, the grid
holds only Jellyfin-badged albums.

cc
2026-08-20 13:49:37 -06:00
vicrodh df409f6c3c fix(qt): resolve media-server art tokens before qbz-models sees them
`qbz-models` is the base of the dependency graph and cannot reach
`qbz-source`, so `artwork_ref_with_plex` knows exactly one remote word:
"plex". A `jellyfin:<albumId>/<tag>` token fell through to its LocalFile
arm, which handed MPRIS a file:// URI to a path that does not exist —
the bar and the queue were fine (they resolve through the Qt art cache),
only MPRIS was blank.

Resolve source-tagged tokens in the Qt layer, where `qbz-source` IS
reachable, and hand qbz-models an already-resolved local path.

Verified over the session bus: a Jellyfin track now reports
mpris:artUrl -> a real 256x256 JPEG in the image cache.

cc
2026-08-20 13:49:26 -06:00
vicrodh d5ce8bcaeb fix(qt): offline-cache rows, Jellyfin playback, and media art on the bar
Three defects the owner found by using it, all mine, all from the same
root: a source word that stops at Plex.

1. AN OFFLINE-CACHE ROW PLAYED NOTHING.

     audible: file not available at …/audio/tracks-cmaf/426056576

   `SourceId::from_word` folds `qobuz_download` into LOCAL because the row
   IS a file — but for a CMAF-cached download that "file" is the offline
   cache's own container and only `qbz-core`'s offline tier can read it.
   IC-12's first pass declared those rows ours and handed them to the
   local audible step. They now stay on the Qobuz funnel, which is where
   they were before stage 3 and where their `QueueTrack.id` — the real
   catalog id — is meant to be resolved. The symptom was the bar, the
   queue and MPRIS all changing while the previous track kept playing.

2. A JELLYFIN ROW CHANGED THE BAR AND PLAYED NOTHING.

   `local_queue_track` folded everything that was not Plex or a download
   into "local", so the row entered the queue wearing a source word that
   disagreed with its namespaced id and carrying no server id at all.
   Both media words are now arms of their own, and the hint carries the
   SERVER's item id — which `claim` prefers, because it survives a cache
   rebuild that renumbers rowids.

3. MEDIA ART NEVER REACHED THE BAR, THE QUEUE OR MPRIS.

   Those surfaces resolve an `artwork_url` string with no row in hand, so
   `classify` has only the SHAPE to go on — and a Jellyfin image tag or a
   Subsonic coverArt id has no shape worth reading. `local_queue_track`
   now stamps the token SOURCE-TAGGED (`jellyfin:<albumId>/<tag>`) and
   `classify` grows one arm that DELEGATES it to
   `registry().artwork_token` instead of guessing. One arm, not 111 call
   sites, and it shares its cache entry with the grid because it is the
   same call.

Verified in the running window: a Jellyfin album streams and plays —
`[JELLYFIN/STREAMING] 331 MB @ 12 MB/s`, decoder up, 44.1 kHz to the DAC.

429/429.

cc
2026-08-20 12:57:36 -06:00
vicrodh a3aff5f1b3 fix(qt): resolve artwork per VISIBLE row again, not per row of the document
The albums grid got slower and the owner was right about it. This is the
cause and it is mine.

Stage 4 moved the artwork decision from window time to MAP time so that a
row's provenance would still exist when it was made. That fixed bug 3 and
introduced a worse one: a remote source answers "what is this token" by
reading its CREDENTIALS, and the map runs over every row of the
document, not over the ~50 on screen. `local_plex::settings()` is a
`load_last_user_id` file read, a mutex and a SQLite query — per Plex row.

Measured on the owner's library, release, same grid each time:

  before stage 4 (a string clone)      ~2 µs/row
  after  stage 4 (resolve per row)   39-92 µs/row   <- the regression
  + memoised settings                  ~9 µs/row
  + resolve at window time again      ~1.8 µs/row

  1703 rows: map 67-71 ms  ->  3.0-3.7 ms

The art index now holds `(SourceId, raw token)` instead of a resolved
`ArtRef`. Provenance still survives — nobody sniffs a string, which was
the whole point — but the resolution happens where it always did, in
`resolve_window_blocking`, over the keys actually on screen. Same
information, right place.

The settings memo stays, in both `local_plex` and `media_servers_qt`:
those accessors are cheap gates that several callers ask repeatedly, and
every writer invalidates so a connect, a disconnect or a user switch is
still seen on the next read.

`search_local` keeps resolving inline on purpose — it is the cortinilla's
bounded 76-row result set, and the url/path split it needs is per row by
definition.

428/428. Grid verified in the running window: Plex, Jellyfin and local
badges all correct, covers resolving.

cc
2026-08-20 12:12:22 -06:00
vicrodh f2d8111721 perf(qt): instrument the albums load — sql, map, serialize
The albums document is republished on every mount of Local Library (five
times in a 37-second screencast) and there was NO instrument anywhere on
that path, so "the grid got slower" could only be argued about rather
than measured.

Three segments, because three different things could cost: the SQL (which
now ATTACHes two mirrors and UNIONs three sources), the row mapping
(which resolves an ArtRef per row since stage 4), and the JSON handed to
QML.

Measured on the owner's library, release build:

  1703 rows (local + Plex + Jellyfin)  sql 59-88ms  map 67-71ms  ser 2ms
  1271 rows (local + Plex)             sql 42-88ms  map 50-117ms
    29 rows (local only)               sql 3-7ms    map 0.06ms

The two large configurations overlap within noise: 432 more rows cost
nothing measurable in Rust, and serialisation of 593 KB is 2 ms. Whatever
the grid pays for row count is paid AFTER this line — the QML parse and
the delegate rebuild — which is exactly what Rust could not see before
and what these numbers now bound.

cc
2026-08-20 11:25:44 -06:00
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