Commit Graph
100 Commits
Author SHA1 Message Date
vicrodh bae1523abf fix(qbz-qt): finish compact album header layout
cx
2026-08-25 23:58:45 -06:00
vicrodh 6dd0f8da7f fix(qbz-qt): preserve listen list timeline
cx
2026-08-25 23:38:08 -06:00
vicrodh d0654f77bd fix(qbz-qt): hide native scopes on software renderer
cx
2026-08-25 23:17:33 -06:00
vicrodh ae175f3403 fix(qbz-qt): retry queue artwork misses
cx
2026-08-25 22:56:10 -06:00
vicrodh 24d778e9cf fix(qbz-qt): decode local artwork file URIs
cx
2026-08-25 22:27:27 -06:00
vicrodh 2d31244a7a fix(qbz-qt): refine compact album header layout
cx
2026-08-25 22:04:35 -06:00
vicrodh aadad1da54 feat(qbz-qt): restore offline and logged-out scrobbling
Port the Tauri immediate and accumulated offline policies into the active Qt fire path, retain independent scrobbler sessions across Qobuz logout behind a default-on opt-out, and flush queued Last.fm/ListenBrainz plays only when the effective policy permits it.

cx
2026-08-25 21:21:37 -06:00
vicrodh 1517b4c567 feat(qbz-qt): add global compact album headers
cx
2026-08-25 20:50:39 -06:00
vicrodh 1f15f988cd fix(qbz-qt): preserve artist sidebar choice when resized
cx
2026-08-25 20:36:57 -06:00
vicrodh 908fdf13b4 fix(qbz-qt): collapse album sidebar when constrained
cx
2026-08-25 20:26:40 -06:00
vicrodh 82953c72da fix(qbz-qt): honor system font with script fallback
cx
2026-08-25 20:15:38 -06:00
vicrodh 10869f76a7 fix(qbz-qt): darken ambient artist jump bar
cx
2026-08-25 19:53:12 -06:00
vicrodh 8c31fd4ab0 fix(qbz-qt): keep library toolbar responsive
cx
2026-08-25 19:53:07 -06:00
vicrodh 40180fca9a fix(qbz-qt): reconcile local favorite album lifecycle
Real-library smoke pending: the original orphaned favorite fixture was removed before verification.

cx
2026-08-25 19:53:02 -06:00
vicrodh ecae778d58 fix(player): preserve audible state on source failures
Preflight fallible queue transitions and publish now-playing state only after the requested source is audible, leaving the prior stream intact when a file or server is unavailable.

Carry source-owned request headers through remote probes, streaming and fallback downloads without exposing sensitive values.

cx
2026-08-25 18:26:35 -06:00
vicrodh d244213a64 fix(library): harden cross-source catalog data
Preserve per-disc and collection artwork, multi-value genres and remote metadata across Local, Plex, Jellyfin and Subsonic.

Apply source, format and quality filters before pagination, reject invalid CUE rows, and keep Qt library surfaces aligned with the selected physical version.

cx
2026-08-25 18:26:06 -06:00
vicrodh 1bd99aed67 fix(ui): finish pending Qt interaction polish
Keep segmented controls within their visual well, use the active theme for playback indicators, disconnect inactive pulse receivers and improve near-fit song metadata.

cx
2026-08-25 18:25:05 -06:00
vicrodh 52fd887f15 fix(navigation): restore routed view state
Persist view-owned search, sort, filter and tab state alongside scroll positions across back and forward navigation.

cx
2026-08-25 18:24:35 -06:00
vicrodh e0dc288659 perf(library): virtualize large album details
Render only the visible track slice for expanded box sets and share physical version caches without cloning every source row.

cx
2026-08-25 18:22:32 -06:00
vicrodh 720e6d33de style(rust): normalize pending formatting
Apply rustfmt consistently to the live Qt frontend and shared audio support modules without changing behavior.

cx
2026-08-25 18:22:05 -06:00
vicrodh 9c22a909d6 feat(library): add Library Explorer
Add the chained genre, artist, and album browser with persistent layout and tab ordering. Reuse the shared album semantics for grouped versions and disc-aware details, enforce active source filters, and bound foreground detail work so chained searches stay responsive.

cx
2026-08-25 17:21:07 -06:00
vicrodh dc0a37487b fix(plex): restore original media streaming
Request Plex media parts with download=1 so servers that reject the bare part route still return ranged original bytes. Preserve existing query parameters and cover both direct resolvers with a regression test.

cx
2026-08-25 15:03:31 -06:00
vicrodh a92dafa734 fix(player): finalize lyrics and park waveform
Hide the waveform controls and force every now-playing bar back to the classic seekbar while preserving the implementation for a later release. Keep full-screen timed lyrics centered, slightly larger and wrapped without changing the approved typography or fill.

cx
2026-08-25 10:17:43 -06:00
vicrodh 9b4bb865bf fix(player): refine immersive lyrics and seekbars
Reuse the split lyrics renderer for full-screen timed fill, add a contrast shadow and expose the immersive song card there. Render the compact seekbar as a stable three-sine motif and make the larger waveform use normalized rounded capsules with stronger peaks.

cx
2026-08-25 09:38:43 -06:00
vicrodh 5501221fdc fix(visualizer): refine scopes and seekbar
Make immersive scopes responsive and derive readable colors from album artwork. Use distinct full-size bar and compact contour renderers for the seek waveform.

cx
2026-08-25 08:21:21 -06:00
vicrodh 88e776fcdb refactor(ui): rename the mini queue action
Keep the mini-player shortcut label aligned with the Listen list surface name.\n\ncx
2026-08-25 02:53:48 -06:00
vicrodh dcf4509aa0 refactor(ui): rename the queue view
Present the expanded queue surface as Listen list across its header, launcher, and supported translations.\n\ncx
2026-08-25 02:52:11 -06:00
vicrodh f56cd00244 fix(player): refine seekbar waveform rendering
Replace the continuous strip with symmetric gradient bars, periodic spacing, and a persistent centre rail. Use more of the available seekbar height while preserving played and cached clipping in the native scene graph.\n\ncx
2026-08-25 02:51:41 -06:00
vicrodh ef7ea217c8 fix(visualizer): improve stereo scope rendering
Render the oscilloscope as a filled antialiased trace and the goniometer as a persistent point cloud with robust autoscaling. Keep the animated scene graph native and the instrument guides static to avoid the previous low-impact line rendering.\n\ncx
2026-08-25 02:51:31 -06:00
vicrodh 6046a959c1 fix(player): skip zero-frame decoded packets
Ignore empty Vorbis and Opus packets before Symphonia interleaving so a decoder edge case cannot panic the CPAL output thread and kill audio for the session.

cx
2026-08-25 02:30:08 -06:00
vicrodh b7918a74e0 fix(player): export waveform track metadata
Expose the analyzer boundary payload from qbz-audio so the player crate can build against the committed waveform API.\n\ncx
2026-08-25 01:18:39 -06:00
vicrodh 0a3bdc3ec8 feat(player): add seekbar waveforms
Analyze audible tracks into a progressive 512-bin RMS document, cache complete results, and preserve gapless track boundaries. Render the waveform through a scenegraph item in both player bars behind a default-off preference.\n\ncx
2026-08-25 01:16:58 -06:00
vicrodh d11e946227 feat(visualizer): add stereo scopes
Add a theme-aware goniometer and pitch-locked oscilloscope to the large dock and immersive focus modes.

Gate scope DSP and scenegraph updates by actual panel visibility so hidden modes remain idle, including while shader scenes cover immersive panels.

cx
2026-08-25 00:22:40 -06:00
vicrodh 8fd962284e fix(sync): serialize shared media cache writes
Prevent parallel Jellyfin, Subsonic, and quality hydration transactions from colliding on the shared SQLite cache during Resync all. Keep WAL reads concurrent and retain a bounded timeout for external writers.

cx
2026-08-24 23:19:55 -06:00
vicrodh 635b8b3a45 fix(metadata): restore editor mounting
Remove an unsupported shared-button property that invalidated the
workspace type lazily in both AlbumView and ephemeral surfaces.

cx
2026-08-24 22:00:22 -06:00
vicrodh 5e759fab50 refactor(metadata): split editor workspace
Give track editing a dense spreadsheet pane, move album tags to an independent sidebar, and isolate remote metadata and artwork lookup in a loading-aware popup.

cx
2026-08-24 21:37:11 -06:00
vicrodh fa649394d1 fix(metadata): scope track row selection
Bind nested editor controls to the delegate index so virtualized rows never resolve an undefined context property.

cx
2026-08-24 20:44:13 -06:00
vicrodh 284db1bfd5 chore(i18n): translate local library workflows
Cover the metadata editor, genre browsing, local tab ordering, and related validation feedback across all eight maintained locales.

cx
2026-08-24 19:47:21 -06:00
vicrodh 75a668ed12 feat(metadata): expand the local tag editor
Promote metadata editing to a routed full-page workflow with tabular per-track fields, ordered artist credits, provider identifiers, and compilation handling.

Add bounded artwork selection and retrieval through MusicBrainz, Discogs, and Last.fm. Direct and sidecar saves preflight immutable rows, verify rich tag writes, cache covers by content, and update exact database ids transactionally.

cx
2026-08-24 19:46:59 -06:00
vicrodh ed8d11e5c8 feat(library): expose catalog recovery guardrails
Add deterministic end-to-end recovery measurements for the production legacy projector at 20k and 200k rows. Validate album and artist FTS through reconciliation updates and deletions, and provide a read-only runtime integrity probe for active generations.\n\nPublish catalog generation, size, row count, integrity state, and effective surface routes in diagnostics while logging native versus legacy loads with their fallback reason.\n\ncx
2026-08-24 18:28:08 -06:00
vicrodh 5402f36992 test(library): cover every catalog track index
Extend the existing EXPLAIN plan guard to every directional TrackSort index.

cx
2026-08-24 17:44:04 -06:00
vicrodh 5a14eef258 fix(metadata): complete local editor sessions
Consume preflight data when loading clears so the modal cannot spin indefinitely.

Reuse the editor for ephemeral folders and bind sidecar or direct writes to the exact active session.

cx
2026-08-24 17:28:54 -06:00
vicrodh 0e53837582 feat(metadata): restore local tag editor in Qt
Restore the selected-version editor with sidecar-safe defaults, inspected tag layers, guarded MusicBrainz and Discogs lookup, and confirmed verified direct writes. Keep paths Rust-owned and reject stale or injected drafts.

cx
2026-08-24 16:41:06 -06:00
vicrodh d81f69113e fix(metadata): verify canonical tag writes
Inspect every tag layer before editing, target the container's canonical writable tag, and read values back before reporting progress. Preserve secondary layers by default, support explicit ID3v2.3 compatibility and synchronize compatible modern layers only when requested.\n\nMake sidecar clears persist across rescans while preserving omitted-field compatibility.\n\ncx
2026-08-24 16:33:40 -06:00
vicrodh 2c6bd510a9 fix(library): make explicit server resync authoritative
Treat Local Library resync actions as complete source reconciliations so provider IDs removed or reminted by Jellyfin are pruned only after a successful sweep.

Add regressions for a fully remapped Jellyfin identity set and for the QML resync contract.

cx
2026-08-24 15:36:32 -06:00
vicrodh d627560f4f fix(library): unify album versions across sources
Associate physical copies only when normalized album identity and strong track-title/duration overlap agree. Keep every source visible on one card and resolve each authoritative album id into the existing version cache.

Order versions by delivered quality and then track count, expose inferred edition labels and full format metadata, and keep source filters aware of every represented copy.

cx
2026-08-23 21:56:38 -06:00
vicrodh b4744b6e7f fix(playlists): open mixed playlists offline
Route numeric playlist details through persisted membership while offline. Resolve ready Qobuz downloads from the shared cache, append local and reachable Plex sidecars in stable order, and keep the page read-only without network access.\n\ncx
2026-08-23 20:59:25 -06:00
vicrodh 390a800545 fix(ui): cap now playing cards at eighty percent
Apply one shared runway fraction to the SongCard in New, Classic, Large, and Small modes while preserving each mode's alignment.

cx
2026-08-23 20:36:52 -06:00
vicrodh 63ca21abaf fix(library): paginate grouped Tracks globally
Order every source by the active grouping key before the bounded merge so later pages preserve the visible prefix. Remove the accumulated QML reorder that moved the viewport when new rows sorted ahead.

cx
2026-08-23 19:30:24 -06:00
vicrodh fcb9851736 fix(library): append Tracks pages without model reset
Keep one compatibility ListModel mounted and publish stable page prefixes through rowsInserted notifications. Preserve semantic anchor restoration only for grouped reorders so ordinary lazy pagination cannot move the viewport.

cx
2026-08-23 18:44:13 -06:00
vicrodh 1244687ab9 refactor(library): remove obsolete artist matcher wrapper
Keep the default alias-free matcher inside the test module now that production artist detail always uses corpus-derived family aliases. This removes a release-only dead-code warning without changing matching behavior.

cx
2026-08-23 18:02:58 -06:00
vicrodh f8092db81a fix(library): retain Tracks anchor through model publish
Capture the visible semantic anchor before requesting the next compatibility page and retain it across bridge signal ordering and the ListView polish pass. This prevents the loading latch from dropping early and avoids recursive page requests during viewport restoration.

cx
2026-08-23 17:50:09 -06:00
vicrodh 4a2f4bdc02 fix(library): canonicalize fallback artist families
Collapse repeated collection-and-contributor credits in the legacy Artists reader while retaining isolated collaborations. Preserve Unicode artist identities, resolve the collapsed row back to every family album, and use lexical QML timer ids so query coalescing remains live.

cx
2026-08-23 17:49:50 -06:00
vicrodh 6bbafb4f69 fix(library): collapse repeated artist credit families
Derive one canonical artist identity when an album-artist prefix repeats with multiple bullet-separated contributors, while preserving source-faithful credits and one-off collaborations. Rebuild schema-v4 catalogs side-by-side so existing installations receive the corrected artist rail without a rescan or in-place database migration.

cx
2026-08-23 17:11:53 -06:00
vicrodh 3ed48163aa fix(library): correct Tracks viewport restoration
Restore the semantic anchor in viewport coordinates and suppress lazy-page requests while the ListView is being repositioned. This avoids treating content coordinates as screen offsets and prevents recursive page loads during restoration.

cx
2026-08-23 16:58:16 -06:00
vicrodh f83e469fd9 fix(library): preserve Tracks viewport on paging
Anchor compatibility page updates to the first visible track and its partial-row offset, then restore after Qt finishes replacing the model. Invalidate deferred restores when a newer document wins.

cx
2026-08-23 16:20:18 -06:00
vicrodh 2c3dc2b29b fix(library): continue loading Tracks pages
Use origin-relative ListView scroll coordinates and terminal signals so compatibility pagination cannot stall after model replacement. Keep a manual Load more fallback and collapse duplicate page requests before Qt publishes busy state.

cx
2026-08-23 15:42:34 -06:00
vicrodh 23a4eb2912 feat(library): persist SACD image tracks
Adopt fully parsed SACD generations atomically into library.db and preserve local row IDs across image moves. Keep failed reads non-destructive, reject stale metadata writes, and project the virtual tracks through the existing local catalogue.

cx
2026-08-23 15:14:58 -06:00
vicrodh 6005510bf7 feat(library): sync Subsonic pages by generation
Project Search3 and per-album results into atomic source-generation pages, authorizing prune only after a complete enumeration.

Reject malformed authoritative responses, invalidate late writes across cancellation and profile changes, and sanitize secret-bearing transport errors.

cx
2026-08-23 14:37:45 -06:00
vicrodh 8fff37a8ef feat(library): hydrate Jellyfin quality in background
Publish bounded essential metadata pages through source generations, preserving delta sync and authorizing prune only after a complete full pass.

Hydrate MediaSources in persistent prioritized batches with cancellation epochs, startup resume, bounded retries, and coalesced catalog catch-up.

cx
2026-08-23 14:24:16 -06:00
vicrodh 6e0e0d3165 feat(library): sync Plex sections incrementally
Fetch Plex tracks in bounded container pages and commit each page with its per-section generation checkpoint. Resume interrupted sections, preserve hydrated quality, and authorize stale-row pruning only after the declared total is complete and identity-exact. Sanitize HTTP failures so authenticated request URLs never reach logs.

cx
2026-08-23 13:45:05 -06:00
vicrodh 480b4b46df feat(library): scan local roots incrementally
Stream each registered root into bounded extraction batches, persist fingerprint and checkpoint generations, and prune only after a complete reachable pass. Add local watcher hints plus separate periodic local and network reconciliation.

Preserve CUE identities, retry unreadable files, and keep cancellation, traversal errors, offline roots, and overlapping roots from authorizing destructive cleanup.

cx
2026-08-23 13:12:48 -06:00
vicrodh 6fd83c42df feat(library): page Artists from local catalog
Materialize source-scoped artist aggregates and FTS, then expose exact keyset pages and bounded A-Z metadata for the existing Artists rail.

Query the selected artist's albums through the indexed credit relationship with an independent bounded model, generation cancellation, artwork updates, and per-surface legacy fallback.

cx
2026-08-23 12:17:08 -06:00
vicrodh 299dcde5b4 feat(library): page Albums from local catalog
Materialize album browse fields and FTS in the derived catalog, then expose exact counts and stable keyset queries for the current filters, sort, grouping, and enabled sources.

Bind the existing Albums layout to a bounded native Qt model with asynchronous page misses, anchors, selection ranges, generation cancellation, artwork updates, and per-surface fallback. Resolve Plex native edition keys directly and retain the metadata-mode legacy reader.

cx
2026-08-23 10:54:31 -06:00
vicrodh 1bb3c41dcf feat(library): page Tracks from local catalog
Add an opt-in QAbstractListModel backed by keyset pages from the derived catalog, with bounded residency, global query semantics, source-aware actions, and per-session fallback to the existing reader.

Wire profile-scoped bootstrap locations and refresh the native surface only after a complete catalog is active.

cx
2026-08-23 09:37:15 -06:00
vicrodh 7689fb0df6 feat(library): reconcile catalog by source
Clone the active generation for bounded source reconciliation, advance watermarks only after complete observation, and prune only the completed source.

Coalesce catch-up after authoritative local and media-server writes while retaining the last complete generation on failure.

cx
2026-08-23 07:53:20 -06:00
vicrodh d39edf5850 feat(library): bootstrap derived catalog
Build the catalog side by side from read-only local, Plex, Jellyfin, and Subsonic cache snapshots after the first useful Qt frame.

Persist bounded batch checkpoints, preflight disk capacity, validate materializations, and activate generations atomically while preserving the existing reader as fallback.

cx
2026-08-23 07:23:23 -06:00
vicrodh a0d8a7fc94 feat(library): add derived local catalog
Introduce a frontend-agnostic generation schema with stable source identities, materialized album and artist structures, trigram FTS, matching sort indices, and descriptor-bound keyset queries.

Add deterministic SQLite coverage and an explicit one-million-track query gate.

cx
2026-08-23 06:39:00 -06:00
vicrodh 1edae11e52 fix(library): correct cross-source pagination
Bound each enabled source to an independent candidate page and merge one stable global Tracks page while advancing only consumed offsets. Include remote counts and artists, preserve Plex rows past 5,000, and reject stale search or sort generations before state and UI publication.

cx
2026-08-23 05:31:35 -06:00
vicrodh 4167f3d104 fix(home): refresh recent rails on playback
cx
2026-08-22 23:09:11 -06:00
vicrodh b03155fc2f fix(player): reduce classic song card width
cx
2026-08-22 23:09:11 -06:00
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