99722aedaf2edcf0408d9ed159447013f55f10b8
6233
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
99722aedaf |
feat: an unreachable track can no longer stop playback
Two owner requirements, one shape: something that cannot play must be skipped, and cleaned when there is nowhere left to skip to. The causes are outside our control — Qobuz withdraws a track overnight, or a local/Plex file sits on a share this network cannot see today. THE FUNNEL, which is where the real gap was. `play_current_if_local` returned a bool whose two `false`s meant opposite things: "not my row, run the Qobuz path" and "my row, and it cannot play". The caller could only act on the first, so an unreachable local file fell through to the Qobuz tier-walk, failed there with an unrelated error, and `auto_skip_unavailable` then declined it as non-terminal. One dead file stopped playback silently. It is now a three-state `LocalPlay`, and a local failure takes the SAME bounded walk a pulled Qobuz track takes — to the user they are the same event. CLEANING. Both dead ends of that walk (budget spent, queue edge) now call `purge_unavailable_upcoming`, which drops only rows `queue_track_unavailable` already refuses to play, walking backwards so a removal cannot shift an index still to be visited. A track on an unreachable share can never land there: it is Unreachable, not dead. PROBES THAT CANNOT HANG (`qbz-library/src/reachability.rs`, 4 tests). The machinery for network shares already existed — mount_info, `library_folders.is_network`, a populated `local_tracks.is_network_mount`, even an unreachable-folder guard in scan.rs. Every one of them probed with a bare blocking syscall, and a mounted-but-unreachable share does not make `exists()`/`read_dir()` fail, it makes them BLOCK for the mount's timeout. The guards could not fire; scan.rs hung on the very condition it existed to detect. Three states, not two, and that distinction is the whole safety property: `Missing` is the filesystem saying the file is gone and may be cleaned; `Unreachable` is it saying nothing, and may only ever be skipped. Collapsing them — which a bare `exists()` does — would delete a user's NAS library because they changed networks. The deadline goes on the PROBE only, never the transfer: a hi-res FLAC over a working-but-slow share legitimately takes longer than any probe budget, and timing the read out would turn "your network is slow" into "this track is gone". A blocked syscall cannot be cancelled, so a timed-out probe leaks its thread; the mount is therefore remembered with a cool-down, one probe per dead mount rather than one per track, which on a 2000-row queue is the difference between a defence and a thread bomb. PERSISTENCE. `busy_timeout=1000` on all 18 store pragmas. Deliberately NOT the 2-3s discussed: these writes run on the Qt thread, so a 3s wait would install exactly the stall this whole sweep has been removing, while today's 0 means contention loses a setting silently and instantly. One second resolves transient contention without a visible stall. `home_bridge::ui` announces both of its silent failure modes. Three builds went into proving it was NOT dropping the Home document during the launch freeze; one warn line would have answered it in one. Verified against the owner's real session state (1935-row queue): the app launches to a painted Discover with no QML errors and no dropped publishes. cc |
||
|
|
47a7641fc2 |
fix(qt): bound the queue panels, and make the gate catch what it missed
Follow-up to
|
||
|
|
0ed9db2f3f |
fix(qt): stop the Weekly rows rebuilding the whole Recommendations tab
Reported as "it loads, then it flashes and loads again", and only on that tab. Measured: the cached blob paints at t+0 and the two Weekly rows land at t+0.9s — and that is with BOTH of their per-week caches hitting, so the delay is ListenBrainz's playlist listing, not the tracks. Their arrival called publish_snapshot, which republishes the document, which hands QML a brand-new array; QQuickItemView::setModel() then tears down every rail's QQmlDelegateModel and rebuilds it. Nine rails destroyed and rebuilt to deliver two, a second after the tab had already settled. Not the artwork, which was the obvious suspect: the same run logged "0 artwork misses". Split them out the way HomeView already splits pinned / radio / spotlight, for the same reason and with the same shape: the document carries an ordering slot, the rows ride reco_weekly_json, and rebinding that re-creates only those two delegates. Every other rail's sectionData resolves to the same modelData reference, so a Weekly landing re-evaluates nine bindings and rebuilds two. The slots are emitted unconditionally (a new `slot()` helper) rather than self-hiding on emptiness like `section()` does — a slot that vanished while empty and reappeared when filled would change the document, which is the republish being avoided. QML hides an empty one instead, on the gate the other out-of-document rails already use. Weekly covers are attached in the same split, so a late cover republishes that property rather than the document: two rails, not nine. cc |
||
|
|
19836547d9 |
feat(qt): give Recommendations its cache window and Refresh now
The gear was dead on that tab and the cache window was unreachable, so whatever hours value happened to be in the shared prefs was permanent. It reads 24h on this account; the reference offers 24/36/48/72 and defaults to 48. Only the WRITER was missing. `reco_cache_ttl_secs()` already read `reco_cache_ttl_hours` from the shared per-user discover prefs, and `refresh()` was already written — and carrying `#[allow(dead_code)]` because nothing called it. TTL_HOURS is the reference's table verbatim (discover_prefs.rs:100). That matters because the value lands in the file BOTH builds read: if the two disagree on the index mapping, a window chosen in one reads back as a different one in the other. The QML option order is the other half of the same contract, noted at both ends. The setter republishes the RESOLVED index rather than the one it was handed, so a failed write (no per-user store) snaps the select back to what is actually stored instead of showing a window nobody saved. The index is seeded at shell entry next to publish_settings(), the first point where the per-user directory is known — without it the select would open on the store's 48h default and misreport anyone who chose otherwise. The modal now has two modes on the same six blocks the reference gates (DiscoverConfigModal.slint:187/213/221/250/261/326): section tabs keep the list, banner, count and reset footer; Recommendations hides those and shows the explainer, the window select and Refresh now. Its empty-state line is hidden there too — DiscoveryTab has no "recommendations" arm, so an empty row list is by design and "No sections to configure yet" would be a permanent lie. All seven strings already existed translated in all eight catalogs. Two comments removed on the way: both claimed the external reco engine was "not ported" and justified the disabled gear with it. It has been ported for a long time (recommendations_qt.rs, 745 lines) — the fossil was the only thing still keeping the setting out of reach. cc |
||
|
|
ccd5272cc3 |
feat(qt): let a nav-bench hop name its landing tab
`library:all`, `local:tracks`. A tab switch inside a mounted view changes no route, so the route stopwatch never fires for it — hopping away and back with a landing tab is what puts a given tab's cost on the clock, which is how the Library grid/list measurement was taken. It also drives the nav-flyout entry path (navigate_to_tab) rather than the plain sidebar one, so the gate now exercises both. cc |
||
|
|
aa2f0362d9 |
perf(qt): stop Library building the body it is not showing
The grid and the list are siblings, both anchored to fill the content
area, and both bound to `visibleRows` — and `visible: false` stops a
QQuickItemView from painting, not from refilling. Measured with a
per-view creation counter: on Albums the grid built 51 delegates and
the hidden list built 47 beside it; on Tracks the list built 47 and the
hidden GRID built 51, the expensive direction since a FeedGridCell
carries a cover. A mount went 368ms -> 216ms, to-idle 727ms -> 441ms.
The predicates above them already claimed this ("one predicate per
body, so no two can mount at once"); they only ever governed painting.
Gated by collapsing the height rather than by swapping the model: this
file spends a paragraph on why handing `model` a new value is harmful
(QQuickItemView::setModel resets the scroll offset and rebuilds every
delegate — the "a heart click in All throws me back to the top" bug),
and the height gate leaves `model` untouched.
Also: the derive and the feed parse are now timed under the shared
qbz.nav.timing category, deferred through Qt.callLater. The defer is
required, not tidiness — `visibleRows` is evaluated during creation,
and a LoggingCategory used before the component completes never gets
its defaultLogLevel applied, which Qt warns about and which left the
logging ON for every user. The parse log was previously a bare
console.log doing exactly that.
Measured for the record: the derive is NOT the cost. Over a 354-item
feed it runs in 0-13ms, so the O(n log n) toLowerCase in its sort
comparator is a real but currently theoretical problem.
cc
|
||
|
|
66ba12bd53 |
fix(qt): stop a long queue freezing the app at launch
A session with 1935 persisted queue rows came up frozen for ~28s at
120% CPU, every launch, deterministically. It looked like corrupted
local state — moving ~/.local/share/qbz aside "fixed" it — but nothing
was corrupt: that is simply a queue the UI could not carry.
The chain. AppShell mounts ImmersiveView unconditionally (its own
comment: while closed it is "an invisible, non-interactive Item"), and
`visible: false` stops an item painting, never building. CoverflowPanel
reads the flat WHOLE-queue document — unlike the queue panel's, which
paginates at 40 — and runs two Repeaters over it, the forward list and
a reversed copy. So every launch built ~4000 CoverCards, each an image
with a masked layer, for a panel nobody had opened.
Two gates, because either alone leaves it broken:
- CoverflowPanel and both QueueTabsPanel mounts move behind a Loader
keyed on the condition they already carried in `visible:`. That is
the launch freeze.
- The coverflow delegates become zero-size, non-clipping slots whose
CoverCard exists only within |offset| < 6.5 (the fan paints 3.5;
the margin covers the 320ms ease). That is opening it, which the
Loader alone would still freeze.
The reference does create one element per row and hides |offset| > 3
(CoverflowPanel.slint), and that is fine there: a Slint element is
cheap where a QML delegate is not. Copying its shape literally is what
cost the 28 seconds.
Verified against the real session state, queue restored, not a
synthetic one: 120% CPU -> 2.9%, and a screenshot of Discover fully
painted where it used to sit on the skeleton.
cc
|
||
|
|
a6b373cb5a |
build(qt): fail the build on a tab body that is built while hidden
The class the previous commit fixed leaves no trace a reader would notice: the view renders correctly, cargo is happy, both existing audits pass, and the only symptom is that entering the section takes seconds. So it needs a static gate or it comes back. qml_eager_tab_audit.py reports a per-tab body gated only on `visible:`, and reports it only when it is genuinely heavy — it must instantiate a Repeater/ListView/GridView AND that view's model must be data rather than an inline literal. Without the second condition it flagged the sort popups in LibraryToolbar, which are Repeaters over four fixed options, and three false positives would have buried the one real finding. cc |
||
|
|
a4c29c796c |
perf(qt): build the tab that is showing, not all of them
Entering Discover froze the UI thread for 1.52s — not slow, frozen:
zero pixels changing anywhere in the window — where the Slint build
painted the same page, artwork included, in a single frame. Measured
frame by frame from a screen recording, with the click located at the
moment the nav flyout disappears.
`visible: false` hides an item; it does not stop QML from building it.
HomeView declared its four Discover tabs as sibling Columns gated only
on `visible:`, so every mount instantiated all four and 19 of its 28
section rails belonged to tabs nobody was looking at. Gating each body
on a Loader's `active:` cut a Home mount from 426-512ms to 182-190ms,
and the tab block itself from 364-444ms to 134-140ms.
Synchronous, deliberately: incubation is time-sliced, so `asynchronous`
spreads the same work over more frames instead of removing it.
Three more of the same class, found by auditing for it:
- SlimGrid's delegate Loader had no `active:`, so it built a full
card for the items past `total` and then hid them.
- LocalLibraryView mounted all four tab bodies, the Tracks one being
the 16K-row view.
- AlbumInfoModal built a credits row per track of the album even when
the Description tab was showing.
ContentRouter's header claimed the fix was to keep views alive across
navigation. It is not, and the references say so: Slint destroys its
view tree with `if` (AppShell.slint:426) and Tauri removes it from the
DOM (+page.svelte:6286), and Tauri even refetches from the network on
mount while still transitioning instantly. Neither gets its speed from
caching the view — both keep the mount proportional to one viewport.
The rule is the SIZE of a mount, not its lifetime. Paragraph rewritten
to say that.
cc
|
||
|
|
8015439304 |
feat(qt): drive navigation without a GUI, and time what a route costs
The cost of a route change is wall-clock on the UI thread, so no cargo test and no static audit can see it: measuring it needs the real app to actually navigate. QBZ_QT_NAV_BENCH=home,library,home walks a hop list after the first home publish, which paired with the qbz.nav.timing probes prints what each mount spent. It also mounts views the offscreen gate never reaches. That gate keys off QbzShell.currentView and nothing navigates during it, so it only ever instantiates the startup view — which is how two entirely unloadable modals and a live ReferenceError once survived fifteen parity rounds. cc |
||
|
|
e779b6ec1e |
fix(qt): a pulled track's menu offers nothing that leads nowhere
Smoke found the row rendering correctly and the menu still offering four entries that cannot work on it. Add to mixtape and Add to playlist are refused, in both row implementations. The precedent was already there -- `hasPlaylistAddSeam` excluded a broken local ref for the same reason -- this is the same idea with a different cause. Gated on `pulled` rather than `pulledDead`, so a downloaded copy is refused too: what a playlist or mixtape stores is the Qobuz catalog id, which is dead on every other device, every other surface and for anyone the list is shared with. The local copy playing here does not make the row safe to seed elsewhere. Both share links go too. Qobuz's page for a withdrawn track is gone and Song.link resolves through it, so each one rendered and led nowhere. Track info as well: it needs the catalog record the pull took away. That empties the menu on a surface that does not offer the repair -- an album page, search results -- so the row menu no longer opens when its model comes back empty. An empty popup reads as a broken control rather than as "there is nothing to do with this row". The ArtistView fork keeps its go-to entries and can never reach that state. cc |
||
|
|
b2c74c4140 |
feat(qt): find an available version for a track Qobuz pulled
Reachable from a dead row's context menu, in a playlist the user owns. The reference had this and shipped it with four defects; none are ported. It was unranked -- Qobuz's raw relevance order shown to the human. The weighted matcher that already lives in qbz-playlist-import does the ranking now, and its ISRC short-circuit matters most: licensing churn usually republishes the SAME recording under a new id with the SAME ISRC, and the pulled track keeps its isrc (confirmed in the 2026-08-17 capture of album 0886443985094). That case needs no human judgement, so the ISRC lookup runs first and pins its hit to the top. It removed before it added, so a failed add lost the track outright. The order here is add, reposition, remove. It did not preserve position -- the computed index went to a console.log and the replacement landed at the end. It lands where the dead row was, through the new updateTracksPosition verb. Every failure in that step is a warning and a different success toast: the repair still stands at the end of the playlist, exactly where the reference always put it. Position is a nicety, never a reason to abort a repair. Two guards. If the ISRC lookup answers with the dead track itself -- it can, since a pulled track keeps its metadata and search still indexes it -- then "replace" would be a no_duplicate no-op followed by a remove, and the user would lose the row while being told it was repaired. And when the remove fails the playlist permanently holds both rows: that is not rolled back, because a rollback is a second write that can fail the same way and would delete the only good copy. The list is refreshed so the user SEES both, and the toast says the old one has to go by hand rather than claiming a reload will fix it. cc |
||
|
|
7780efa5db |
feat(qt): mark tracks Qobuz pulled, and keep them out of the queue
Qobuz withdraws tracks: sometimes a recording, sometimes only a quality tier, sometimes it republishes the same recording under a new album id. The row keeps appearing and simply cannot play. Until now the user found out by clicking it. The app already knew and threw the answer away: eight builders hardcoded `streamable: true`, so the API's own flag never reached a row model. The reactive half was already here and stays -- a terminal failure still toasts and skips, bounded at five consecutive, with the 403 back-off kept separate so a 403 storm cannot burn five good tracks. Detection has two clauses. The API's `streamable: false`, and a session-only memory of tracks that died terminally under the player this run: /album/get is honest, but plenty of rows only fail at the stream-url step, and that memory is what stops the queue re-offering them. Session-only on purpose -- the reference kept the equivalent set in localStorage, never expired it and never revalidated it, so a track Qobuz restored stayed dead forever. Dead rows are dimmed, get an alert glyph and a tooltip, lose click, drag, favourite and download, and STAY visible and right-clickable: hiding them would defeat the point and remove the only route to a replacement. Qobuz's own player does exactly this, and the pulled track carries displayable:false while still being rendered -- that flag is not a hide instruction. A downloaded copy is exempt everywhere. The offline tier resolves before any availability question, so a pulled track already on disk plays fine; dropping it from the queue would take away something the user downloaded, and it would bite hardest offline. Those rows say they are playing the local copy and keep their play affordance. Two seams filter, not one, and both had to learn the surviving anchor: every caller read `first_id` from the UNFILTERED list, so the seam dropped that row and the app then asked the player for the id it had just discarded -- silence, no toast. That was already reachable today through the artist blacklist. The seams now return the surviving start and its id. Two id bugs surfaced on the way and are fixed here. A playlist row's `playlist_track_id` was filled with the CATALOG id; they are different numbers for the same row, one naming the recording and the other its slot in that playlist, and Qobuz's delete and reposition verbs both take the membership id -- so "remove from playlist" was addressing a row that does not exist. And the For You rows were built with `..default()`, claiming availability while the queue built from the same track read the truth. Catalogs: the two strings this needed already existed in all seven translated locales and are reused; the skipped-tracks toast is a plural pair, and the replacement feature's strings ride along here since they share the files. cc |
||
|
|
8e52157299 |
feat(qobuz): add the updateTracksPosition verb
Reordering tracks inside a playlist had no client verb: addTracks takes only
[playlist_id, track_ids, no_duplicate] and appends, and updatePlaylistsPosition
reorders the user's PLAYLISTS, not the tracks within one.
The endpoint exists and is documented from a desktop web-player capture --
{playlist_id, playlist_track_ids, insert_before} -- not from the decompiled
Android route tables, which are not a valid source for this. Its exact argument
encoding is the one part no capture in the repo pins down, so every caller must
treat a failure as recoverable rather than fatal.
Written for the replacement flow, which needs the repaired track to land where
the dead one was instead of at the end of the playlist.
cc
|
||
|
|
371be04be8 |
feat(models): Track.streamable becomes Option<bool>, and absence stops meaning dead
`#[serde(default)]` on a bare bool means an absent key deserializes to FALSE, so a catalog Track from any endpoint that omits `streamable` came back marked unavailable. The models' own tests document this as a hazard rather than a bug (the "streamable split": PurchaseTrack defaults true, catalog Track defaults false, and the comment warns that a wrong default "inverts that whole screen's behaviour"). The inferred OpenAPI disagrees with the code and documents `default: true`. That was survivable only because nothing read the field. It stops being survivable the moment a frontend greys rows out and keeps them out of the queue: one terse endpoint would blank an entire view, silently. `None` now means the endpoint made no claim, and only `Some(false)` marks a track pulled. `is_streamable()` is the ONE place absence is interpreted, and it resolves to available: refusing to play a track because a response was terse is strictly worse than playing one that turns out to be dead, which the reactive skip path already handles. Album.streamable was already Option<bool> with a doc comment naming the same causes, so this makes Track consistent rather than novel. Every consumer in the workspace moves to the helper: both matchers, the ISRC validator, the daemon driver, the purchases service (which maps catalog Track into PurchaseTrack and therefore feeds the very screen the split protects), the radio pool, the mixtape resolver and the QConnect renderer. The replaced tests pin both directions of the new contract. Also here, because it lives in the same file as the matcher's streamable skip: `rank_candidates`, an additive public entry point over the existing weighted matcher (ISRC short-circuit, title/artist/album weights, duration bonus, stop-word normalisation, quality tie-break). `match_tracks` is untouched. cc |
||
|
|
3f5f27bba5 |
fix(qt): local playlist covers, and the playlist ref guard gets wired
Two dead-code warnings that were hiding real defects. A local playlist's cover was written to one store and read from another. The header menu persists through `cover_artwork_qt` to custom_playlist_covers.json -- deliberately separate, since a `playlists` key inside the shared custom_artwork.json is dropped by the other build's next write -- while the detail document read `library.db`'s custom_artwork_path, which nothing in this port writes. So a cover the user had just picked appeared in the sidebar and never in the header, and has_custom_cover stayed false, which meant the menu only ever offered "Add cover": Remove was unreachable. The document now reads the same store the menu writes. The DB column survives as a read fallback so a cover stored there by an earlier build is not silently lost, and the clear path is kept and wired into remove_custom_playlist_cover -- without it, such a cover stayed on screen with the menu insisting it had been removed. PlaylistRef was a documented type guard nobody constructed. Nine call sites hand-rolled it as is_local_id() then a separate parse::<u64>() -- correct at each site, but it left "a ref classified by the wrong half" representable, and that id-space hazard has already produced real bugs here: a LocalLibrary row id is a small integer that parses as a perfectly valid Qobuz id, it just means a different track. Classification now happens in one place, and a new call site cannot forget the test. cc |
||
|
|
05c5799192 |
feat(qt): Detected device limit row, its refresh triggers and cache drop
The last unported row of Settings > Audio. Read-only by design: a Hz dropdown is the documented root-cause trap, because it promises rates the app cannot request when Qobuz sells four discrete tiers. The value is Rust-composed from the probe and the row hides on an empty summary; the fallback caveat renders only when the probe fell back to the common set. No new msgids -- both strings already exist, translated, in all 8 catalogs. Six refresh triggers, one more than the reference: startup, the toggle, a backend switch, an output-device change, reset-to-defaults, and the device refresh/release button. That last one is Qt-only and the easiest to miss -- it is the hotplug case, where the hardware behind a "System default" selection changes without the selection changing. Ordering is persist -> refresh -> publish, and the refresh goes BEFORE apply_audio rather than merely before the handler's closing publish: apply_audio publishes on its own way out, an earlier serialize that would ship a stale summary. The symptom of getting this wrong is a row that is only right after reopening Settings. The boot trigger publishes the snapshot itself after awaiting the refresh. The boot snapshot is spawned separately and can serialize first, which left the row absent for a user who already had the toggle on until some unrelated mutation happened to republish. A cap change drops the tier-keyed audio cache, symmetric with the streaming -quality change: the cache is quality-blind, so bytes fetched under the old tier would keep serving and the feature would be inert on anything already played. The first refresh of a process is exempt -- see the CapChange classification, which exists because clearing there wiped the disk cache on every launch. cc |
||
|
|
3aed6d3ea6 |
fix(qt): every local play goes through the funnel, which now owns the tier
play_resolved_offline_aware was meant to be the single point every local play passes through, but the quality was a PARAMETER of it, so seven callers re-resolved the same value and five bypassed it outright. The five bypassers are a live bug with nothing to do with #638. MyQBZ (x3), playlists and the gapless prefetch called the core directly with None, None for the offline handle and the row sink, and 0 for the resume position. So playing a collection or a playlist ignored the offline cache entirely: a downloaded track re-downloaded, and failed outright with no network, while never feeding the offline-cache progress row. Gapless had the same hole under a comment claiming this port opens no offline index, which the funnel above it already falsified. The funnel now resolves the request tier itself through local_playback_quality, which reconciles the streaming preference against the detected device cap. It costs two uncontended lock reads, so there is no argument for hoisting it back out. quality_state drops its own cap-free copy and calls the same function: a badge that reports a tier the play path did not request is worse than no badge. route_play_to_peer deliberately stays at the call sites. play_queue_track needs the peer route BEFORE its local-file branch, and the MyQBZ collection play does not early-return on a routed play because its recency bookkeeping runs either way; hoisting it would force a tri-state return and weld a routing decision onto a quality one. cast_qt::effective_cast_quality still reads the UNCAPPED preference. The local DAC is not in a cast's signal path, and capping it there is #638 in reverse: a 48 kHz DAC silently limiting a 192 kHz DLNA renderer. A source audit test pins the exact call-site set, since no runtime test can reach it. Known limit, documented at the funnel: the cap governs what is REQUESTED. An offline copy already on disk is served verbatim, because downloads are always taken at the top tier. cc |
||
|
|
f75e1a4f47 |
feat(app): add frontend-agnostic device_cap module (#638 fix 3)
The local output-device quality cap lived in the Slint binary crate, which is why the row never reached the Qt frontend: it is app-glue in a crate Qt cannot depend on. Added to qbz-app, which already depends on qbz-models, qbz-audio, tokio and log, so no new dependency edge. The Slint copy is left untouched and keeps its own now-stale version. Two behavior changes over the original, both deliberate: Tier boundaries are tightened. A tier is a CEILING the service may deliver at, so naming one the device cannot play defeats the cap: an 88.2 kHz DAC must not resolve to Hi-Res (deliverable at 96) and a 176.4 kHz DAC must not resolve to Hi-Res+ (deliverable at 192). refresh() now returns a CapChange so the caller can decide about the audio cache without a racy before/after read. The first refresh of a process is Seeded, not Changed: the cap cache starts empty, so at boot None -> Some looks like a user change, and the invalidation it triggers unlinks every cached audio file on disk. A naive comparison wiped the whole playback cache on every launch with the cap enabled. A generation counter, re-checked while holding the CAP write lock, refuses a superseded commit. Without it "enable (slow probe) then disable" could let the stale probe resurrect a cap the settings say is off; checking the counter before doing the work left exactly that window open. refresh() also takes the active backend, so a "System default" selection resolves through the backend that is playing instead of always PipeWire. cc |
||
|
|
bad0e3aed3 |
fix(qt): search album cards read the nested audio_info and dates too
Sweep after the award-grid fix, comparing every album-shaped surface against
the reference's canonical reader (`album_map::map_album`).
Search was the one live gap left, and it is not a guess: `search_albums`
returns the very same `SearchResultsPage<Album>` that `get_award_albums`
does, and that endpoint demonstrably answers with the nested fields only.
Its mapper read `release_date_original` and `maximum_bit_depth` flat, so the
same missing date and missing badge the owner saw on the award grid were
sitting in search results.
The three chains are shared helpers now (`album_release_date`,
`album_audio_parts`, `album_quality_tier`) rather than a third hand-rolled
copy — the next album surface gets them by calling, not by remembering.
Everything else in the sweep is already correct or matches the reference:
- nested-aware already: album page cards, artist, artist scene, library /
favorites, label, myqbz builder, playback
- flat in BOTH frontends, so shared debt and not a port gap: the album page
HEADER (album.rs:275 reads flat too), the album info modal, and
purchases
- every Track reader is fine by construction — `Track` has no `audio_info`
and no `dates` fields at all
cc
|
||
|
|
5d1448b23e |
feat(qt): pin the award page's controls in a nav bar
Search, shuffle and the award picker are one right-aligned cluster in a pinned 44px bar now, given the ArtistView jump-nav treatment: its own fill, a 1px bottom hairline, the page's 32px rhythm, and a slot in the flow that reserves its height so the hero does not jump when the bar is lifted out. Order inside the cluster is left-to-right from "narrow this" to "leave here": the search and the shuffle act on the page you are on, the picker takes you off it. Carries the rounded-corner rule this codebase has hit twice already (ArtistView.qml:2795, the Discover full-bleed toolbar): pinned at the pane's top edge the bar's square corners poke out past the shell's rounded bezel, so the top corners round — but only WHILE it is at the top, driven off the live y. Mid-page the bar is surrounded by content and a notch there is worse than the square corner it fixes. The picker still tracks the OPEN award: `currentIndex` is the document's `catalogIndex`, so navigating to another award moves the selection with you instead of stranding it on the one you came from. cc |
||
|
|
c2218dcdb2 |
fix(qt): read the nested audio_info and dates on album cards
The award grid drew cards with no release date and no quality badge while
Discover's looked fine, and the reason is the same one twice:
`map_flat_album` read only the FLAT fields, and /award/getAlbums answers
with the nested ones. The reference's `album_map::map_album` has read both
shapes all along.
Three fallback chains, ported from it:
- date: nested `dates` (original > download > stream), else the flat
`release_date_original`
- bit depth and sample rate: `audio_info` first, else the flat pair
- tier: falls back to the hi-res FLAGS (`hires || hires_streamable`) when
no depth arrived at all. A payload can say "this is hi-res" without
saying how, and the old match answered "" — no badge — for exactly that
Also `> 16` rather than `>= 24`: a 20-bit master is hi-res.
cc
|
||
|
|
2d33a4fdfb |
feat(qt): search, load more and random on the award landing page
Three controls so the landing is usable without bouncing through "See all". Search sits in the section header and shares the LISTING's query — one award, one filter — so what you type here is still in force behind See all. Client-side over the loaded set, like the listing and like the reference. Load more is a BUTTON, not infinite scroll. The listing sub-view owns the scroll-driven paging; this page has the "Other awards" rail under the grid, and auto-loading would push that rail out of reach forever. Suppressed while a query is active, for the listing's reason: paging in rows the filter then hides is work with no visible result. That button needed the preview cap gone. The landing document published `take(PREVIEW_PAGE_SIZE)`, so "load more" would have fetched rows the view threw away. The first fetch still asks for one preview page, so the first paint costs what it did before; the cap is now only on the REQUEST, not on what the document carries. Random plays one album out of the LOADED set (the LibraryToolbar precedent: pick a visible row, `QbzPlayer.playAlbum`). Not the whole award — the rest is not here to pick from, and reaching for it would put a blocking fetch behind a button that should feel instant. Every mutation republishes BOTH documents now: they share one accumulating album list, so a page loaded or a query typed on either side is visible on the other. No new msgids — "Search albums & tracks…", "Load more" and "Loading…" are all in the eight catalogs already. cc |
||
|
|
f046bb8c1f |
fix(qt): album cards get their genre and a localized release date
The card was never the problem: AlbumCard.qml renders genre and the date as hover meta and draws the quality badge, and AlbumCollection passes all three. The DOCUMENT arrived incomplete. `home_qt::map_flat_album` — the mapper behind Discover, the label pages, the award pages and DiscoverBrowse — never wrote `genre` at all (it fell through to `..Default::default()`), so that line was empty on every card it feeds. And it took the first four characters of the release date, so those surfaces showed "2021" where the rest of the app shows "Sep 2, 2021". That inconsistency is the actual finding: five mappers already localize through `qbz_text_utils::dates::release_label` and four sliced the year, and the comment on `map_recent_album` — directly below the worst offender — claims the discover cards localize it "exactly" the way it does. They did not. The album-page and artist-page release cards had the same slice and are converted with it. Two year sites are deliberately left alone: `album_qt.rs:959` and `myqbz_builder_fetch_qt.rs:64` produce an `Option<i32>` used to sort and group, not to display. A localized string there would break the ordering, which is exactly the kind of blanket sweep this is not. cc |
||
|
|
6dcfbc77c9 |
chore(qt): clear six dead-code warnings, each verified before deleting
- label_qt: an unnecessary `mut` — the closure takes its Vec as a parameter and captures nothing mutably. - sidebar_qt: `SidebarLocal.hidden` was write-only. NOT a bug: the list is filtered on `!p.hidden` before the struct is built, so the field could only ever be false. Hidden local playlists were, and are, excluded. - local_playlist_qt: `queue_track_for_row` / `plex_key_for_row` duplicated `playlist_qt::row_to_queue`, which is both live and better — a local detail adopts its rows into that shared page and `row_to_queue` types them from `row.source`, the guard that keeps a library rowid off the QConnect wire as a catalog id. `CURRENT_QUEUE` stays: their sibling `local_picker_ref_for_row` is its live reader. - cover_artwork_qt: `prefer_playlist_cover` had no possible call site — the one consumer that would want it returns a Vec of cover refs, not a single fallback string, so it applies the same rule itself. - playback_qt: `enqueue_track_list` was an append-only alias of `enqueue_track_list_mode`, orphaned by the "Play all next" fix. A second name for the append is what let the mode-less call site look correct. Also carries playback_qt's half of the local-feed play routing (the sibling of `library_qt::feed_track_to_queue`): a local or Plex feed row is resolved as a LOCAL queue track instead of falling through to the Qobuz builder, which would have rebuilt it with the same numeric id as somebody else's song. It rides here because the two changes share the file. The four warnings left are the ones awaiting a ruling: PlaylistRef and its parse (a documented type guard nothing constructs) and the two local playlist custom-artwork writers (Qt stores playlist covers in a JSON sidecar while it READS the library.db column the Slint writes). cc |
||
|
|
1f636679d5 |
fix(qt): "Play all next" enqueues the artist instead of replacing the queue
The artist ⋯ menu offers "Play all next", "Add all to queue" and "Shuffle all". The first was wired to `playArtistTop(false)` — which the bridge's own doc calls "Play all": it REPLACES the queue and starts playing. There is no plain "Play all" row in that menu, so there was no way to get the intended behaviour, and asking to queue an artist next wiped whatever was queued. The invokable takes a mode now and both rows route through the shared insertion funnel: "next" inserts at the cursor, "queue" appends. That is what the reference does (`enqueue_artist_top_selected(.., next: true)`) and what `playback_qt::enqueue_artist_top_mode` was written for — it had no caller at all, which is how the miswiring survived. cc |
||
|
|
dc2496e5c0 |
feat(qt): award pages, reachable from the album sidebar and from each other
Ports the Slint/Tauri award pair: the landing (gold laurel hero, magazine line, follow heart, "Acclaimed Releases" with its count and See All, the preview grid and the "Other awards" rail) and the paginated listing with its client-side search. Routes "award" and "awardalbums", the label pair's shape — one controller for both, since they share the hero, the follow state and the identity. The three rules from the Tauri review are in the module header because they are the ones a re-implementation gets wrong: the grid is ALWAYS /award/getAlbums (/award/page is user-scoped and may omit releases, so it feeds the hero and nothing else); the favorite split is plural-read, singular-write, because the backend derives `award_ids` from the write kind; and getAlbums has no has_more, its `total` is a hint, so the only sound predicate is `total > loaded`. The album sidebar's AWARDS cards navigate now — with an id directly, and by NAME through the resolver when Qobuz omits it, which it does. They were also reading `album.awards` when the field lives on `album.header`, so the block had rendered EMPTY for every album since it was written: an empty Repeater draws nothing and reports nothing, and the sidebar's own gate counted the same empty list. The LABEL card beside it had a "no label view yet" note and no handler, years after the label view landed. Beyond the reference: an all-awards dropdown pinned to the page's top-right, so any award is reachable without first finding an album that won it. It costs nothing new — the /award/explore crawl already existed to resolve names to ids. The award icon was lucide's MICROPHONE under the wrong filename. Search had routed around it with `crown` and left a note; kiosk was missed and drew a mic for "Most popular". The asset is the laurel the other two frontends use, and kiosk now asks for the crown it wanted. cc |
||
|
|
60bf5b8365 |
fix(qt): "Not interested" actually removes the artist from Recommendations
The write existed — `dismissArtist` persisted to the reco-dismissal store and toasted — but nothing READ it: `reco_dismiss_qt::ids_snapshot()` had no consumer outside its own module, so the artist came back on the next rebuild. The card's comment promising it would "leave the rails on the next publish" was false, because nothing filtered on publish either. The dismissal ids now join the exclusion set fed to `compose_artist_rails`, so the composition choke point can backfill the freed slot from the pool instead of leaving a hole, and `apply_dismissal` drops the card from the cached rails on the CLICK — no optimistic flip in QML. Scope is deliberate and recorded on the card: RECOMMENDATIONS only. The same card is mounted by Search, Label, Artist Scene, Home and the Library grid; dismissing from one of those persists and takes effect in Recommendations, but the card you clicked stays where it is. Hiding an artist app-wide is the blacklist — a different store and a different row. cc |
||
|
|
41c1b50d61 |
feat(qt): offline cache manager, and stamp the album identity on cached tracks
The last missing Settings surface: the OFFLINE CACHE group (Open manager / Open folder / Clear all) and the manager view behind it — stats bar with the GB limit, sort and failed-only toolbar, A-Z artist rail, album+track rows with per-row re-download and remove. The engine was already here; what was missing were four actions (remove_album, redownload_track, open_folder, clear_all), the rollup and the view. Two bugs the owner's smoke exposed, both older than this view: ALBUM IDENTITY WAS BEING LOST ON EVERY ALBUM DOWNLOAD. A Track inside an /album/get payload carries no nested album object, and `track_cache_info` read exactly that, so `cache_album` wrote `album = NULL, album_id = NULL` for every track it queued. Measured on a real index: 34 of 46 rows, three complete albums. Not cosmetic — `album_id` is the key `remove_album`, `redownload_album` and the downloaded-album registry look rows up by, so those albums could not be removed, refreshed or recognised as downloaded. The album document now stamps id and title onto every track that lacks its own. The reference has the identical bug. AND THE ROLLUP MIXED ARTISTS. Every row without an album_id collapsed into one "__singles__" bucket titled from the first track and credited to its artist, so three albums by three artists rendered as ONE album header while two of the artists vanished from the rail. The key is a ladder now: album id, else (artist, title), else a per-ARTIST singles bucket — two artists can no longer meet in one group even with the broken data. Rows already on disk recover their title from library.db, which does know it, so an existing cache reads correctly without re-downloading. Deltas from the reference, both because Qt is not Slint: covers are paths (slint::Image is not Send, which is why it pre-decodes; Qt has no such constraint) and selection is QML-local, this port's convention, which keeps the document a pure function of the DB so a finishing download cannot fight the user's checkboxes. Clear-all confirms first — the reference fires straight from the glyph, and undoing this one means re-downloading the whole cache. cc |
||
|
|
ffa7cfd4c4 |
feat(qt): Library All local scope, and stop resolving local rows as Qobuz tracks
PARITY-DEBT #6, both halves. `all_local_scope` was read-only here and the feed's "all" branch did not exist, so Settings > Local Library > LIBRARY › ALL was absent and the only local content in the All feed was hearted items. `all_local_feed_blocking` ports the whole rollup — albums with the Plex union, artists, tracks paged to the reference's 200k ceiling — over the same queries the Local Library tabs run, so the two views cannot disagree about what is in the library. Two deltas from the reference, both because the Qt side already had the better primitive: the local/Plex artist merge keys on `normalize_artist` (diacritic fold — PARITY-DEBT #7, already fixed for the Artists tab) rather than a raw lowercase, and the quality badge comes from the pair every other local surface uses. And the reason this could not ship as just a feed branch: `feed_track_to_queue` did not look at the row's SOURCE. A local track carries its `local_tracks.id` — a small integer that is ALSO a valid Qobuz track id — so clicking one queued whatever Qobuz song happened to hold that number. The guard cannot be "does it parse", it has to be the source; local and Plex rows now resolve through `local_playback::local_queue_track` against a raw-row cache, the same shape the Tracks tab keeps. A row that cannot be resolved is dropped rather than played as somebody else's track, and the regression pins both arms. cc |
||
|
|
bbbac0a0ad |
feat(qt): auto-theme custom image picker, and retire the fossil settings notes
The reader had been there the whole time — `theme_qt.rs` builds
`AutoSource::Image` from `auto_theme_image_path` — with no way for a user
to write it, which made "Custom Image" a source you could select and never
supply. Adds the native picker (rfd, through the `settingsString` action
seam the folder chooser already uses), the "Detected: <desktop>" hint from
the shared detector, and the experimental caveat. Persisting the path also
sets source=image before regenerating, because the theme re-reads the prefs.
No new msgids: all four are already in the eight catalogs.
Also deletes eight header comments that were false, each verified against
the code before removing it:
- LocalLibrarySettings: "no native folder picker" (library-pick-folder
uses rfd) and "LibFolderEditModal not ported" (it is, and mounted at
SettingsView.qml:330)
- OfflineSettings + settings_qt/offline.rs: "Check now not shipped" (the
button is right there)
- DeveloperSettings: "in-app log viewer not ported" (LogViewerModal.qml)
and "export always without auth" (the credentials switch is inline)
- PlexSettings: "the PIN flow is not ported" (Generate/Link/Copy code and
Open sign-in all present)
- settings_qt.rs: "no JACK in the POC build" (the banner is in
AudioSettings.qml), plus the wizard and settings export, both shipped
- tray_qt.rs and auth_qt.rs: "the Qt port has no session_persist module"
— false since the queue restore landed; this port flushes once after
the event loop instead of from each quit handler
A "not ported" note that outlives the port is worse than no note: the last
round of recon believed two of these and planned around them.
cc
|
||
|
|
57fd17f7d3 |
feat(qt): raise the desktop now-playing notification
`system_notifications` was written by Settings and read by nobody: the shared `qbz_media_controls::show_track_notification` had one caller in the whole workspace and it was the Slint build's. The switch shipped on by default and did nothing. Ordering is load-bearing. The de-dupe runs FIRST — `push_now_playing` reaches here from `refresh_now_playing`'s 23 call sites (resume, seek, quality patch, every republish) and only a changed track id may pass — then the pref read, then the peer guard inside the spawn, so a track a remote QConnect renderer is playing is never announced. The toast is NOT downstream of the MPRIS handle: a machine with no session-bus MPRIS still has a notification daemon, and the reference likewise raises it outside its `if let Some(mc)` block. Hanging it off that early return would have made the switch dead again for a smaller audience. Cover resolution differs from MPRIS on purpose: the notifier decodes by CONTENT, so a cached copy is fine and saves a re-download, while a widget resolves file:// through the mime database and rejects the cache's extension-less .img. That split is the B11 rule. cc |
||
|
|
9899ad6b09 |
feat(qt): open the playlist importer from the Playlist Manager toolbar
The importer had exactly two doors, both inside the sidebar's playlist popup, so the one view whose job is managing playlists could not reach it. Placed before "New folder": that slot disappears in flat mode and a Row skips invisible children, so a button after it would slide sideways every time the mode toggles. No offline gate — the modal recomputes its own and shows a disabled confirm with the reason, which beats a toolbar button that vanishes when the network drops. No new msgids: "Import" is already in all eight catalogs. cc |
||
|
|
16a25bbbe6 |
fix(qt): pin the Local Library tracks scrollbar to the view edge
The bar sat inside the tab's 32px content inset AND carried its own 4px margin, leaving it 36px from the window with an empty channel beside it. It now cancels the inset; the rows keep theirs. The "By name" arm is untouched: there the alpha strip owns the right edge and 22px is what clears it. The inset is a named property now because the scrollbar has to cancel it — written twice, moving one silently misaligns the other. cc |
||
|
|
13f1b106dc |
fix(qt): keep the Local Library select button clear of the search box
The expandable search keeps a 30px footprint and grows its inner field LEFT (QbzLineEdit.qml, `x: root.width - width`), so anything positioned before it in the row is covered while the search is open and cannot be clicked. On the Tracks tab the multi-select button led the row and was exactly that. Moved to the end of the group — the slot the Albums tab already puts it in, so the two tabs agree as well. Swept the other nine expandable searches: in all of them the field is already the first child of its row, and the tree rail's box width is computed to stop at the third button. Tracks was the only case. cc |
||
|
|
3bbe7a039d |
fix(qt): album info opens for every album, not only numeric ids
The guard had its predicate backwards. `album_id.parse::<u64>()` rejected
the four-in-five Qobuz albums whose id is alphanumeric ("vy32kidwrer67",
114 of the 139 ids in the captured fixtures) while admitting local library
row ids, which are exactly what it claimed to be blocking.
A rejected open returns before touching the bridge, so the modal kept
whatever document was there: the previous album's, or — once the QML
started holding its overlay until the ids matched — an empty card. Neither
said a word above debug level, which is the whole of "only the first album
info of a session works".
The published document now stamps `albumId` with the id that was ASKED
FOR rather than the one /album/get echoed back. The QML compares those two
with exact string equality, so any divergence blanked the card silently;
a mismatch now logs instead.
Regression tests cover the four real alphanumeric ids, the barcode-shaped
minority the old guard did accept, and the local/Plex keys the guard
exists for.
cc
|
||
|
|
7d98ad044f |
feat(purchases): the Qt frontend — list, album detail, routing and gating
Two screens, a controller and a bridge. `QbzPurchases` publishes one JSON document per screen plus a revision counter, so QML re-parses even when the text is byte-identical. - The list: two tabs with per-type counters, a server-side search on a 300 ms debounce, and a toolbar that DIFFERS by tab — Albums gets group, sort, direction, two filters and grid/list; Tracks gets group and the filters only. Building the Albums toolbar on both would have invented two controls. - The detail: a format dropdown whose selection re-scopes every downloaded mark, the progress section and Add-to-Library together; a determinate bar counted in TRACKS, never bytes; goodies as an album-level action counted separately. - The album download is the frontend loop, not the Rust command the reference registered and never called. Sequential, concurrency 1, cancellable, and a failed track does not abort the album. - Both prefs are read at runtime, not merely written. `show_purchases` is the master gate and `nav_tb_purchases` only relocates the entry to the title bar; both default false, so Purchases still ships hidden. - Scanned local rows keep their raw `qobuz_purchase` source so the gold badge survives in list mode as well as grid, while still classifying under the Offline source filter. Remote feed rows deliberately stay `qobuz`: stamping them would badge every purchase the user merely OWNS as locally downloaded. Defects fixed after the screens were written, none of which any test could have caught: - `QbzPurchases.boot()` was called nowhere. Without the Qt-thread hop every publish is a silent no-op, so both screens would have stayed permanently empty with nothing logged. The cause was an ownership gap rather than the code: `Main.qml` belonged to no lane because the plan listed the files each lane CREATES rather than the files it TOUCHES, and "belongs to nobody" is indistinguishable from "done" until something runs. - A second download of the same album nested inside the first. The store rewrites its destination to the album subfolder after the first successful track so that Add-to-Library adds the album rather than the whole music root, and the download planner read that value first — producing `.../Album [q]/Artist/Album [q]/...`, one level deeper each time. The picked ROOT and the resolved ALBUM FOLDER are now separate fields with names that cannot be confused, with a regression test. - A tab counter could stick at zero forever with its list plainly full: the two ids calls each swallow their error, and the reference's backfill from the list response was missing. - Cancelling made the whole progress block vanish instead of showing the cancelled state, whose msgid was already translated in all eight catalogs. - Add-to-Library re-walked the entire library when the folder id it needed was already in hand. - A live language switch and a logout left the previous state behind. Deliberate divergences from the contract, each recorded with its reason: the page header is a plain `fontSection` title with no icon tile, because nothing else in this application announces itself over a large coloured square; purchase download status travels inside the album document rather than on the shared cache channel, which is keyed by track id alone and would let the two features overwrite each other on the same row. Gate: release build clean, QML audits 278 files / 37 bridges, `cargo test -p qbz-qt` 398 passing, offscreen smoke green with zero QML complaints. Not verified, and cannot be from here: every populated screen. This account owns nothing, so only the empty states have ever rendered. cc |
||
|
|
475f261dc1 |
feat(purchases): shared backend for the Qt port, with tags, covers and goodies
The frontend-agnostic half of Purchases. Ported against the Tauri reference, which is the only implementation ever validated by a real user; the Slint controller was read for divergences only and never as a source of truth. Context that sets the bar for all of it: Qobuz does not sell Purchases in this account's region, so no populated screen and no download can be exercised here. Anything merely plausible ships unseen. Hence the tests below are the deliverable as much as the code. Behaviour ported or corrected: - Album "downloaded" now comes from the LOCAL REGISTRY. The reference derives it from `album.tracks.items`, but `getUserPurchases?type=albums` carries no nested tracks page (measured against a live account), so that predicate is unsatisfiable: no album card can ever mark as downloaded and "Hide downloaded" can never hide anything. `get_downloaded_purchase_album_counts` answers it instead, giving a reader at last to the `album_id` column and to the index that has always sat on it. - `album_id` is written by every download path. It used to be set only by the album loop, so a release downloaded track by track produced rows the rule could never attribute. The `/track/get` payload each download already fetches carries the id. - `remove_downloaded_purchase` keys on `(track_id, format_id)`, the table's actual primary key; erasing every format is now a separate, named call. - `sanitize_filename` no longer panics. The 200 cap is in BYTES and `truncate` panics off a char boundary — reachable because the mapping keeps Unicode alphanumerics. Measured: the crash belongs to the THREE-byte class (CJK), where 200 % 3 lands mid-character; two-byte scripts happen to land on a boundary. The cap stays in bytes so no already-downloaded path moves. - The purchase path sends `/album/get` and `/track/get` UNSIGNED, matching both the reference and Qobuz's own desktop client, whose request helper takes a sign flag and passes it false for these and true only for `getFileUrl`. That signature is the entitlement proof and is untouched. - The purchase-type guard runs before any I/O; a bogus type no longer issues a live authenticated GET before failing. - Track subtitles survive. `PurchaseTrack` gained `version` and `build_purchase_album` maps it from the catalog track, closing a latent regression where every purchased track lost its edition. - Downloads write tags, `cover.jpg`/`back.jpg` and goodies. All three are best-effort: the audio file is the deliverable and is already on disk and registered before any of it runs. - `goodies` is deserialized leniently. Its populated shape has never been observed — it comes back empty on every album nobody owns — and `/album/get` is parsed with the strict structs, so an unexpected shape would take the whole album down rather than merely hide a booklet. Riding along, both found while building the above and both unrelated to Purchases: - The metadata EDITOR silently discarded saves. `write_album_tags_to_files` picked its write target as "the primary tag, or else the first one I can find", and lofty refuses to WRITE a tag type a container only supports READING — silently, skipping it rather than erroring. A FLAC carrying an ID3v2 tag has no Vorbis comments, so every edited field went into that ID3v2 tag, the save dropped the lot, and the call returned Ok. The user pressed Save, saw a success toast, and the file never changed. Same defect, same fix, in the new purchase tagger. - `fetch_asset_bytes` does not reuse the audio downloader. That one sizes its buffer from the response's own Content-Length and sets no total timeout, which is fine for a track from a trusted CDN and wrong for an arbitrary goodie URL on a machine that hard-freezes under memory pressure. Tests: 277 across the four crates, covering every acceptance item the contract set. The signature preimage character for character, the pagination loop including the zero-total case, the sanitizer against the inputs that used to panic, every deserializer default, and the wire envelopes against real bytes captured from a live account. A download harness stands up a local HTTP server and drives the pipeline end to end — real bytes over a real socket, a real FLAC tagged and read back, a real database — and the two tag regressions were both verified to FAIL against the old code before being accepted. cc |
||
|
|
9c67be4dcd |
fix(qobuz): give the offline-gate tests a rustls crypto provider
Both `client::tests::offline_gate_*` failed on every run, not intermittently: reqwest is built with `rustls-tls-webpki-roots-no-provider`, so BUILDING a client panics with "No provider set" until a process-level CryptoProvider is installed. The application installs one at startup via `qbz_app::ensure_crypto_provider`, but a test binary has no startup, and this crate sits below `qbz-app` and cannot call it. Confirmed pre-existing by stashing the unrelated work and re-running: same two failures, same panic. Worth knowing beyond these two tests: the panic fires at client CONSTRUCTION, before any request and regardless of scheme, so it hits a plain `http://` URL too. Any future tool or test that reaches for the download path without an application around it will land on it. cc |
||
|
|
f701bcd4db |
fix(qt): size-aware now-playing artwork, up to the mega tier
Restored queues and recently_played carried whatever variant the building surface picked — 50px JPEGs measured in the wild — into the now-playing bar, MPRIS, Discord and the immersive big slots. The np feed rewrites sized Qobuz covers up to 600px; big slots (immersive panels, split, preview overlay) resolve per size bucket through npArtworkPathLarge with one re-resolve per tier crossed and never on shrink. Registry-miss arm (restored sessions carry no ImageSet): rewrite to the FULL requested bucket with a session negative cache, so a pre-mega album pays one 404 per session instead of one per track change. And the mega tier's wire suffix is the literal _max.jpg, not _3000 (404 measured against the live CDN) — qobuz_cover_at_px now accepts and emits _max. Local covers gain an on-demand 1600px tier (never upscaled); Plex big slots tokenize at 1024. km |
||
|
|
e6c058c2f2 |
feat(qt): immersive shader scenes — RHI items, scene menu, GL link pairing
Port the immersive GPU scenes: Plasma, Tunnel, Aurora, Ambient, Spectral Ribbon and Line Bed as QQuickRhiItems/ShaderEffects under a pulse-driven ShaderSceneLayer, plus the Qt-only Tunnel Flow (Tauri Canvas2D port) fed by the Viz16 stream. Scene rows live in the header view menu (no Off row); g cycles the ring. A fresh immersive open defaults to Split > Lyrics (owner ruling). OpenGL link fix (owner smoke: Tunnel black with "uniform _22 declared as type buf and type buf"): every ShaderEffect fragment with its own uniform layout now ships a paired vertex stage declaring the identical block — the shared spectrum.vert only ever linked under Vulkan. RHI hardening: render pass descriptors on the ping-pong targets (the Tunnel Flow segfault / Plasma dead window), mirrorVertically tied to the backend (!== OpenGL), ribbon sub-uploads carry setSourceSize. km |
||
|
|
8bd02e35d3 |
feat(qt): custom-theme editor, artist portrait, playlist rail, About/What's New and diagnostics
Five surfaces the Qt frontend was missing, landed together because they share
five wiring files (build.rs, main.rs, shell_bridge.rs, AppShell.qml,
HeaderBar.qml) and splitting them would produce commits that do not build.
ARTIST PAGE
- The Playlists section used a hand-rolled Rectangle delegate: a MouseArea with
no onClicked, and a RoundedImage with no fit:, so the default crop blew an
800x380 playlist banner up inside a 200x200 box and showed the middle 47% at
2.1x. Replaced by the real cards/PlaylistCard.qml — already live on five other
surfaces — mounted in a new views/PlaylistRail.qml modelled on SectionRail.
ArtistPlaylist publishes the five roles the card reads.
- The portrait gains what the album cover has: the right-click menu the Slint
reference already has (Add/Change/Remove image, Open in browser, Save as…) and
the lightbox this port invented for albums and playlists. Custom images are
keyed by artist NAME into the same custom_artwork.json the Slint build writes,
so they round-trip between the two.
- CoverLightbox is now aspect-aware. It forced a square box on the premise that
covers are square; artist portraits are not (1080x720 measured in the cache).
- Removing an override invalidates the pipeline's resolved path for that url:
artwork_qt::cached_path consults the override FIRST, so coverMap held the
custom file and the header kept showing the picture the user had just deleted.
- artwork_qt::local_path is the inverse of file_url. trim_start_matches("file://")
is not: it leaves the percent-escapes in place, so any path holding % # or ?
came back as a name that does not exist on disk.
- One new msgid, "View image", in the seven non-en locales — matching its sibling
"View cover", since en carries none of that family and falls back to the msgid.
CUSTOM THEME
- The editor, the colour picker and the swatch grid, ported 1:1 from
AppearanceSettings.slint and ColorPicker.slint. The 11 editable base tokens,
their expansion into the full 55-value palette and the on-disk format all stay
in the shared qbz-theme crate, so a theme authored in the shipping Slint build
already rendered here and now round-trips both ways.
- Hue is normalised 0..1 at every Qt.hsva boundary; the reference stores 0..360,
and porting the number instead of the domain would render every swatch red.
- The first-selection seed is captured BEFORE set_theme persists the slug,
because current_slug() re-reads it from disk — a seed taken afterwards would
resolve "custom", find no file and snapshot the default it was meant to replace.
- The in-memory base is dropped when the theme row enters "custom": it is
memoized for the life of the process and filled at bridge construction, so a
process that booted without the file would keep serving the OLED default even
after the shipping Slint build wrote the user's theme.
- The disk write is debounced to drag-end and atomic; the reference persists on
every drag frame with a truncating write, on a file the other build has open.
ABOUT + WHAT'S NEW
- Both modals, with their producers. render_markdown is transcribed line for line
from the reference: h1 and h2 collapse to one level, an indent-0 bullet becomes
a section plus a TOC entry, bold and backticks are stripped rather than styled.
Text.MarkdownText is a different subset and would have rendered a different
document from the same source. Pinned by tests, including the real 2.0.2 body.
- The codename is a single const, so the release checklist has one Qt-side place
to bump.
DIAGNOSTICS
- The panel in Settings > Developer, the Report an Issue modal and its menu row.
- The log bundle carried eight hand-built header fields where the reference
carries about seventy, so every issue filed from a Qt build arrived with no
kernel, no distro, no install method, no GPU, no exclusive-mode / DAC /
sample-rate state and no playback context. The report is injected INTO
qbz_log::bundle rather than replacing it, keeping the <details> wrapper that
makes a pasted bundle readable.
- The GTK / WebKit / GSK / GDK rows are CUT. In a Qt process those libraries are
not mapped, so their runtime column can only read "—", and their saved column
would come from a graphics.json this binary never reads or writes — foreign
state presented as this app's configuration.
SHELL
- The four always-on-top overlays (toast, tooltip, art preview, immersive) relied
on declaration order and carried no z, so any z-bearing modal buried them: a
toast raised by a background event behind an opaque panel is never seen. They
now sit in a band above the modals, keeping their relative order.
- build.rs asked cargo to watch ../../.git/HEAD, which does not exist in a git
worktree (.git is a file). Cargo treats a missing rerun-if-changed target as
always-dirty, so the whole build script — per-shader qsb, the recursive asset
sweep, cxx-qt/moc codegen — re-ran on every cargo invocation.
Verified: cargo test -p qbz-qt 343 passed, cargo test -p qbz-library 83 passed,
qml_singleton_xref and qml_resolution_audit clean over 35 bridges and 265 QML
files, and qmlcachegen compiles every one of them. Smoke-tested by hand.
Contracts, recon and adversarial reviews:
qbz-nix-docs/qt-frontend/2026-08-14-six-item-round/
cc
|
||
|
|
4bfd8b30e2 |
fix(library): keep a track's rowid when re-indexing it
insert_track used INSERT OR REPLACE. SQLite resolves that conflict by DELETING the old row and inserting a new one, which hands out a fresh rowid. playlist_local_tracks.local_track_id is a foreign key onto local_tracks(id), and foreign keys are OFF in this database (the only pragmas set are journal_mode and synchronous), so the cascade never fires: the playlist row survives pointing at an id that no longer exists and the INNER JOIN that reads it returns nothing. The scan re-inserts every file — there is no mtime skip — so every library rescan silently emptied Qobuz playlists of their local tracks. The conflict that fires is not the obvious one, which is worth recording: the table's UNIQUE(file_path, cue_start_secs) does NOT catch an ordinary track, because cue_start_secs is NULL and SQLite treats NULLs as distinct. What catches it is the partial index idx_tracks_file_nocue. Both identities are handled. The row is now looked up and UPDATEd in place. Reproduced on a scratch database before the fix was written, and the regression test was confirmed to fail against the old code path. First-class LOCAL playlists were never affected: local_playlist_tracks stores local_path, not an id. cc |
||
|
|
0bba3d0cf4 |
fix(library): stop the direct tag write from clobbering per-track ARTIST
write_album_tags_to_files called set_artist(album_artist) unconditionally on every file. Two irreversible outcomes, and a direct write has no undo: - On a various-artists release the editor pre-fills the artist field from compute_track_artist_match, which returns None the moment two track artists disagree — so the field renders EMPTY and that empty string was written over the ARTIST of every file in the release. - With a non-empty album artist, every guest credit was replaced by it. The DB half of the same save was already careful: update_album_group_metadata renames the track artist only where it still equals the value that was uniform across the album. The file writer now agrees with it. A pre-pass reads the prior artist from the files themselves — they are the ground truth about what is being overwritten — and should_rename_artist renames only on an exact match with a non-empty new value. ARTIST is track scope and the editor has no field for it. The pre-pass is skipped when the album artist is blank, so the common path costs nothing. cc |
||
|
|
8783031ea0 |
fix(library): group a titled disc folder into its release
In the DEFAULT (folder) grouping mode, a box set laid out the way our own
multi-disc guidance recommends — Disc NN - {subtitle}/ — showed up as one album
per disc.
album_root_dir climbs past a directory only when is_disc_folder accepts it, and
that predicate required the name to consist ONLY of disc tokens, digits and a
short modifier list. "Disc 01 - TV Series Soundtrack #01" tokenises to
["disc","01","tv","series","soundtrack","01"], has_extra_words hits "tv", and
the climb stops — so the group key becomes the disc directory and a 13-disc set
becomes 13 albums.
is_disc_folder now also accepts a name whose LEADING token is a disc designator
followed by a separator with whitespace on at least one side. That is disjoint
from the issue #147 false positives, which are album titles that END with a disc
token ("Now 75 - CD1", "100 Popular Classics, Disc 1", "Relaxation Disc1") —
their regression tests pass unchanged. The rule stays narrow deliberately:
"CD-Rom Favourites" and "Disc Jockey Anthems" are still album titles, because an
accidental merge of two releases is worse than the split being fixed.
The disc NUMBER is gated on the same predicate, so a box set with no DISCNUMBER
tag now recovers it from the folder name too.
Sidecar lookup follows the key, so moving the key would have orphaned every
.qbz.json saved while a disc was still its own album — album title, artist, year,
genre, catalog and all per-track overrides, silently. apply_sidecar_override now
tries the album root and then the file's own directory, and the CUE path goes
through the same function instead of its own single-directory copy.
Existing installs see the merge after a rescan; album_group_key is stored at
index time.
cc
|
||
|
|
8ff0adc149 |
fix(qt): convert the Slint colour literals that made scrims and shadows invisible
Slint writes #RRGGBBAA, Qt parses #AARRGGBB, so a literal copied verbatim does not come out the wrong colour — it comes out transparent, because the alpha byte is whatever the red byte used to be. Seven sites across five modals. Every faked 32px drop shadow in the port was #00000080 (alpha 00), and the scrims of LogViewerModal and LibFolderEditModal were #000000bf, so those two modals had been opening with no dark backdrop at all. The rest of the tree already spells the same scrim #bf000000. Found by grepping for 8-digit literals of the shape #00xxxxxx. The gradient stops in MixArtwork and LocalAlbumView that are legitimately transparent, and the comments in the scene components that warn about this exact trap, were checked and left alone. cc |
||
|
|
016eb3cd23 |
feat(qt): artist scene, musician page and the shared scene-discovery engine
Both views were absent from the Qt frontend, not partial. This lands them
together with the core engine they need and the two entry points that reach
them.
- qbz-core gains scene discovery ("artists from the same place"): a
cancellable run with a phase for the progress label and a typed
SceneDiscoveryError, so a view can tell offline, all-upstreams-failed and
an honest empty result apart. CancelToken, ScenePhase and SceneDiscoveryError
are re-exported for frontends.
- qbz-integrations: MusicBrainz location resolution plus its cache layer.
- qbz-qt: ArtistSceneView with its three scene components, MusicianPageView
and MusicianModal, backed by artist_scene_qt.rs, musician_qt.rs and the two
bridges. The five-branch musician dispatcher is wired to all six of its
consumers. Both scene doors are live: the sidebar location link and the
overflow row that shipped grey with action: "stub".
- Translations: the new msgids in all eight locales.
Three defects fixed on the way, all with the same signature — a comment
asserting a mechanism that does not exist:
- Volume opened at 100 %. The comment claimed the poll loop mirrored it from
the engine; now_playing::set_volume has two callers and neither runs in a
local session.
- mbAvailable was documented as "MusicBrainz enabled AND resolved to an MBID"
while being assigned from the opt-in alone. Consumers now test the MBID.
- Session restore could not resume from a cold cursor ("cannot resume - no
audio data available") for want of a has_loaded_audio() guard. Investigation
in qbz-nix-docs/qt-frontend/2026-08-14-cold-cursor-resume.md.
Contract and evidence:
qbz-nix-docs/qt-frontend/2026-08-14-artist-scene-musician/00-CONTRACT.md,
which wins over specs 01..09 and records 20 deliberate divergences from the
reference in section 6.
cc
|
||
|
|
b045f7023b |
chore(qt): vendor the country flag set used by the artist scene
265 SVG flags (1.1 MB) plus the upstream MIT licence and attribution in crates/qbz-qt/third-party/. Kept as its own commit so the asset drop can be rolled back or re-sourced without touching the scene code that consumes it. cc |
||
|
|
085c35876a |
revert(qt): make the content router synchronous again
An asynchronous Loader plus a router-level skeleton was meant to fix the dead click on navigation. It made the app markedly worse and is reverted whole. Async incubation is TIME-SLICED: it yields to the UI thread between slices, so it trades a short freeze for a much longer wall-clock build, and the Loader stays in Loading until the entire tree exists. For Discover > Home that is all 413 cards -- a one-second freeze became a five-second skeleton on the most used route in the app. Measured after the fact, which is the mistake; a change that moves WHEN something heavy is built has to be timed on the busiest route before it is called a fix. The placeholder is gone with it. It drew the same two text lines and row of five cards for all 40 routes, so the swap moved everything -- the exact jump a skeleton exists to prevent. The dead click is real and remains unfixed. The durable fix is not here: stop rebuilding these views on every navigation (keep the top-level tabs alive) and build the heavy views incrementally. The reasoning is kept in the file so the experiment is not re-run. cc |
||
|
|
fda56f9ea2 |
fix(qt): pin the artist jump nav bar and round it at the pane edge
The strip scrolled away with the body; the reference clamps it to the top. Lifted out of the flow with a slot holding its place, using the reference's clamp verbatim -- the formula must not add the viewport y or the scroll is double-counted and the bar lifts ahead of the body mid-transition. Pinned at the pane's top edge it then showed the square-corner defect already fixed in Discover: Qt's clip is a rectangular scissor that ignores radius, and the shell hides its bezel nubs while the dynamic background shows through the corners, so a full-bleed child has to round itself. Driven off the live y, so the rounding exists only while the bar is actually at the top -- rounded corners in mid-page would be a notch with nothing behind them. cc |