dc8cb1fa3219a4d5653344bb07ba71befaf1c0d8
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dc8cb1fa32 |
perf(qt): build only the discover rails that fit the viewport
2026-08-17 fixed the four Discover tabs, which were all instantiated because `visible: false` hides an item without stopping QML from building it. This is the same disease one level down and it is what was left: a Repeater builds EVERY delegate in its model, so a ten-section tab built ten rails, each rail's horizontal ListView then created its own delegates during the first layout pass, and the viewport has room for about two and a half. That work is what `to-idle` was made of — the rails nobody could see paying for their cards before the GUI thread could reach the event loop and let a frame out. The seed is one viewport and the rest arrive after the first frame. A rail is ~328px with the Column spacing against a ~740px pane, so three covers the fold; +2 per tick reaches even the twelve-section For You tab inside ~80 ms. The `active &&` on the visibility gate is load-bearing: a Column skips invisible children entirely, but an active:false Loader is visible and 0px tall, which would reserve a spacing slot per pending rail and collapse it as the rail lands. Honest limit: this did NOT turn out to be what fills the blank stretch between the acknowledgement frame and the content. Library has no such ramp and shows the same shape, so that hypothesis is refuted and the remainder is unattributed. cc |
||
|
|
5b274cab57 |
perf(qt): acknowledge a route change on the next presented frame
Measured by driving the real window and diffing the captured frames: a
click on Discover > Home left the window with ZERO pixels changing for
590 ms — the flyout did not even close. The click handler, the flyout
close, the sidebar highlight, the page blank and the whole mount all ran
inside ONE turn of the event loop, so the first frame that could be
presented was the one that already had the finished page in it.
The breakdown matters more than the total, because only one part of it
is what it looks like:
navigate_to (Rust, GUI thread) 7.6 ms
bridge queue hop 1 ms
the QML mount (`built`) 263 ms
post-mount work before a frame ~290 ms <-- `to-idle`
`built` is under half of it. `to-idle` is the number that matches the
freeze, and any future work here should be judged on that one.
So the route is now committed in two phases: arm on the change, build on
the next PRESENTED frame (frameSwapped). Frame N carries the cheap half
— flyout gone, highlight moved, page blanked — and frame N+1 pays for
the mount. Acknowledgement measured at 73-90 ms across five routes and
repeat runs, against 590 ms before.
Two things this had to get right, both caught by capturing frames:
- The backstop timer was a RACE at 32 ms. Render-at-next-vsync
(16.67 ms here, per QSG_INFO=1) plus a QUEUED frameSwapped delivery
lands in ~16-33 ms, so Home acknowledged at +90 ms while Library got
nothing at all on the same build. 250 ms puts the guard outside that
range; it now only covers the hidden-window case it was written for.
- During the armed frame `viewLoader.item` is still the OUTGOING view,
so the incoming route's discriminators were applied to it. The
activeTab Binding wrote "home" onto LibraryView — which has an
activeTab, so the typeof guard passed — emptying its body and
restarting the page fade over the blank. Both Bindings now require
the commit to have happened.
Not asynchronous: incubation is time-sliced and was reverted the day it
was tried; every view is still built synchronously.
cc
|
||
|
|
a11b302e5d |
chore(qt): an env-gated probe for what a navigation costs the GUI thread
The route stopwatch in ContentRouter.qml starts when QML sees the new route, so everything before that — the invokable, the prefs write inside nav_qt::record, the per-view hydrate — was outside every instrument this track has. That is not a small remainder: measured with this probe, a navigation spends single-digit milliseconds there, which is what rules the Rust side OUT as a cause of the dead click and points at the mount. QBZ_QT_NAV_PROBE=1 turns it on. Unset it costs one OnceLock read per navigation and emits nothing, the same discipline the qbz.nav.timing LoggingCategory already follows. cc |
||
|
|
64f0382153 |
chore(i18n): the disc batch's strings, in all eight catalogues
481 entries across en, es, de, fr, pt, ru, ja and nl: the rip wizard and its progress panel, the metadata modal, the per-disc blocks and the track filter, every new toast, and the tooltips. Spanish is Mexican/neutral and was scanned for voseo and Castilian before landing. cc |
||
|
|
2273db9184 |
feat(qt): the disc surfaces — rip wizard, metadata correction, and a pane that
can be read RIP WIZARD, three steps, each answering something the first version got wrong by not asking. What will happen — which disc, how many tracks, and that the output is FLAC, stated rather than implied. The tracks, editable, because a CD-DA carries no titles at all so "named wrong" is the ordinary case and once files are on disk it is a rename job. And what happens afterwards: a ripped album the library cannot see is a folder full of files, so the destination is compared against the registered folders — by path COMPONENTS, not by string prefix, since /music-backup starts with /music and is not inside it — and the right follow-up is offered, naming the folder it would re-scan. "Do nothing" is an explicit choice rather than the silent default. The follow-up runs only on success. Tracks can be deselected. Everything is ticked on arrival and the user goes from all to fewer; a wizard that starts empty makes the common case the one that costs the most clicks. On the Rust side an absent `selected` means yes, so an older form cannot silently rip nothing. A PROGRESS PANEL behind the ripping chip, which is a door rather than a label: it is the only thing on screen during a rip and the two questions it could not answer are the ones that matter. A soft lock says not to eject — soft because nothing here can hold a tray shut, so the honest move is to say it and keep saying it. Per-track state with a bar on the row that is actually moving. Stopping is offered, deletes nothing, and says so; a cancelled job does not jump to 100 %, which is the one lie a progress bar must not tell. CORRECT DISC DETAILS, for a CD and a SACD image and for neither on a folder, which has no identity to write a correction under. The list shows what distinguishes four pressings of one album rather than four identical titles, and flags a track count that disagrees with the disc. Selecting is not applying: the candidate is fetched in full and its track list shown, because that list is the only thing proving a plausible title is the right pressing. Applying renames the live session without renumbering ids — a re-open would orphan a playing track — and writes a user row. Only MusicBrainz re-resolves the cover; Discogs ids are not Cover Art Archive keys and a wrong cover is worse than the old one. The correction can be dropped, because without an escape a bad apply is permanent and that makes people afraid of the button. THE PANE SPLITS BY DISC. A box set scans as one album — measured on a 34-track set whose album_group_key is the box root for every file while the disc number sits in the data unused — so the pane was a flat list 247 rows long. The key carries the disc, unconditionally, because it round-trips through the play-album lookup. Its own client-side filter over what is already rendered, since finding one track meant scrolling past the other 246. EPHEMERAL TRACKS LOSE WHAT CANNOT OUTLIVE THEM. queue_qt has published isEphemeral on every row for a while and the queue panel respected it; the bar never read it. The heart goes inert, the add menu keeps only its queue actions, and the row menu comes back with those three and nothing else. Absent rather than rendered-and-inert. Also: a spinner on Open that blocks a second click onto a drive already being read, held by a counting RAII guard because opening is a relay and the chip must not blink off in the seam; the now-playing card routes a disc to the pane it came from instead of to an album id no catalogue can open; the file pickers use rfd, the platform's own dialog, which the POC-NOTE said was a five-line change once the dependency existed — it existed, Settings had been calling it from this crate for weeks, and on macOS the four Linux choosers do not exist so Open folder and Open SACD image opened nothing at all, in silence; the empty state stands down for the Open pane, which on a machine with no library drew both fully painted; tooltips on every disc control through one hover() helper, since two call sites had already mismatched their key, which does not fail — the bubble simply never goes away. cc |
||
|
|
b69a058bc3 |
feat(rip): a provenance log and the cover, beside the tracks
qbz-rip.log is deliberately NOT an EAC log. An EAC or XLD log is trusted for what it PROVES — test-and-copy, a drive read-offset correction, an AccurateRip cross-check against thousands of other rips of the same disc — and QBZ does none of the three. A file that imitated the format without the method would be worse than no file: it invites a claim this ripper cannot back, and the people who read rip logs are exactly the people who check. So the header says what it is and is not in the first block, not in small print. What remains is true and useful: which disc (DiscID and TOC fingerprint, both recomputable by anyone holding it), which drive, and per track the audio md5 that already lives in the file's own STREAMINFO — verifiable with flac -t without QBZ. Verified end to end: metaflac --show-md5sum prints exactly what the log claims, and flac -t passes on every track. "0 read errors" is a real promise and is stated as one: an unreadable sector ABORTS the rip rather than being written as silence, which is stronger than a burst-mode EAC rip. Where the OS did the reading there is no drive to name, and the log says that instead of printing a volume as though it were hardware. A partial rip declares itself partial: three tracks with no such line read as the whole album. cover.jpg keeps the source's EXTENSION — renaming a PNG to .jpg is how a picture stops opening. Neither sidecar can fail the rip: the audio is the deliverable, and a missing extra is a blemish on a folder of correct files. Five tests, including that the log states all three things it does not do, that the logged digest IS the file's own, and the calendar arithmetic that is the only thing here that could be quietly wrong for years. cc |
||
|
|
b38d6bd867 |
fix(player): attach a cover to queued rows that were enqueued without one
The sibling of patch_plex_quality, for the other thing a row can learn after it was enqueued. A disc carries no artwork — it is fetched, and the fetch outlives the click that started playback — so a CD queued at second two is still holding artwork_url: None when the cover lands at second ten. Every consumer downstream reads the QUEUE row (the now-playing bar, the miniplayer, MPRIS through art_url_for), so patching the session store alone left all of them blank. Rows that already have art are left alone: this fills a gap rather than overwriting a cover somebody else resolved. It answers two questions separately, because they drive different work — any patch makes the queue panel's thumbnails stale, only the current one needs the now-playing stamp re-pushed. cc |
||
|
|
b6deec0aaf |
feat(integrations): search and fetch remote album metadata
The pure converters have been here for a while; what was missing was the thin async layer that puts a client in front of them. It lives here rather than in the frontend for the reason ADR-006 gives: which provider answers, how a query is built and what an empty answer means are domain decisions, and a second frontend must not re-derive them. The Tauri reference is v2_remote_metadata_search / _get_album with its State arguments removed. A provider that is disabled, rate-limited or simply silent answers with an empty result set rather than an error, because "nobody has this record" and "the server said no" look identical to someone staring at a list — and the distinction that does matter rides its own flag. Discogs needs no credentials wired: its client already goes through the proxy. cc |
||
|
|
57ae465668 |
feat(disc): remember a disc after it has been ejected
A disc is the one medium QBZ can play that leaves nothing behind: no path, no row, no file. Everything learned about it was thrown away when the tray opened and re-derived from the network next time. That is fine for names that came from MusicBrainz and not fine for names that came from a human. One DiscID can name several pressings — this album answers with four — so the moment there is a button to pick the right one, or a wizard that lets a title be fixed before it is written, the choice has to outlive the eject. Otherwise correcting a disc is a toy: fix it, take it out, put it back, it is wrong again. A CACHE, not a second source of truth, with one rule that makes it safe: a row the user edited is never overwritten by an automatic lookup. put_auto refuses to touch an edited row and only put_user can replace one, enforced in the store rather than at the call sites so a future third lookup path cannot forget. Identity is the TOC fingerprint: pure geometry, no network, available before anything has been looked up, and identical on both platforms. It names the GEOMETRY rather than the pressing, which is the point — "for this geometry the user chose pressing 2" is the fact worth keeping. Global rather than per-user, because which record is in the drive does not depend on who is logged in. Seven tests, including the one this exists for. disc_identity carries WHICH disc is open for the two features that name the medium rather than the session; nothing there persists. cc |
||
|
|
8d4ed8e959 |
feat(disc): read a CD on macOS, through a reader split by platform
The disc feature was built entirely on Linux in a day, and the result did not
COMPILE on macOS at all: qbz_disc::cdda was cfg-gated to Linux while qbz-rip,
cdda_qt and audible_qt used its names unconditionally. Any macOS build has been
broken since qbz-disc landed.
ONE SET OF NAMES, two implementations that share no code. Linux talks to the
drive through ioctls and reads raw sectors; macOS does not have to, because the
OS mounts the disc and exposes one AIFF per track plus a .TOC.plist. qbz-rip,
audible_qt and cdda_qt needed no change, which is the test of whether the seam
was in the right place. Every other platform compiles and reports "no drive".
VERIFIED against the owner's Tool — Fear Inoculum, the USB drive moved from the
Linux box to the Mac mini:
- the plist's Start Block includes the 150-sector lead-in that the kernel
omits. Subtracting it reproduces the ioctl's numbers exactly, so both
machines compute DiscID BeNBMsD8Du5NO2W61Yk.B2jwwIs- and fingerprint
38bef21351f7fca3 — which is what lets a disc corrected on one be found on
the other;
- all seven tracks read on macOS md5 to exactly what metaflac reports for the
FLACs the Linux path ripped from the same disc hours earlier.
TWO THINGS ONLY MEASUREMENT CAUGHT.
Byte order. "AIFF is big-endian" is true of the format and wrong here: macOS
writes AIFC with compression sowt, which is twos backwards and means the
samples are already little-endian. An unconditional swap produced audio that
passed every cheap statistic — 97 % non-zero, full-scale peak — and matched the
Linux rip nowhere. The COMM chunk is read and the swap is conditional.
Track resolution. audible_qt rebuilds its TocTrack from a CdRef, which carries
a device and a sector range and no track number, so it passes zero. Matching on
the number meant every CD track on macOS answered "could not read the disc"
while ripping, which passes a real number, worked — two paths disagreeing with
only one of them exercised. Resolution is by start_lsn, the address the seam
actually carries, with a regression test on a synthetic volume that fails with
NoDisc under the old rule.
One transient disagreement is recorded rather than smoothed over: reading all
seven tracks back to back once produced a different digest for the last track,
and three further reads matched. The read is not perfectly reproducible on a
marginal region, which is exactly the caveat this approach already states.
cc
|
||
|
|
70defb2e86 |
fix(dsd): the SACD stereo area is byte-interleaved, not block-per-channel
Reported: a SACD image played as white noise with the music distorted behind it, identically in all three DSD modes. That the three agreed was the clue — DoP and native never touch the PCM filter, so the damage had to be upstream of the delivery choice, in how the bits are pulled out of the image. THE LAYOUT WAS DECIDED BACKWARDS. The first measurement converted twelve seconds and looked at where the energy landed: block-per-channel put 23 % above the audible band, byte-interleaved 0.2 %, and the reasoning was that real DSD64 must carry a large ultrasonic component. That argument does not hold for this pipeline. DsdPcmConverter low-passes and decimates to 88.2 kHz, so it REMOVES sigma-delta noise shaping by design — a correct decode has nothing above 24 kHz. The 23 % was aliased garbage from bits read out of order: the symptom was being read as the proof. The ratio does not discriminate either, since scrambled bits and shaped noise both put energy up high. Re-measured with a statistic that can come out differently: decode with the real converter, then ask whether the audible band has the shape of music and whether the two channels are a stereo pair at all. layout spectral flatness L/R correlation BlockPerChannel 0.038 … 0.107 -0.17 … -0.06 ByteInterleaved 0.0036 … 0.0093 +0.24 … +0.48 Tracks 1, 4 and 13 of disc 1 and track 5 of disc 2. A negative correlation between the channels of an orchestral recording is not a subtle hint, and a spectrum ten to twenty times flatter is noise. Two earlier instruments are recorded in the doc as non-discriminating rather than deleted quietly: the energy ratio, and the variance of the raw stream's 64-bit local mean, which answers the same for clean and scrambled bits because the local mean of a byte-level mix of two correlated channels is still roughly the audio. Also lands a real framing bug found on the way, which was NOT the cause: the packet descriptors carry a frame_start bit that the reader ignored, and frames begin 0, 672 or 1344 bytes into a track's payload depending on the track. With byte interleaving an even offset is harmless, which is why it hid; an odd one would swap the channels. FrameSync makes the reader hunt for the boundary, and keeps the unsynced path so a probe can reproduce the broken stream. cc |
||
|
|
cd8f8e6443 |
fix(qt): the CD cover arrives, and it stops blocking the disc from opening
Reported: the CD opened with its names but no artwork. The log said why, because the previous commit taught it to — "no cover art (500 Internal Server Error)" — which is a better failure than the silent one it replaced, and it pointed straight at two separate bugs. WRONG KEY. Cover art was asked for by RELEASE, and a release is one pressing. The owner's Fear Inoculum answers 500 for its release and 200 for its RELEASE GROUP, which is the album as a work and where the canonical art lives. The lookup now asks for `release-groups` too and tries the release first, the group second. WRONG PLACE. The fetch was awaited BEFORE the session was published, so a click produced ten seconds of nothing — measured: the Cover Art Archive took 9.4 s to answer, because it redirects to archive.org. The tracks now land immediately and the art is patched in when it arrives. Patching it in needs `replace_tracks_preserving_ids`: re-running `open_tracks` would renumber every row from the synthetic floor, and the queue may already hold the old ids — a playing track would lose its source mid-song. A late cover for a session that has since been closed or replaced is dropped rather than painted onto whatever is open now. cc |
||
|
|
a922bdd316 |
feat(qt): disc covers, a corner close, and an icon for Rip
Three things the owner asked for after driving the first build. COVER ART. A SACD image carries none, but the file it was downloaded as almost always sits beside one — `cover.jpg` next to the `.iso`, or one level up when the rip is split into "Disc 1"/"Disc 2" folders, which is the shape of the owner's Rheingold. `find_folder_artwork` already knows that walk, so this reuses it rather than growing a second rule about where covers live. CLOSE moves to the pane's top-right corner, for all three media. It used to sit in the action row beside Shuffle — a destructive action one pixel from "play", which is where a hand goes without looking. It is also OUTSIDE the Flickable now: scrolling a 24-track disc must not carry the only way to close the session off the top of the screen. THE RIP ICON is a disc with a folder, drawn as a Lucide-style outline rather than traced from the owner's PNG. The argument is the size it renders at: at 15px a solid, detailed glyph turns to mush while an outline still reads, and the comparison was rendered at both sizes before choosing. Adding it also found a gap in my own tooling. `icon_tint_qt.rs` re-tints from MASTERS at theme-change time, and `masters_cover_every_shipped_glyph` failed the build for a glyph that was baked but had no master — six files written, one line forgotten. `bake_icon.py` now registers the master itself, because a step that has to be remembered is a step that eventually is not. cc |
||
|
|
bbce651d79 |
feat(qt): a Rip button on an open CD, and it asks where to put the album
The button lives on the ephemeral pane's header and appears only for a
PHYSICAL disc — an image is already a file, so offering to rip one would be
offering to copy something the user already has. Whether the session is a disc
is DERIVED from its rows rather than passed in: a flag somebody has to
remember to set is a flag that eventually is not.
The destination is always asked. The app knows several plausible music folders,
which is exactly why it must not pick one — a ripper that decides where your
music goes is a ripper you clean up after.
While a rip runs the button gives way to its progress ("3/7 · 45%") and a
spinner: one control, one state, rather than a button that looks pressable
during a job it cannot start twice. The bar is per-DISC and not per-track,
because a listener watching a 79-minute album wants to know how far the ALBUM
is, and a per-track bar that resets seven times reads like a stall.
Driven end to end on the owner's Fear Inoculum: seven tracks written into
"Tool - Fear Inoculum/", every one decoding clean and seekable, all tagged,
no `.part` left behind.
Four new strings in all eight locales.
cc
|
||
|
|
5d3d51a882 |
feat(rip): rip a CD to tagged FLAC, bit-exact and seekable
New crate `qbz-rip`: CD audio in, tagged FLAC out. Frontend-agnostic — it
knows discs, samples and files, and reports progress through a callback so a
UI can drive it without this crate depending on one.
`flacenc`, pure Rust: no libFLAC to build, nothing extra to bundle into
Flatpak, Snap or AppImage, and macOS gets it for free. `lofty` (already in the
tree) writes the Vorbis comments.
`RipSource` is the portability seam, and it is the only part that differs
between platforms: Linux reads sectors through ioctls, while macOS MOUNTS an
audio CD and presents each track as an AIFF under /Volumes — so there a "read"
is a file read. Everything after the seam is shared.
Two defects were found by ripping a real disc and looking at the result, and
neither would have failed a test:
1. The tags were never written. Tagging happened on the `.flac.part` file,
and lofty identifies a file by its EXTENSION — so it wrote nothing, and
said nothing. Tagging now happens after the rename, with a test that
reads the bytes back and asserts the strings are in them.
2. The files could not be SEEKED. `flac -d --skip=2:00` returned
FLAC__STREAM_DECODER_SEEK_ERROR and `flac -t` warned 3102 times over 3103
frames. The cause is precise: a stream's last frame is short (12 709 620
samples at a 4096 block leaves 3828), flacenc reports that honestly as
min_blocksize 3828 / max 4096 — but in FLAC `min != max` MEANS
variable-blocksize, and a decoder reading one expects SAMPLE numbers in
the frame headers while the frames correctly carry FRAME numbers.
libFLAC's own encoder writes min == max for exactly this reason. Two
bytes of STREAMINFO, and now: zero warnings, seek works.
Verified against the drive, not inferred: 176 400 bytes decoded out of the
FLAC are byte-identical to the same sectors read straight off the disc. The
whole album ripped from the UI — seven tracks, 436 MB, every one decoding
clean, tagged with TITLE/ARTIST/ALBUM/TRACKNUMBER/TRACKTOTAL/DATE/ALBUMARTIST,
and no partial file left behind.
Filenames are sanitised for the strictest filesystem the library might be
copied to, including Windows' reserved DEVICE names and its habit of silently
dropping a trailing dot — which would turn two tracks into one collision.
cc
|
||
|
|
50469ccc57 |
chore: lock the new disc dependencies
sha1 and base64 for the MusicBrainz Disc ID, and the qbz-disc path entries. cc |
||
|
|
452589ddaa |
feat(qt): open a SACD image and play it — the disc names itself
`Open > Open SACD image…` picks a .iso, reads its stereo area and publishes it as the ephemeral session. Nothing lands in library.db. Unlike a CD, a SACD needs no network to be named: the Master TOC carries the album title and artist, and the area TOC a title per track. The owner's disc opens as "Wagner: Das Rheingold", 24 tracks, every one with its German title and "Berliner Philharmoniker, Herbert von Karajan, etc." — all of it read off the image. Routing is one line in `LocalSource::playback` and none in the player: a `sacd:` reference rides the existing DsdFile ticket, because `open_dsd` is the funnel all six player-side DSD constructions already go through. Teaching it there is what makes the PCM, DoP, native and gapless paths inherit disc images for free. Driven end to end on the owner's Rheingold: the tab reads "Wagner: Das Rheingold 24", the badges read HI-RES / DSD64, and pressing play took the DoP route — Player: DSD track — DSD64 via DoP (176400 Hz carrier) [ALSA Direct] DoP hardware configured: 176400Hz, 2ch, S32_LE Audio thread: DoP playback STARTED which is the outcome that matters: the disc's DSD bits reach the DAC without being converted to PCM at all. Six new strings in all eight locales. Unsupported cases are named rather than approximated — a multichannel-only area, a DST-compressed one and a file that is not an image each get their own message. cc |
||
|
|
06405969e9 |
feat(disc): read a SACD image's stereo area, measured against two real discs
Adds an ISO 9660 walk (no mounting, no privileges) and a Scarlet Book parser
for the stereo audio area, plus the `DsdDemuxer` adapter that lets every DSD
delivery the player already has — PCM, DoP, native — play a disc image.
The parser exists because the previous account of this format, written in this
repo, was WRONG: it said the per-sector header was a fixed 8 bytes, derived
from looking at one sector. Every structure below was re-derived by a CENSUS —
3 066 893 sectors across both of the owner's Rheingold discs, zero exceptions —
and each claim is falsifiable rather than asserted:
- byte 0 carries packet_count = (b>>5)&7 and frame_info_count = (b>>2)&7,
so the header is 1 + pc*2 + fic*3 and takes the values 5, 8 or 10;
- packet descriptors are BIG-endian, and the length field is ELEVEN bits,
not the twelve every published layout shows. The first descriptor on both
discs is 0x181B, whose low twelve bits are 2075 — larger than the sector
it lives in. With eleven bits it is 27, and 5 + 27 + 2016 = 2048;
- the proof of the whole decoding is the sum rule
`header + SUM(packet lengths) == 2048`, which the parser CHECKS on every
sector rather than trusting;
- the area is uncompressed, proven from the data and not from a flag: the
audio-packet bytes divide by 9408 (one uncompressed DSD64 stereo frame)
exactly — 350 495 frames on Disc 1, 306 696 on Disc 2. DST is
variable-length; those could not be integers.
Track geometry comes from SACDTRL1/TRL2, tied together by an identity that
holds for 44 of 44 tracks: start_lsn == area_start + start_time * 350. The
ISO 9660 extents mirror SACDTRL1 byte for byte, so the one-sector overlap
between consecutive tracks is the disc's own accounting, not a bug to fix.
The channel layout inside a frame was the last unknown, and three statistical
probes could not settle it — they were measuring the DSD noise floor. What
settled it was physics: converted with the real filter, block-per-channel puts
23 % of its energy above the audible band while byte-interleaving puts 0.2 %
there. A correctly decoded 1-bit stream MUST carry that rising noise floor,
and no filter puts it back once scrambling has folded it away.
Also in this commit, from the same measurements: `2C_TAREA.2CH` on Disc 1 is
recorded in FOUR extents, so `IsoImage::find` REFUSES a multi-extent file
rather than returning its first quarter as if it were the whole thing.
cc
|
||
|
|
d9893b659a |
feat(qt): open the audio CD in the drive and play it
`Open > Open audio CD` reads the disc, builds an ephemeral session and lands
on its tab. Nothing touches library.db, which is the whole point of the
ephemeral path: a disc is content from outside the index that leaves with the
plastic.
It does not invent metadata. CD-DA carries no titles, and the disc measured
here reports its MCN as all zeros, so tracks are "Track N" and the album is
"Audio CD". A DiscID lookup is the obvious next step and is deliberately not
faked: a wrong title is worse than an honest number.
The feeder is the interesting part, and its liveness check took three designs
because the first two were wrong in opposite directions — both caught by
driving the app, neither by any test:
1. `current_track_id() != play_id`: `play_streaming` posts to the audio
thread and returns, so the id is not ours yet and the feeder retires
before reading a sector. Nothing played at all.
2. The same test with a wall-clock grace: a DEADLOCK. That field is stored
only once the audio thread starts playback, which needs the initial
buffer, which is what the feeder is filling. Waiting on it is waiting on
ourselves; the grace expired and the track stopped mid-play.
3. A feeder slot the audio thread plays no part in. A newer disc play claims
it and the older thread retires on its next block. The acknowledgement
path stays for "something else superseded us", but only AFTER the player
confirms our id, and with no timeout — a timeout is what kills a healthy
feeder.
A 16-bit WAV header, not `qbz_dsd`'s: that one is hardcoded to 24-bit, and
reusing it would either lie in the header or promote 16-bit samples to 24.
The shift is arithmetically lossless but it makes the DAC open at 24 bits for
a 16-bit source, which is precisely the silent conversion a bit-perfect path
must not contain. `play_streaming` also does not publish bit depth, so the CD
states 44 100/16 explicitly instead of inheriting the previous track's.
Driven end to end on the owner's disc: seven tracks with the right durations,
CD / 16-bit / 44.1 kHz badges, ALSA Direct opened at 44100 Hz in exclusive
mode, playback past 2:25 with no read errors, and two rapid skips retiring
their feeders in ~160 ms each.
Seven new strings in all eight locales.
cc
|
||
|
|
3ba476b43c |
feat(source): a CD track is a ticket of its own, not a file
`PlaybackTicket::CdTrack { device, start_lsn, sectors }`. No path-shaped
variant fits: a track on a disc is a device plus a sector RANGE, and the bytes
only exist while that medium is in that drive. `File` and `DsdFile` would both
hand it to something that opens it.
`LocalSource::playback` answers it FIRST, before the extension tests, through
`CdRef::is_cd_path` — the encoder's own predicate rather than a second
`starts_with` written out at the call site. That is deliberate: a hand-copied
prefix list is exactly how six bugs of the same shape shipped on 2026-08-20,
and a fold to a valid-looking answer is invisible to every test.
The match in `audible_qt::play_ticket` is exhaustive on purpose, so the new
variant broke the build until somebody decided what a CD means. It now means:
read the sectors and feed them as 44.1 kHz / 16-bit / stereo PCM.
`EphemeralLibraryState::open_tracks` installs a track list that did not come
from scanning a directory. `open_folder` cannot serve a disc — it takes a
`&Path`, rejects anything that is not a real directory, and derives every
field by reading files off a filesystem. A disc has no filesystem to read.
Ids come from the same synthetic range, so every playback caller keeps routing
them without knowing a disc exists.
cc
|
||
|
|
db0999bf6f |
feat(disc): read CD-DA off a real drive, measured before it was written
New crate `qbz-disc`: table of contents and raw audio sectors through the
kernel's own cdrom ioctls. No libcdio — not because libcdio is worse (it is
better at RIPPING than this will ever be) but because this path PLAYS a clean
disc, and for that the kernel interface is enough while a native library would
have to be built into Flatpak, Snap and AppImage. Both need the same --device
permission in a sandbox; only one needs the library shipped.
Every claim below was measured on the owner's USB drive with Tool — Fear
Inoculum BEFORE a line of this crate existed, so the design answers evidence
rather than the other way round:
- the TOC reads back 7 audio tracks, lead-out at LSN 356568, and the track
lengths match the record exactly (10:21, 11:52, 12:44, 13:37, 10:05, 4:48,
15:44 — 79:11 total);
- CDROMREADAUDIO over 20 frames from LSN 4000 returned 47 040 bytes, 99 %
non-zero samples, peak 14559: real audio, not silence;
- the drive hands back LITTLE-endian samples. Neighbouring-sample deltas
average 73.5 read as LE against 20 971 read as BE, a 285x margin, so the
bytes go straight into a little-endian WAV with no swap. That is one
drive, so `looks_byte_swapped` keeps the test available as a diagnostic
instead of an article of faith;
- the disc's MCN is all zeros, which is why identity is a digest over the
track table and not the catalogue number.
What this is NOT is accurate extraction: no overlap synchronisation, no cache
modelling, no jitter verification. On a clean disc that is fine for playback,
and an unreadable sector surfaces as an ERROR rather than a hole full of
zeros — silence that pretends to be audio is the one outcome a bit-perfect
player must never produce.
`/dev/sr0` is nowhere in the code: drives are enumerated, because a box can
have several and /dev/cdrom is a symlink that may point at any of them. Data
tracks of a mixed-mode disc are skipped by their control bits.
`CdRef` is the typed reference (`cdda:/dev/sr0#285735+70833`) with ONE parser,
not prefix tests copied around — the scattered version is a known failure mode
here and cost a whole session on 2026-08-20.
11 tests, several asserting against that real table of contents.
cc
|
||
|
|
0e9721d24b |
refactor(qt): reorder the Local Library header and label the Open menu
Owner's layout: the two maintenance glyphs sit together and the action that
produces new content reads last —
Local Library (settings) (resync) [folder] Open v
Open also stops being a bare glyph. A folder icon on its own reads as "browse
folders" when there is a tab literally called Folders two rows below, and this
control is the one door for every medium the pane can hold: a folder now, a CD
and a SACD image next. It is a 30px / radius-6 chip with icon, label and
chevron — the same small toolbar-control contract this header's selects use.
cc
|
||
|
|
9666ef129e |
feat(qt): give the ephemeral pane an album header, a shuffle and zebra rows
The pane opened on a bare title, a track count and the folder PATH — a filesystem readout for something the user is about to listen to. For the common case this pane IS an album page: one folder, one album, one cover. It now reads like one. The cover is 224px, the size LocalAlbumHeader uses, so an opened folder and an indexed album never present the same record at two different scales; the cell collapses entirely when the session has no art rather than reserving an empty square. The second line trades the path for the total playing time, formatted by the same `total_duration` helper every other Local Library surface uses. Play gains a Shuffle beside it, with the same `(shuffle)` shape `play_album` has, and the Clear stays — it is the only way to close a session, and with it the only way to make the tab go away. The per-block header now draws only for MULTI-album sessions. Adding the big header made it a stutter for single-album ones: the same cover, the same title and the same count, twice on one screen. Track rows get `zebra: true`. `TrackRow` already had the property; this pane was simply the surface that never set it. The shuffle's generator was replaced before it shipped. The first version was a bare xorshift with a comment claiming a folder shuffle "is not a place that needs a good generator" — measured over eight consecutive shuffles of a ten-track album, one track opened FOUR of them. It is splitmix64 now, seeded with a counter as well as the clock so two shuffles inside one clock tick cannot repeat, and there is a test asserting no position is pinned over 2000 runs. Shuffle quality is something the listener sees directly. cc |
||
|
|
45c686fab0 |
fix(qt): take the view to the session you just opened, every time
Reported: opening a folder left the view on whatever tab was already showing. The handler watched `ephemeralActive`, and that flag is ALREADY true when you open a second folder over a first — or when the boot restore opened one before you ever touched the view. So it fired exactly once in the life of the app and then never again, which is precisely the symptom. It now watches an open SEQUENCE that changes on every open. The discriminator was already in the code and did not need inventing: `open()` and `open_path()` pass a runtime to `scan`, `rehydrate()` passes None. Bumping only for the former is what keeps a remembered folder from stealing the tab you actually navigated to at startup. Also restores `onEphemeralActiveChanged`, which I deleted by accident in the label commit: a range replacement swallowed the handler sitting between the two edits. It is the fallback that sends you to Albums when the session closes under you, and without it closing a session left `activeTab` pointing at a tab that no longer exists — a blank content area. It had been smoke-tested BEFORE that edit, so the test was valid when it ran and stale by the time it landed. Verified in the reported scenario: standing on Albums with one session open, opening a different folder switches to the tab, the label changes and the count follows. cc |
||
|
|
ffc2d7d664 |
build(qt): gate on a fifth audit — every icon name must have a baked svg
`QbzIcon` does not draw an SVG by path: it picks a pre-baked, per-tint copy out of `qml/assets/icons/<tint>/`. A name with no bake renders NOTHING. The row keeps its 15px slot, the label sits where it always did, and no test, audit or compiler says a word. That is how three rows of the new Refresh menu shipped iconless through a clean build, 434 green tests and four green audits: `"icon": "server"` looked obvious and has no bake. It was caught by driving the app and LOOKING. The audit checks literal `name:` / `"icon":` assignments against the union of the tint directories, and it is deliberately a floor, not a ceiling: an icon chosen by expression is out of reach for a static pass, and a first draft that also flagged names absent from SOME tints produced ~90 findings, all noise — amber/green/orange/favorite are status tints that hold a handful of glyphs by design. An audit that cries wolf gets ignored, and then it protects nothing. Proven in both directions before landing: with "server" reintroduced it fails and names the file; without it, 101 names across 288 files pass. The tool itself lives in qbz-nix-docs (planning/tooling stays out of the repo). cc |
||
|
|
85ece130e7 |
feat(qt): name the ephemeral tab after what is open, with no verb
"Open", "Now Playing" and "Playing: <album>" were all tried and dropped. A verb is a claim the tab cannot keep: a folder can sit open while something else plays, and then the tab and the now-playing bar name different tracks on the same screen. The content's own name cannot go stale that way, and the tab appears the instant you open something, so nothing has to explain it. The chain lives in Rust so the tab and the nav flyout read ONE value (`localEphemeralLabel`) instead of deriving it twice — the flyout does not parse the ephemeral document and should not start: single album -> its title (a folder IS an album, and so is a disc) otherwise -> the medium's display name, what the pane's header shows neither -> "Media" Elided at 24 CHARACTERS, not bytes: `QbzTabBar` sizes a segment to its text, so an unbounded folder name pushes the whole bar off screen — and slicing a UTF-8 string by byte index panics mid-codepoint on a Japanese album title. There is a test for exactly that. New msgid in all eight locales. Verified on screen: the tab reads "Live 8". cc |
||
|
|
cb4c03ca46 |
feat(qt): one Open menu and one Refresh menu in the Local Library header
The ephemeral pane used to be a third arm of the Folders tab, reachable only
through a folder-open icon buried in the tree rail — one arm of one mode of
one tab. It also HID the folder browser while it was open. Neither survives
the media this pane is about to hold (a CD, a SACD image).
Row 1 of the header now carries two menus, both `CardMenu` (the shared menu
surface, no new component):
- `Open`, always present, is the one door for every medium. Only "Open
folder…" is wired here; the disc entries land with their readers.
- `Refresh` replaces the Plex-only re-sync icon (#573). Rows are APPENDED
per configured source rather than rendered disabled, so a user with no
media servers sees local folders and nothing else. It needed no new Rust:
`plexAvailable`, `mediaHasJellyfin`, `mediaHasSubsonic`, `syncPlex()`,
`mediaSync()` and the settings' own "library-scan" all already existed.
The ephemeral session becomes a tab of its own, present only while a session
is. Two failure modes are handled because the tab can VANISH under the user:
closing the session falls back to Albums instead of leaving `activeTab` on a
tab with no body, and opening one navigates to it — while the pane was an arm
of Folders that was implicit, and with a tab of its own, opening a folder from
Albums would otherwise do nothing visible.
`load_tab_impl` gains an "ephemeral" arm: nothing to load (the session is in
memory), but without the arm every switch to the tab logged "unknown tab",
which is what a real routing bug looks like.
Driven under Xvfb, not inferred: both menus open, the gated rows match the
configured servers, opening lands on the tab, the badges read DSD64, closing
removes the tab and falls back to Albums, and the folder tree is now visible
WITH a session open.
cc
|
||
|
|
a0ed0d752f |
fix(player): cancel the DSD-to-PCM conversion when its track is superseded
The conversion thread relied on `push_chunk` returning `Err` to notice the
track had changed — the comment said so in as many words ("Reader gone (track
changed/stopped) — just stop"). That check could never fire: `push_chunk`
only extends a Vec and fails solely on a poisoned mutex, and `BufferState`
carries no reader-gone flag and no Drop.
So skipping a DSD track left the thread converting to the END of the file,
growing a buffer nothing would ever read. A 60-minute DSD64 album track is
~1.9 GB of 88.2 kHz/24-bit PCM held for nothing, on a box that hard-freezes
under memory pressure.
The fix reuses the contract that already exists for the CMAF feeder:
`register_stream_feeder` with the play generation, plus a cooperative check
once per converted block. Registration happens AFTER the spawn and carries
the generation the play was born under, so a supersede that lands in between
cancels the thread instead of registering it. Like the network feeder's own
cancel, it neither errors nor completes the buffer — the outgoing engine keeps
playing what is already buffered until the new source is installed.
Confirmed in a live session log, not inferred:
[CMAF-STREAM] Cancelling feeder for superseded track 5672
Player: DSD conversion for superseded track 5672 cancelled
cc
|
||
|
|
71f8d08970 |
refactor(qt): hoist the quality badge into one classifier and teach it DSD
`myqbz_builder_qt.rs` carried its own copy of the reference's `quality::badge` plus three helpers, and its own note called hoisting them out a named follow-up that "must not be done in a domain commit". This is that commit: they move VERBATIM into `quality_qt.rs`. The hoist is not tidying. Local Library never had those helpers, so it read a DSD row's nominal 1-bit depth and 2 822 400 Hz bit rate literally: the CD badge, and "1-bit / 2822.4 kHz" where MyQBZ printed DSD64 for the same file. Six surfaces disagreed with a seventh about the same track. `local_rows` gains a `detail_of` to pair with `tier_of`, and all SIX call sites are routed through the pair — the pairing is what stops the two halves of one badge from drifting again. `tier_of` keeps its extra "max" tier on purpose: `LocalLibraryView.qml:435` filters hi-res on that word and `QualityBadge.qml:66` folds it back to "hires". Measured before/after on the same album: CD + "1-bit / 2822.4 kHz" -> hi-res + "DSD64", on the grid, the rows, the album header and the playlists. cc |
||
|
|
6328994c00 |
fix(library): read "DSD" back out of the database as DSD, not Unknown
`LibraryDatabase::parse_format` is the inverse of `AudioFormat`'s Display,
which is what the scanner stores. It had no arm for "DSD", so every DSD row
came back out of the DB as `Unknown`: the album header printed "UNKNOWN" as
its format, the quality badge said CD (depth 1 IS a known depth below 24) and
the detail line read "1-bit / 2822.4 kHz". The now-playing bar was right all
along because it reads the decoder, not the row — which is exactly the clue
that separated the two data sources.
A fold to `Unknown` is a VALID answer, so nothing failed: not the build, not
`cargo check` (it cannot see the match at all), not a single test.
Three sites had the same hole and all three are fixed:
- `database.rs`, the one that bit;
- `local_plex.rs`, same fold for a Plex server serving DSD;
- `media_servers_qt.rs`, worse than the others — its fallback is `Flac`, so
a .dsf from Jellyfin or Navidrome was not losing its format, it was being
mislabelled as lossless PCM.
The guard is a round-trip over EVERY variant rather than a "DSD parses" test:
adding a variant without an arm now fails here instead of shipping the same
silent loss again. Verified against the real rows: format is stored as "DSD",
so this is a READ-side fix and no rescan is needed.
cc
|
||
|
|
a7b7c1b236 |
fix(qt): light the playing row for a downloaded track, in every track list
The now-playing affordance — the accent pill on the row's left edge and the eq bars in the number cell — never appeared for an offline row on the very view it was played from. `TrackRow` asked one question, QbzPlayer.npTrackId === item.id and for a `qobuz_download` row the two sides come from different id spaces: the queue id is the QOBUZ CATALOG id (the offline tier is keyed on it) while every Local Library list draws the row with its `library.db` id. They never matched, so the row sat dark while its own audio played. The reference has a second published id for exactly this and the port never carried it over: `NowPlayingState.local-track-id` (playback.rs:1978-1986). This adds its twin, `QbzPlayer.npLocalTrackId`, and makes the row match either. Published for Offline rows ONLY, and that restriction is the safety of it: `source_item_id_hint` is a shared slot carrying a Plex ALBUM key and a media server's own item id for those sources, so publishing it unconditionally would hand a list a number from another id space and light the wrong row. It stays empty elsewhere, which makes the second test inert there. Scope checked by driving the window rather than reasoned about: the pill and the play glyph land on the right row for local user files, Plex, Jellyfin and Subsonic both before and after this change — offline was the only source whose id diverged. The other three surfaces that ask the same question need no alias: the queue panel and the kiosk album carry queue ids, and the purchases list carries catalog ids. qbz-qt 431/431, 4 QML audits green. cc |
||
|
|
8c3514b899 |
fix(qt): play a downloaded album from Local Library through the offline tier
A CMAF download's `local_tracks.file_path` is the BUNDLE DIRECTORY (`tracks-cmaf/<qobuz_id>/`, holding `init.mp4` and an encrypted `segments.bin`), not a playable container. `LocalSource::playback` handed it back as a `PlaybackTicket::File`, the read failed with EISDIR, and the audible step reported it as audible: file not available at …/tracks-cmaf/266725026 (drive unmounted?) — a message about a drive, for a bundle sitting on the local disk. The bar adopted the track anyway, so the album looked like it was playing and no audio came out. Three funnels reach the audible step and only two excluded an offline row: `play_current_if_local` (auto-advance) and `playback_qt::play_resolved_offline_aware`. The Local Library PLAY funnel did not, nor did its hand-copy in `local_album_actions`. Only qbz-core's offline tier can decrypt the bundle, and the row carries the real Qobuz catalog id precisely so that tier can be asked for it. - `local_playback::play_audible` is now the ONLY copy of the audible step and routes an offline row to the offline tier; `local_album_actions` drops its copy and calls it. - `LocalSource::playback` refuses an offline row with `BadIdShape` instead of emitting a ticket that lies. The test is on the row data, never `is_dir()`: a stat there would block for the mount timeout on the unreachable share the audible step's bounded probe exists to survive. - Tests run the row through the real `local_queue_track`, because the two halves that must agree are its source word and the badge the router reads. Verified by driving the window: album play, track-row play and skip-next all log `[Core] track ... served from OFFLINE cache` and decode at 44.1 kHz / 24-bit; a plain user-file album still plays through the local audible step at 96 kHz / 24-bit. qbz-qt 431/431, qbz-source 91/91. cc |
||
|
|
900517db57 |
fix(qt): size the filter flyout to its content, not to 372px
The Slint original sized a fixed card around THREE source chips. This port now has five — Plex plus the two media servers — and the row ran to the card's edge, with the last chip all but touching the border. The 372px becomes a floor and the width is derived from the widest chip row plus the 16px padding on each side, capped so the card can never run past the left edge. That also survives a UI-scale bump or a language whose labels are longer, neither of which a fixed number can. `implicitWidth` is safe to measure here: a Row sums its children and a non-wrapping Text reports its natural width, so nothing in the chain reads the card width back and there is no binding loop. An invisible chip contributes nothing, so the card is narrower when a server is not configured. cc |
||
|
|
021b5609e1 |
feat(qt): the Albums funnel survives navigation and restarts
The filter worked but forgot itself twice over: leaving Local Library
reset it, and so did closing the app. The first one is not a bug in the
view, it is what this port does — navigating away DESTROYS the view
(project_qt_nav_eager_tabs), so a `property var filter: ({})` cannot
outlive a trip to Discover.
Both follow from one move: the funnel lives on the BRIDGE, mirrored into
the shared ui_prefs.json. The bridge outlives the view, and the file
outlives the process, so one property buys both kinds of persistence.
`setFilter()` is the only writer, so a mutator cannot change the funnel
and forget to persist it, and the view re-seeds from a Connections on
the qproperty rather than only at construction — a republish has to
reach a view that is already open.
The saved funnel is pruned where the source GATES are published, not
somewhere else, so the two can never disagree: a restored `jellyfin`
tick with no Jellyfin configured would match zero rows while the chip
that clears it is hidden by the same gate — an empty grid with a badge
counting a filter the user cannot see. Only the media-server keys are
pruned; local/offline/plex are always meaningful words.
Verified: ticked Jellyfin, went to Discover and back (badge and grid
held), restarted the process (held), pressed Clear (ui_prefs went to
{}). The prune is reasoned, not exercised — both servers are configured
on this box, so no gate is off to test it against.
cc
|
||
|
|
1a2ebcf146 |
fix(qt): re-seed a line edit on the CHANGE, never on the focus loss
`QbzLineEdit` restored the owner's value with
Binding { target: input; property: "text"; value: root.text
when: !input.activeFocus }
and a Binding re-asserts whenever its condition flips, so BLURRING the
field wrote `root.text` back over whatever had been typed.
A SEARCH field never noticed: it fires `edited()` per keystroke, the
owner republishes, and `root.text` is already what the user typed. A
FORM field is the opposite — it reports on `committed()` and the owner
holds the value until Save — so `root.text` is still the stored value,
which for a password is the literal `""` that is deliberately never
prefilled. Every blur erased the field.
Measured in the media-server panel: username and password wiped each
other, so a Subsonic server could not be signed into by hand at all.
The stored state was in fact correct the whole time — `onCommitted`
fires on focus loss and captures the value before the Binding clears
the view — which is why this survived the Jellyfin round: the form
worked, it just did not look like it.
A republish IS a change to `root.text`; a focus loss is not. Reacting
to the change alone keeps the documented case (an external Reset
re-seeds an unfocused field) and drops the destructive one.
Verified in the window: password survives blurring into the username
box; the Tracks search keeps its query after a blur; the × still
collapses the expandable search with no stale query snapping back.
cc
|
||
|
|
4ba4d336d0 |
fix(qt): route every server album key to the local view, not /album/get
`open_album` asks `is_local_feed_id` whether an album id belongs to the local detail view or to the Qobuz catalog, and that gate's whole vocabulary was the literal prefix `"plex:"`. A `jellyfin:<itemId>` key answered NO, went to Qobuz's `/album/get`, 404'd, and the view NAVIGATED AND LANDED EMPTY — not only from the now-playing bar: every caller of that router shares the gate (the queue, "go to album" in a row menu, the Library ALL feed). None of the other clauses caught it either: a Jellyfin item id is 32-char hex, so it carries neither `/` nor `|`. Spelled through `SourceId::from_word` instead of a second prefix list, so the Subsonic brand spellings come along and the next source does not have to remember this site. `qobuz:` stays excluded — it is a legal word and a catalog id. Verified on the bench: playing a Jellyfin track and clicking the album in the bar opens KARAOKE FIRE!! with its 17 tracks and the playing row highlighted. cc |
||
|
|
8b4e0e92b8 |
fix(qt): treat every server source as non-catalog, not just Plex
Three places still spelled "not a Qobuz row" as "local or plex", which made a Jellyfin or Subsonic row fall into the catalog class: - Library ALL feed stamped the album source from the ID PREFIX (`plex:`), so every media-server album came out as "local" and drew the local hard-drive badge. Reads the row's own `source` word now — byte-identical for local and Plex, correct for the rest. The same failure `local_rows::badge_source` had. - TrackRow's `localSourceRow` gated "Add to playlist" and the offline download entry. A Jellyfin row counted as a catalog row, so the menu offered to send a namespaced synthetic id to the Qobuz playlist API, which adds an UNRELATED track. An allowlist, not `!== "qobuz"`: "offline" is an offline-CACHED Qobuz row whose id IS a catalog id. - AlbumCard hid "Block this album" on local/plex only, so a server album offered to blacklist an id the Qobuz catalog never had. `feed_track_to_queue` is taught the words too. Media-server TRACKS do not ride that feed today (only their albums do), so that one is the guard learning the vocabulary before it is needed rather than a live fix — its existing test, which pins that a local id must never resolve as the Qobuz track carrying the same number, now covers all of them. cc |
||
|
|
13e938727c |
feat(qt): merge media-server rows into both local search paths
`media_servers_qt::search_tracks` was written with the rest of the stack and never called — the compiler's dead-code warning was the only thing that said so. Both places that merge Plex now merge the media servers too: the Local Library Tracks tab and the cortinilla's local section. Two asymmetries are deliberate: - Page 0 only, in the Tracks tab, for the same reason the Plex arm is page 0 only: these caches are bounded sets read whole, so re-merging them per page would repeat every remote row once per local page. - BOUNDED in the cortinilla, unlike the Plex arm. The Plex cache is read whole because it always was; a media-server mirror can hold 50k rows and that path runs on every keystroke, so it takes the same limit the caller asked the local query for. Verified on the bench: "totsugeki" in the Tracks tab returns Jellyfin rows first, then the Plex ones. cc |
||
|
|
322b9374fb |
feat(qt): Jellyfin and Subsonic filter chips in Local Library
The Source row of the filter popup offered Local / Offline cache / Plex, so a media-server library could be browsed but not narrowed — no Plex equivalent existed for it. Adds a chip per media server, each gated on a new bridge property so an unconfigured server never shows a chip that can only ever return zero rows (Subsonic stays hidden here, Jellyfin appears). The fold in applyFilter is the part that is easy to get wrong: rows are stamped with the BRAND the server reports — navidrome, gonic, airsonic, astiga — while the chip is the PROTOCOL. Without folding those words to "subsonic", ticking any source filter would silently drop every row of a Subsonic library instead of filtering it. Verified against the live Jellyfin bench: with the chip on, the grid holds only Jellyfin-badged albums. cc |
||
|
|
df409f6c3c |
fix(qt): resolve media-server art tokens before qbz-models sees them
`qbz-models` is the base of the dependency graph and cannot reach `qbz-source`, so `artwork_ref_with_plex` knows exactly one remote word: "plex". A `jellyfin:<albumId>/<tag>` token fell through to its LocalFile arm, which handed MPRIS a file:// URI to a path that does not exist — the bar and the queue were fine (they resolve through the Qt art cache), only MPRIS was blank. Resolve source-tagged tokens in the Qt layer, where `qbz-source` IS reachable, and hand qbz-models an already-resolved local path. Verified over the session bus: a Jellyfin track now reports mpris:artUrl -> a real 256x256 JPEG in the image cache. cc |
||
|
|
d5ce8bcaeb |
fix(qt): offline-cache rows, Jellyfin playback, and media art on the bar
Three defects the owner found by using it, all mine, all from the same
root: a source word that stops at Plex.
1. AN OFFLINE-CACHE ROW PLAYED NOTHING.
audible: file not available at …/audio/tracks-cmaf/426056576
`SourceId::from_word` folds `qobuz_download` into LOCAL because the row
IS a file — but for a CMAF-cached download that "file" is the offline
cache's own container and only `qbz-core`'s offline tier can read it.
IC-12's first pass declared those rows ours and handed them to the
local audible step. They now stay on the Qobuz funnel, which is where
they were before stage 3 and where their `QueueTrack.id` — the real
catalog id — is meant to be resolved. The symptom was the bar, the
queue and MPRIS all changing while the previous track kept playing.
2. A JELLYFIN ROW CHANGED THE BAR AND PLAYED NOTHING.
`local_queue_track` folded everything that was not Plex or a download
into "local", so the row entered the queue wearing a source word that
disagreed with its namespaced id and carrying no server id at all.
Both media words are now arms of their own, and the hint carries the
SERVER's item id — which `claim` prefers, because it survives a cache
rebuild that renumbers rowids.
3. MEDIA ART NEVER REACHED THE BAR, THE QUEUE OR MPRIS.
Those surfaces resolve an `artwork_url` string with no row in hand, so
`classify` has only the SHAPE to go on — and a Jellyfin image tag or a
Subsonic coverArt id has no shape worth reading. `local_queue_track`
now stamps the token SOURCE-TAGGED (`jellyfin:<albumId>/<tag>`) and
`classify` grows one arm that DELEGATES it to
`registry().artwork_token` instead of guessing. One arm, not 111 call
sites, and it shares its cache entry with the grid because it is the
same call.
Verified in the running window: a Jellyfin album streams and plays —
`[JELLYFIN/STREAMING] 331 MB @ 12 MB/s`, decoder up, 44.1 kHz to the DAC.
429/429.
cc
|
||
|
|
a3aff5f1b3 |
fix(qt): resolve artwork per VISIBLE row again, not per row of the document
The albums grid got slower and the owner was right about it. This is the cause and it is mine. Stage 4 moved the artwork decision from window time to MAP time so that a row's provenance would still exist when it was made. That fixed bug 3 and introduced a worse one: a remote source answers "what is this token" by reading its CREDENTIALS, and the map runs over every row of the document, not over the ~50 on screen. `local_plex::settings()` is a `load_last_user_id` file read, a mutex and a SQLite query — per Plex row. Measured on the owner's library, release, same grid each time: before stage 4 (a string clone) ~2 µs/row after stage 4 (resolve per row) 39-92 µs/row <- the regression + memoised settings ~9 µs/row + resolve at window time again ~1.8 µs/row 1703 rows: map 67-71 ms -> 3.0-3.7 ms The art index now holds `(SourceId, raw token)` instead of a resolved `ArtRef`. Provenance still survives — nobody sniffs a string, which was the whole point — but the resolution happens where it always did, in `resolve_window_blocking`, over the keys actually on screen. Same information, right place. The settings memo stays, in both `local_plex` and `media_servers_qt`: those accessors are cheap gates that several callers ask repeatedly, and every writer invalidates so a connect, a disconnect or a user switch is still seen on the next read. `search_local` keeps resolving inline on purpose — it is the cortinilla's bounded 76-row result set, and the url/path split it needs is per row by definition. 428/428. Grid verified in the running window: Plex, Jellyfin and local badges all correct, covers resolving. cc |
||
|
|
f2d8111721 |
perf(qt): instrument the albums load — sql, map, serialize
The albums document is republished on every mount of Local Library (five
times in a 37-second screencast) and there was NO instrument anywhere on
that path, so "the grid got slower" could only be argued about rather
than measured.
Three segments, because three different things could cost: the SQL (which
now ATTACHes two mirrors and UNIONs three sources), the row mapping
(which resolves an ArtRef per row since stage 4), and the JSON handed to
QML.
Measured on the owner's library, release build:
1703 rows (local + Plex + Jellyfin) sql 59-88ms map 67-71ms ser 2ms
1271 rows (local + Plex) sql 42-88ms map 50-117ms
29 rows (local only) sql 3-7ms map 0.06ms
The two large configurations overlap within noise: 432 more rows cost
nothing measurable in Rust, and serialisation of 593 KB is 2 ms. Whatever
the grid pays for row count is paid AFTER this line — the QML parse and
the delegate rebuild — which is exactly what Rust could not see before
and what these numbers now bound.
cc
|
||
|
|
3cfce8a769 |
feat(qt): the media-server settings panel, driven and verified in the window
ONE component instantiated twice, because it is one form twice. Every difference between the protocols is a property set at the call site, so they are visible in one place instead of drifting across two files: "Test" proves the address for Jellyfin and only "a Subsonic server answered" for Subsonic (which has no credential-free endpoint), the password is discarded for Jellyfin and kept for Subsonic (no session, md5(password+salt) per request), and the sync cost is stated up front — 45.8 s per 4924 tracks against 0.81 s per 6678 — so a long sweep does not read as a hang. The credential never leaves Rust; the panel gets `hasCredential`. Assets: the colour marks for cards, the monochrome ones for rows. Both mono files arrived as BLACK ink and are invisible on a dark theme untinted, so they were normalised to the #ffffff master ink and joined `icon_tint_qt::MASTERS` — the port's only shader-free way to recolour an SVG per theme. The Jellyfin colour asset had a white rounded-rect plate behind the mark; it was stripped, because every other brand mark here is transparent and a white tile beside them reads as a rendering bug. Rendered with Qt itself and looked at before any of it was wired. CAVEAT, recorded in SourceIcon: the Subsonic mark is NAVIDROME's logo, and `subsonic` covers Gonic, Airsonic, Astiga and Ampache too. `ping` reports the server's own type and it is already persisted, so branching later is cheap — a known wrongness, not an unnoticed one. THREE BUGS THAT ONLY THE RUNNING WINDOW COULD SHOW, all found by driving it under Xvfb rather than by reading the log: 1. `MediaServerSettings is not a type` — a .qml missing from build.rs is not in the QML module. It resolves fine on disk, compiles nothing, and blanks the WHOLE settings page at runtime. All three existing audits passed. `qml_module_registration_audit.py` is the missing check and is now in the gate. 2. The toggle would not move. The panel reads its state from the settings DOCUMENT, unlike Plex's toggle which reads a bridge property, so every mutation has to republish it — connect, enable, disconnect and the end of a sweep now do. 3. Every Jellyfin album in the grid drew the LOCAL HARD-DRIVE badge: `local_rows::badge_source` had never been taught the two words and folded them into `_ => local`. Not cosmetic — that string is what the source chips filter on. `play_single_from_source` also grew the arm it needed: a media-server row from the recently-played rail has no library.db row to mint a LocalTrack from, so it resolves through `registry.tracks_of` instead. END TO END, from the UI, against the real server: toggle on -> type the address -> Connect -> "jellyfin sync: 500/4924 ... 4924 saved, 0 pruned, 4924 cached" -> Local Library shows 461 albums with their covers and the Jellyfin badge. The cached quality distribution is identical to what the server reported in the original protocol sweep. 428/428. cc |
||
|
|
63a4f2daf1 |
feat(qt): the Local Library union, the settings store and the sweep
Three pieces that only mean anything together: rows in the shared cache,
a union that shows them, and settings that decide whether either
happens.
THE UNION. `get_albums_metadata_page` grows a second, INDEPENDENT
attachment. `plex_cache` stays; `remote_cache` is the shared mirror, and
they attach separately on purpose — a missing Plex cache must not cost
the user their Jellyfin rows. `attach_best_effort` is extracted because
the failure mode of a botched ATTACH is a silently local-only library,
and a failure stays non-fatal: refusing to list any albums because one
server is off turns "Jellyfin is unplugged" into "your library is
empty".
The union takes an ENABLED-SOURCES list, not just a path. The mirror
holds every remote source, so the master toggle has to be a filter
rather than a detach: turning Jellyfin off must hide Jellyfin's rows and
leave Subsonic's alone. That predicate is interpolated into SQL, so it
is VALIDATED rather than escaped — only [a-z] survives, an all-invalid
list yields `0` (show nothing, the safe direction), and a test feeds it
`x'; DROP TABLE local_tracks; --`.
The remote group key is `<source>:<album id>`, matching Plex's
`plex:<hash>`. That namespaces two servers using the same album id AND
lets a source claim an album card that arrives with no source word,
which is every card that round-trips through a QML string property —
`recognises` grew that arm and `claim` strips the prefix, because
carrying it inward is how Plex ended up with two album keys only one of
its queries understood (IC-2).
THE SETTINGS. One store for both servers, keyed by a `server` column,
mirroring the shared-cache decision. What each protocol needs differs
and `is_configured` is the one place that knows: Jellyfin needs its
issued token, Subsonic needs a password because it has no session and
re-derives `md5(password + salt)` per request.
Two identifiers are minted once and persisted, and both are
load-bearing. Jellyfin's DeviceId, because re-authenticating under the
same one revokes the previous token — a constant would make two installs
log each other out, a fresh one per launch drops the last run's token.
Subsonic's salt, because rolling it per request makes every cover URL
unique and the artwork cache keys on the URL.
THE SWEEP. In the frontend, where the runtime and the progress UI are,
writing through the cache handle the SOURCE owns so sweep and reads can
never point at different users. Jellyfin gets progress and a delta path
(45.8 s for 4924 rows); Subsonic needs neither (0.81 s for 6678).
`prune_stale` runs only after a COMPLETED full sweep — a delta never
prunes, because it deliberately does not see unchanged rows and would
read every one of them as deleted. `last_sync_at` is stamped only on
success for the same reason.
Verified against the running binary, twice:
source registry bound: local_tracks=617 plex=connected
ephemeral_store=yes remote=none configured
jellyfin: minted a device id for this install
subsonic: minted a salt for this install
...and on the second launch, zero re-mints and the same values on disk —
which is the property that keeps a Jellyfin session alive.
qbz-qt 428/428, qbz-app 224/224, qbz-library 100/100, qbz-source 88/88.
Release build clean, zero warnings.
cc
|
||
|
|
51aa8ba081 |
test(source): walk the whole chain against both real servers
The unit tests prove each method in isolation against a hand-made row.
They cannot prove the thing that actually broke this project once: that
the pieces are CONNECTED. `source_wiring` shipped a registry whose
sockets were empty and every test stayed green, because a pure-function
test has no bound user and a structural gate only proves the OLD path is
gone.
So this walks it: sweep a real page off the server, write it through the
same mapping the sync will use, bind the source exactly as the app binds
it, then claim -> tracks -> meta -> artwork -> playback, and finish by
pulling real bytes through the url the source produced. Nothing is
asserted from a type; everything is asserted from a server's answer.
The assertion with the most teeth is the identity round trip. A queue
row as `cached_to_queue_track` builds it — namespaced id plus the
server's id in the hint — must claim back to the server's id, and the
same row must ALSO resolve with no hint at all, through the cache. Those
are the two shapes a producer can hand over, and a mismatch between them
is bug 2 wearing a new source's clothes.
`assert_serves_audio` issues a Range request, which is what the
progressive feeder does: it checks seekability, refuses a chunked
(transcoding) answer, runs the Subsonic error-envelope guard over the
body, and keeps the test to 64 KB instead of a 100 MB FLAC.
Against the bench, 3/3:
jellyfin swept 500 of 4924 -> album "...And Justice For All",
9 tracks, tier "hires", cover image/jpeg (tokenless,
self-keying), stream 206 audio/flac
subsonic navidrome openSubsonic=true, swept 500 -> album
"POWERS OF VOICE (Disc 1)", 13 tracks, tier "cd",
cover image/webp keyed on subsonic:al-…_0 rather than on
the credentialed url, stream 206 audio/flac
registry each namespace routed to its owner, and a Plex id still
goes to Plex
The third needs no server and runs everywhere.
cc
|
||
|
|
b09f03b739 |
feat(source): JellyfinSource and SubsonicSource — §10's acceptance test, paid
Design 02 §10 predicted a fourth source would be "one impl plus one
registration line, zero edits in any view". Two of them landed, and the
prediction held: no arm in any view, none in the playback router, none
in the artwork pipeline, no entry in a url taxonomy. Every one of those
became structural during stages 3 and 4, which is what this commit is
the receipt for.
One honest correction to that claim, now written in sources/mod.rs: it
was true only AFTER stages 3 and 4. From stage 1 the seam existed but
playback and artwork never asked it anything, so a source added earlier
would have claimed correctly and then been silent and coverless.
`sources/remote.rs` holds what the pair shares — the cache handle and
the row mappers — so they cannot drift the way the three copies of
play_audible did. The two conversions that are easy to get backwards
(ms -> s, Hz -> kHz) live there once, with a test.
Identity, and it is the part with teeth. A queue row carries BOTH the
namespaced id the grid published and the server's own id in the hint,
and `claim` prefers the hint: rowids are reassigned by a cache rebuild,
the server's id is not, so a queue that outlives a re-sync still
resolves. Recognition is by source word or namespace bit ONLY — never by
the shape of an opaque id. A Jellyfin item id is a 32-char hex GUID and
a Subsonic one is base62, but neither is guaranteed, and inferring a
source from a string's alphabet is the class of guess the seam deletes.
Both sources are tested against every neighbouring floor: Plex's 1<<40,
each other's, the ephemeral store's 1<<48, and a plain local rowid.
The Subsonic brand spellings — navidrome, gonic, airsonic, astiga — fold
to ONE source. They speak one API and QBZ reads them through one impl,
so a row stamped with the server's brand must still resolve; returning
None would make it Unclaimed and it would refuse to play.
Artwork differs between the two, and only because the protocols do.
Jellyfin's cover url carries no credentials, so it is stable and its
cache_key can be the url itself. Subsonic's carries the salt and the
size, so its key is the opaque coverArt id instead — keying on the url
would re-download every cover whenever either moved, and would put a
credential in a cache key.
Both playback tickets are Stream with the bit-perfect contract spelled
out: `?static=true` for Jellyfin, `format=raw` for Subsonic, and tests
that fail if `audioCodec` or `maxBitRate` ever appear.
One test had to be rewritten rather than deleted: `from_word("jellyfin")
== None` was the example for "unknown words are not guessed into Qobuz".
The property still holds and is now spelled with "emby".
qbz-source 84/84, qbz-media-cache 11/11, qbz-qt still builds clean.
cc
|
||
|
|
0dfec607c2 |
feat(media-cache): one cache table for every remote server
The Local Library union works by ATTACHing a remote server's mirror and UNIONing it with local_tracks, and today that mechanism names Plex inside a shared crate: `get_albums_metadata_page(..., plex_cache_path)` with `FROM plex_cache.plex_cache_tracks` hardcoded twice. Copying it per source means three Option<&Path> parameters and six UNION arms, and every future source pays again. This is the generic form: one table with a `source` discriminator, one ATTACH, one UNION arm. The schema is `plex_cache_tracks` with its columns renamed — all 19 map 1:1 onto Jellyfin and Subsonic, which is what made "generic" a measurement rather than a guess. Plex is deliberately NOT migrated here. It is a live table with 17145 rows on the owner's install, and folding it in alongside two new sources would make one commit where a regression could come from either. This table proves itself with Jellyfin and Subsonic first; Plex moves in as its own change with its own verification. The cost until then is a second ATTACH in one SQL builder, which is cheaper than the risk. Identity: the namespaced Local Library id is `FLOOR | rowid`, with Jellyfin at 1<<41 and Subsonic at 1<<42 — disjoint from Plex's 1<<40 and the ephemeral store's 1<<48, asserted against both. The payload is this table's AUTOINCREMENT rowid, NOT a hash of the server's id. Plex had to hash because its ids predate its cache, which is why resolving one costs a table scan; a new table has no such debt, and at ~50k tracks a 40-bit hash carries ~0.1% odds of a collision that would play the WRONG TRACK. Writes are UPSERT on (source, item_id), never delete-then-insert: the row id is published identity — it sits inside queue entries and session.db — so a re-scan must not mint a new one for a track that was already there. AUTOINCREMENT for the same reason: a freed id must never be handed to a different track. Both are pinned by tests. `prune_stale` keys on updated_at rather than a set of ids, so a 50k sweep needs no giant IN(...) — with the documented precondition that it only runs after a sweep that COMPLETED, since a dropped connection halfway through would otherwise read as "the server deleted everything it did not get to". 11 tests, including the id-namespace matrix against the two existing floors and the untagged-track ordering (75 of 4924 measured Jellyfin rows carry no track number and must not sort above track 1). cc |
||
|
|
0f41c44dac |
feat(subsonic): the protocol crate, with the HTTP-200 trap enforced in code
Stage 5's second protocol crate. One client for Navidrome, Gonic,
Airsonic, Astiga and Ampache — which is why the owner scoped this as
"Subsonic, not just Navidrome". Only Navidrome 0.63.2 was on the bench,
so the places servers are likely to differ are named rather than
smoothed over.
The dangerous property of this protocol is that it reports FAILURE with
HTTP 200. `error_for_status()` passes on a refusal. Three defences, all
in code rather than in a reviewer's memory:
- `parse_envelope` is the only way a body becomes a payload, and it
checks `subsonic-response.status` first. It also accepts XML on the
failure path, because the measured no-credential getCoverArt reply came
back as XML DESPITE `f=json` — a JSON-only parser would report "not
understood" for a perfectly clear "missing parameter: 'u'".
- `looks_like_audio` exists because a refused stream is a ~200-byte JSON
blob served as the audio body. That is the one way this feature could
reach the protected decoder with garbage.
- code 40 and 70 become their own variants, so a caller can act on
"re-authenticate" without matching on prose.
Two mapping decisions worth stating: `duration` is SECONDS on the wire
and milliseconds in QBZ, and `bitDepth` is **0** for a lossy track where
Jellyfin reports null — both sentinels fold to None so downstream sees
one vocabulary rather than two.
The salt is FIXED per install, not rolled per request. Rolling looks
more careful and would make every cover URL unique per request, which
re-downloads every cover on every pass since the artwork cache keys on
the URL. It buys nothing: the password is not transmitted either way.
The consequence is recorded where it matters — a cover URL carries
credentials, so callers must key their cache on the opaque coverArt id.
`detect_sweep_mode` ASKS rather than checks the server's name: search3
with an empty query is 14 requests for this library and getAlbumList2 +
getAlbum is 675, but the fast path is the behaviour most likely to
differ on a server that was never tested, and probing one page costs
~100 ms.
14 unit tests + 6 live. The live suite runs in PARALLEL by design —
Subsonic auth is stateless, so unlike Jellyfin nothing here can revoke
anything else's session. Against Navidrome 0.63.2, three consecutive
runs, 6/6 each:
ping ......... navidrome 0.63.2, openSubsonic=true
wrong pass ... Unauthorized, and the raw request really answered 200
sweep ........ Search3 detected; page 0 = 500 rows, 344 bit depths
per-album .... fallback returns 500 album ids, 17 tracks in the first
stream ....... 206 audio/flac, Content-Range total == the API's own
reported file size, so nothing was re-encoded
cover ........ authed image/webp 69 KB; unauthed HTTP 200
application/xml 251 B, rejected by the guard
cc
|
||
|
|
910f7aec4d |
fix(jellyfin): one session per test binary — a repeat auth revokes the token
Reported as a flake: four live tests passed and one failed Unauthorized, while other tests doing the same call succeeded. It is not a flake and not the server being slow. Jellyfin keys the SESSION on DeviceId, and a second AuthenticateByName under the same DeviceId REPLACES the first — the earlier token starts answering 401. Measured directly against 10.11.11 rather than inferred: same DeviceId: auth -> T1, auth -> T2; T1 = 401, T2 = 200 different DeviceId: auth -> A, auth -> B; A = 200, B = 200 Five tests authenticating in parallel under one DeviceId were therefore invalidating each other, and whichever finished first was left holding a dead token. Running with --test-threads=1 hid it, which is why the first run looked green. The tests now share one lazily-initialised session, which is also what the app must do. Two production obligations follow and are now written on `auth_header` where somebody will read them: the DeviceId must be stable PER INSTALL (persisted like local_plex::client_id — a constant would make two QBZ installs log each other out, a fresh id per launch drops the previous run's token and litters the server's devices page), and the app must authenticate once and hold the token rather than concurrently. Verified in the parallel configuration the report used: 10 unit + 5 live, three consecutive runs, 5/5 each time. cc |
||
|
|
e578d8a54a |
feat(jellyfin): the protocol crate, verified against a live server
Stage 5's first half. Sibling of `qbz-plex` and deliberately shaped like it, so `qbz-source`'s JellyfinSource will read like PlexSource: frontend-agnostic, HTTP + DTOs, no cache schema and no settings store. Three measured facts shape the file, and each one is a comment because each one is a decision somebody could otherwise undo: - `?static=true` is bit-perfect. Verified by md5 against the file on the server's own disk, not inferred from a content type. `is_direct_ response` encodes the tell a transcode leaves — chunked with no Content-Length — so a server-side policy silently resampling a hi-res track is caught rather than played. - Quality costs 45.8 s per 4924 tracks and there is no cheaper path. `Fields=MediaStreams` trims 29% of the bytes and saves nothing: the cost is server-side media-info hydration. The scan will own that knowingly, which is why `track_count` exists to give a progress denominator. - Cover urls need no credentials, so unlike a Plex thumb they are STABLE and their cache key can be the url itself. `stream_url` / `image_url` are free functions rather than methods. Two reasons and both are load-bearing: `Source::artwork_token` may not touch the network by contract, and a `reqwest::Client` cannot even be CONSTRUCTED without a rustls provider — which a unit test does not have. The mapper picks the audio stream BY TYPE, not by position: an embedded cover rides in the same MediaStreams array as an `mjpeg` with BitDepth 8, and taking [0] would badge a 24-bit FLAC as 8-bit. A lossy row keeps an absent bit depth rather than defaulting to 16, which would badge every MP3 as CD quality. 10 unit tests, plus 5 LIVE tests that skip unless QBZ_JELLYFIN_URL names a server. The unit tests prove the JSON shape is understood; only the live ones can prove the endpoint still exists, the token still opens it, and the stream is still direct. Against Jellyfin 10.11.11: 5/5 — 500-row page with 221 real bit depths of 4924 total, a Range request answered 206 with a `fLaC` bitstream, a tokenless cover, and a future-dated delta returning zero. cc |
||
|
|
604f336e24 |
fix(qt): wire the source registry to its stores — it never was
This is the regression report from the previous build, and the cause is that stage 1 built three injection sockets and nothing ever plugged into them. `set_creds`, `set_ephemeral` and `bind_user` had zero call sites in the entire frontend. It stayed invisible for two stages because nothing needed them: `claim` is pure, and `PlexSource::tracks`/`meta` read plex_cache.db through qbz-plex's own global. The first consumers that DO need them are playback and artwork — stages 3 and 4 — so routing those onto the seam turned a latent gap into a live break: - every Plex row: "plex: not configured (no Plex credentials configured)", so Plex playback died and every Plex cover in the Local Library grid resolved to ArtRef::Unavailable and vanished; - every LOCAL row: an unbound DbPool refuses all reads, so LocalSource found no track and local playback died too; - ephemeral rows had no session store to resolve against. Wired now, each to the ONE authority that already existed: PlexCreds over local_plex's settings store (read per call, never cached, so a connect/disconnect mid-session is picked up), EphemeralTracks over the session store, bind_user as the first statement of bind_per_user_stores, teardown on logout, and the unbound-window fallback so pre-login reads resolve the way local_state::with_db already resolves them. Also wired: the album identity mode. The grouping IS the query, so a metadata-mode user's album play would have resolved a different track list than the grid rendered. `report_wiring` logs one line at bind time asking each socket a question only a wired one can answer. The failure this fixes is silent by construction — an unbound pool and a disabled Plex look identical to a user with no local tracks and Plex switched off — and it stayed hidden because the tests are pure-function tests and the structural gate only proves the OLD path is gone, never that the new one is connected. Verified against the running binary, not the test suite: source registry bound: local_tracks=617 plex=connected ephemeral_store=yes qbz-qt 423/423, qbz-source 59/59. cc |
||
|
|
68235a4012 |
refactor(qt): the Local Library art index carries an ArtRef, not a string
Design 02 §9 stage 4, the Local Library half. `local_state.art_index` becomes `HashMap<String, ArtRef>`: the row mappers register what the row's OWN source says its artwork token means, at the last moment that provenance still exists, and the windowed pipeline reads that answer instead of re-deriving it. `artwork_qt::classify` is gone from every caller outside artwork_qt itself. It recognised a Plex thumb only because `local_plex:: is_thumb_path` was bolted onto it, so a token from any future source that starts with `/` — a Jellyfin `/Items/<id>/Images/Primary` — matched its filesystem-path arm and the pipeline opened it from disk: blank cover, no error, nothing in the log. That is bug 3. Producers wired: both row mappers, the folder tree (two sites), the ephemeral pane, the album-detail header and the Artists rail. The Artists rail needed real work rather than a cast. Its portrait comes from one of three chain tiers (custom image, Plex portrait, borrowed album cover) while `MergedArtist.source` describes the artist's LIBRARY provenance and is legitimately "mixed" — which is not a source a token can be interpreted by. `AlbumCredit` now carries its album's source word so a borrowed cover keeps that cover's provenance, and `MergedArtist` reports `image_source` beside `source`. `fetch_plex_misses` carries `(artKey, fetch url, cache key)` instead of collapsing the last two. They are not the same string — a Plex url is re-tokenized every pass and a Qobuz one carries a size query, while the key stays put — and collapsing them re-downloads covers already on disk. `artwork_qt` grows `cached_path_for` / `download_pairs`, the classification-free entries, sharing one download loop with `download_missing` rather than a second copy of the pool and the error-page guard. Still on the old path and NOT in this commit: the Home / queue / now-playing surfaces, where the string arrives as `HomeCard.art_url` or `QueueTrack.artwork_url`. Those need IC-7 (HomeCard has no source field) and the eight hardcoded `"source": "qobuz"` QML payloads first. 423/423. cc |
||
|
|
ffb35085c9 |
feat(source): each source interprets its own raw artwork token
Design 02 §9 stage 4 needs the art index to carry an ArtRef instead of a String, and the producers of that index are the row mappers, which hold no MediaRef — the windowed grid pipeline is keyed by art_key (§3.4). The two ways to give them one were both bad: sniff the string later (that is `artwork_qt::classify`, i.e. bug 3), or claim per visible row and let the source look the token up again, which for Plex is one plex_cache.db query per row on a cold grid — on the exact grid whose scroll cost is the open perf item. `Source::artwork_token` is the artwork analogue of `claim`: the source says what its OWN token means, at the moment provenance is still known. It is pure — no network, no DB, no filesystem — because the token is the whole input. This is what makes stage 5 free. `classify` recognises a Plex thumb only because somebody bolted `local_plex::is_thumb_path` onto it, so a Jellyfin token (`/Items/<id>/Images/Primary`) reads as a filesystem path — it starts with `/` — and the pipeline opens it from disk: blank cover, no error, nowhere to look. Dispatching by owner removes the guess. Three impls, each carrying the arm of `classify` that was really about it: Local (file:// and absolute paths, plus the CDN url an offline row can keep), Plex (the two declared thumb prefixes, Unavailable rather than None when there are no credentials), Qobuz (CDN url, cache_key on the RAW url so downscaling does not re-download per size class). An unregistered source returns None, not Unavailable: a row whose source word this build does not know has no art to wait for, and Unavailable would park it in the retry-when-connected bucket forever. 59/59. cc |
||
|
|
22033672a1 |
fix(qt): claim the row before the Qobuz funnel, and before casting it
Two remaining stage-3 consumers. `play_resolved_offline_aware` is the QOBUZ entry — the tier walk, the offline cache, the quality request — and every MyQBZ and album play arm sent its anchor there regardless of who owned the row. A local, ephemeral or Plex first track resolved and then failed AT THE PLAYER with "No Qobuz client available", which `myqbz_play_qt`'s own header documented as open. It was also unsafe: the offline cache is keyed on the raw u64, and a local_tracks rowid or a Plex rating key shares that small-integer space with older Qobuz ids, so a collision played the wrong track rather than failing cleanly (#638 fix 3's sharp edge). The funnel now claims the row first and hands a non-Qobuz one to audible_qt, which fixes all six call sites without touching a signature. The claim is gated on the core's cursor already sitting on that track — true for every caller, since they all set the queue and then play its anchor — so a caller that plays some other id behaves exactly as before instead of guessing. `cast_qt::resolve_local_path` becomes a PlaybackTicket consumer (IC-10). It was an ephemeral test plus a bare db.get_track on a QueueTrack.id, which is only sometimes a library.db rowid: a Plex row carries a namespaced id and an offline row carries the Qobuz catalog id, so both missed and reported "Local file not found" — an answer naming the wrong problem. Now File/DsdFile is servable and everything else is a named refusal, which covers every remote source by construction rather than the one word somebody thought to list. The Plex arm stays an early refusal so it does not pay a network round trip to build a url it would throw away. Structural gate: no plex_resolve_track_media / plex_rating_key left in qbz-qt outside comments. Four source-word matches remain on purpose — local_queue_track STAMPS the word (producer side, not consumer), play_single_from_source looks up a ROW rather than playing it, cast's early refusal above, and batch_all_qconnect_castable, which asks "does this row have a real Qobuz catalog id?" — a different question from ownership, and one SourceId cannot answer since qobuz_download maps to LOCAL while being castable. 423/423. cc |
||
|
|
a55e426330 |
refactor(qt): collapse the three audible steps into one PlaybackTicket match
Design 02 §9 stage 3. `local_playback::play_audible`,
`local_album_actions::play_audible` and `local_ephemeral::play_file` were
three near-copies of the same routine, each matching on `track.source`
by hand, and they had already drifted:
- `local_playback` carried the `plex_rating_key` ladder — one
engineer's reconstruction of a five-id table nobody wrote down, whose
fallback is right for the MyQBZ path and wrong for the LocalLibrary
one. `local_album_actions` held a copy of it, kept in sync by comment.
- both handed `track.id` to `play_local_file` for anything not Plex,
which is wrong for OFFLINE rows: their `QueueTrack.id` is the Qobuz
catalog id while the `library.db` row id rides in
`source_item_id_hint`, so the lookup missed.
- the CUE fast path existed in two shapes. `local_playback` compared
track ids, and since every virtual track of a CUE album is its own
row that test only passed when replaying the same virtual track — it
never fired for the case it exists to serve. `local_ephemeral`
compared file paths, which is the right question, but only for rows
in the session store.
`SourceRegistry::playback` now claims the row once and `audible_qt`
performs the ticket. The CUE test becomes exact for every source:
`audible_qt` records what its last `play_data` actually read, so "is
this container already loaded?" is answered against a real path with no
source lookup on the hot path.
`play_current_if_local` stops matching `Some("local") | Some("plex")`
(IC-12). Six source words occur on a queue row and the four it missed —
`user`, `ephemeral`, `qobuz_download`, `qobuz_purchase` — all name
files on disk, so auto-advance onto one fell through to the Qobuz
tier-walk, died with "No Qobuz client available", and
`auto_skip_unavailable` then declined to skip because that error is not
terminal-unavailable: one ephemeral track could stop playback dead.
The whole-body fallback in the streaming arm checks Content-Type and a
minimum length before the bytes reach the player. Plex cannot trip it,
but a Subsonic server answers a refused request with HTTP 200 and a
~200-byte JSON error envelope (measured against Navidrome 0.63.2), and
that must never reach the decoder.
Deleted: `play_local_file`, `play_plex_track`, `plex_rating_key` and its
three tests (the same cases are already pinned on `PlexSource`),
`local_ephemeral::play_file`, and the duplicate `play_audible`.
cc
|
||
|
|
7113ef5794 |
feat(source): add PlaybackTicket::Stream and emit it from PlexSource
Design 02 §3.3 predicted a `Stream` variant "when the documented progressive-streaming follow-up lands". It landed first: `local_playback::play_plex_track` already resolves the part URL and Range-streams it through `qbz_player::remote_stream`, keeping the whole-file download only as the fallback for a server that refuses Range. Stage 3 cannot route playback through the ticket while the ticket has no way to say "stream this". `PlexSource::playback` now calls `plex_resolve_part_url` instead of `plex_resolve_track_media`: the ticket resolves a LOCATION, it does not fetch a body. Dropping the old `resolve_track_media` fallback is not a behaviour loss — both functions run the same metadata request, the same parse and the same part-key extraction, and only then does the second also GET the body, so every way the first can fail the second fails identically. Carrying the url on the ticket also removes the duplicate metadata round trip the fallback performed. `duration_secs` comes from the queue row rather than the core's current track: a caller may be priming a row that is not current yet, where the core would answer about a different one. The url embeds credentials (Plex's token, and Jellyfin's / Subsonic's when they arrive), so `Debug` redacts the query string while keeping the origin — telling a LAN address from a plex.tv relay is exactly what the perf log lines exist to answer. Three tests cover the redaction and the `play_id()` arm. cc |
||
|
|
321d8fe753 |
fix(qt): align the playlist manager's card grid with its own headings
The grid delegate carried x: 32 on the delegate ROOT. A ListView owns its delegates' position, so an x set there is not reliably the one that survives layout, and it did not: the cards sat about 24 px left of "Folders (12)" and "Playlists (9)", which PmPageHead draws at 32 through its own Column. Headings at one inset and cards at another, on the same page. The list and tree delegates right below it already use the shape that works — a full-width delegate with the inset on the child — so the grid now does too. The inset lives where the view has no opinion about it. cc |
||
|
|
7e233104f5 |
feat(import): playlist files, JSON, ListenBrainz and Last.fm as import sources
Implements qbz-nix-docs/deferred-2.0.3/playlist-importer-expansion-design.md, whose §11 the owner ratified 2026-07-31. PlaylistSource is an enum, not a trait (ADR-007): the set is closed, there is no plugin story, and every arm resolves to the same ImportPlaylist the URL scrapers already produce — so the matcher, the 2000-track split, the sink and the summary never learn a second kind of source exists. import_prepared_playlist takes the playlist BY VALUE and import_public_playlist becomes a shim. That kills the double scrape the Tauri re-fetch-on-execute left behind, and it is not optional for the new sources: the rfd bytes of a picked file are gone by execute time and the dialog cannot silently reopen. It also closes a real race — open() resets the session, so a modal reopened between the Import click and the task's read handed a detached task None. FILES read the track list ONLY. The paths inside are never opened, copied or added to the Local Library, and the modal says so in a persistent row rather than a tooltip. Content sniffing beats the extension, with an HLS guard because .m3u8 is also the stream-manifest extension, and a utf8-then-Windows-1252 fallback because .m3u and .pls predate UTF-8 and "Björk" has to stay matchable. JSON locates the track list BY SHAPE. Depth is a DoS bound, not a locator: the array sits at 0, 1, 2 or 3 levels depending on the export, and a hard two-level rule drops an ordinary GraphQL envelope. Container demotion keeps a discography from importing album names as track titles. Walls at 16 MiB, depth 8, 2M nodes and 10k tracks. SERVICES are public reads with no authentication; a connected account only prefills the field. No qbz-integrations edge — its ListenBrainz client returns tracks with no duration and drops the playlist title, both verified in its source, and a direct GET /1/playlist/<mbid> reads all three in one request. A Last.fm STATION IS A RADIO, and ?page=N is not pagination. Measured against a live profile: pages 1 and 2 overlapped and batch sizes varied (20, 20, 36). The first cut fetched 20 pages, never hit its empty-page exit, and returned 469 rows holding 198 distinct tracks. It now dedupes as it draws, stops after two dry draws, and stops at MAX_STATION_TRACKS = 200 — the number shown in "Found N tracks" has to be one we can deliver, and a station is dynamic enough that a bigger single import buys little (owner ruling: no false expectations of "traeré casi 500" when we bring half). A failed draw no longer fails an import that already has rows; Last.fm rate-limits a burst of these. The summary reports THREE DISJOINT NUMBERS that sum to the total. skipped is now counted from the matches that found nothing, not inferred as total - matched, which folded duplicates into failures: that same import told the user 271 tracks were skipped when 255 of them had matched perfectly well and been deduplicated before the add. 23 new strings across the seven translated locales; en is the sparse fallback catalogue and needs none. cc |
||
|
|
c11bdf0654 |
feat(qt): bring back the New playlist modal, and delete the shortcut
The sidebar "+" called create_playlist(), which made a playlist named literally "New Playlist" and navigated to it. Four of the modal's five fields had no other door, and one had NO door at all: name hardcoded reachable in Edit playlist description always empty reachable in Edit playlist folder always root reachable in the sidebar row menu public always false NOWHERE offline-only from connectivity NOWHERE for an online user That last row is why this counts as a regression rather than a rough edge. offline_only decides whether the playlist is a local_playlists row (local:<uuid>, library.db, no Qobuz account, never uploaded) or a Qobuz entity, and the shortcut derived it from connectivity alone — so someone with a Qobuz session could not create a local playlist without pulling the network cable. Ported against CreatePlaylistModal.slint plus on_create_playlist (main.rs:21107) and on_submit (:21496). The submit branches where the reference branches, and a creation forced local by being offline KEEPS the flag, as the reference's own note says, so Edit can unmark it later to enable "Upload to Qobuz". Two calls made here: - Create rides the QbzPlaylistEdit singleton rather than getting its own, where the reference splits them. Same domain — a playlist's own metadata — with two documents that never interact, and a second QObject is a second boot() to forget; that bridge's header already documents a missing boot() swallowing publishes silently and forever. - The folder picker hides on the offline-only arm. folders_qt::move_playlist takes a u64 and a local:<uuid> has none, so the reference shows the control and discards the value. The shortcut is deleted rather than left beside the modal: a second creation path that skips it is the fork the port rule forbids. All thirteen strings were already in the catalogues. cc |
||
|
|
e1876c15f0 |
feat(qt): the artist page's radio disc goes live, with a start-up indicator
The disc was btnEnabled: false with a comment saying neither engine had a seam on a Qt bridge. Both do now. ArtistPageView.slint:426-461 opens a two-row dropdown — QBZ Radio (the local qbz-radio pool builder) and Qobuz Radio (/radio/artist) — and that is what this restores. The smart arm already shipped behind Discover's spotlight RADIO card and simply was not wired here; the Qobuz arm is new (get_radio_artist + the same enrich_radio_tracks the album radio uses, because its track objects are just as bare). Both labels already exist in all eight catalogues, so no new msgid. AND THE WAIT IS VISIBLE NOW. A radio is a fetch, an enrich and a queue write; until the first track resolves nothing on screen moves, so the click read as dead. QbzCircleAction gains the `loading` arm the reference already has (CircleAction.slint:18/:77) and QbzHome.radioPending carries the KEY of the radio being built — "album:<id>" or "artist:<id>" — so the Discover rail spins only the station that was clicked instead of all six. The spinner rides QbzShell.pulseMs, NOT a RotationAnimation. Qt Quick has no partial redraws, so a continuous animator presents the whole window at display rate (~1.2% GPU per present on the owner's hybrid laptop); a 60 Hz spin for the seconds a radio takes is exactly the bill the pulse law refuses. 12 degrees per ~30 Hz tick, and the Connections is disabled unless the button is spinning and on screen, so at rest it writes nothing. theme/QbzSpinner.qml still uses a RotationAnimation and is deliberately not reused. On the cards, `loading` also pins the hover disc up: it only exists on hover, so it vanished the moment the pointer left after a click, which is what made the click look swallowed. A RadioGuard disarms the key in Drop, so a bad id, a network error and an empty result all clear it. A disc spinning forever is worse than no indicator. cc |
||
|
|
e816436fd0 |
feat(qt): remember where each page was scrolled, across back and forward
The port of the block Slint repeats in 27 views (NavState.restore-scope /
scroll-restore / report-scroll). A .slint file cannot hand a component a
reference to a Flickable, which is why the reference inlines it; QML can, so
controls/ScrollMemory.qml exists once and 31 containers mount it.
Two divergences from the reference, both forced by the same fact — tabs are not
history entries in this port:
1. THE SCOPE IS NAMED BY THE VIEW, not derived from the entry by a scope_for
table. Slint can derive it because its entries carry the tab
(Favorites { tab }); here an entry is a bare route id, so a derived scope
would read "library" for all five Library tabs and pour the Albums grid's
offset into whatever tab mounted. Letting the reporter name its own scope
buys tab granularity for free, and the mismatch case degrades the right way:
an unmatched scope restores NOTHING instead of restoring the wrong thing.
2. THE RESTORE RE-ASSERTS instead of firing once. A Qt item view resets
contentY to 0 whenever its model is replaced, and several pages assign their
model from a binding whose inputs arrive over the network after the mount —
the album page rebuilds listCells from three deferred rails, so it hands its
ListView a new array up to four times in its first seconds and snaps back to
the top over a restore that already ran correctly. The offset is held and
re-applied until a 2.5 s deadline, or until the user takes over (a wheel, a
drag or a flick raises `moving`, and from then the position is theirs).
Diagnostics are off by default: QT_LOGGING_RULES="qbz.nav.scroll.info=true" for
the QML half, RUST_LOG=qbz=debug for the stamp/arm half.
cc
|
||
|
|
47cab502f4 |
feat(qt): remember the immersive shader scene, not just the panel triple
"Remember the last immersive view" covered view_mode / mode / split_panel and NOT the scene, so Plasma, the Spectral Ribbon, the Line Bed and Tunnel Flow were exactly the views it could not remember. Worse, apply_open force-reset the scene to Off on EVERY open edge, which is Slint's behaviour (main.rs:10300-10301) and is only correct because Slint never stored one. The scene now persists through a custom WRITE on the property, so every writer funnels through it: the menu rows, the `g` ring and ShaderSceneLayer's shader-error latch. That last one persisting Off is intended — a scene whose effect failed to compile on this box must not be what the next open restores. The open edge restores it behind three gates: the default-view pref is "remember", the box is on the shader tier (off-tier the scenes render BLACK, so restoring one would open immersive onto a black rectangle), and the stored number is a scene this build ships. Any of them failing falls back to the Off reset, which stays the fallback rather than disappearing. The pref key is new to the shared ui_prefs.json and unknown to the Slint typed struct. Worst case there is that a Slint write drops it and this falls back to Off; the document is not poisoned. cc |
||
|
|
221572b36e |
fix(qt): hydrate the view a session restore lands on
"Startup page = where you left off" opened Library on an EMPTY page: the feed was still [] until the user clicked Library in the sidebar. Same hole for Mixtapes and Collections. navigate_to is not the only door. A restore mounts its view by calling nav_qt::record directly from on_session_entered — it must not clear the landing tab or the detail latch of a session that has not started — and record is history bookkeeping, nothing more. The per-view lazy load lived inside navigate_to, so the restore path never ran it. Split that load into hydrate_view and call it from both doors. Home never showed the bug because load_home_once is its own unconditional phase of shell entry; `local` is absent from the arm on purpose and is not a gap, since LocalLibraryView loads its tab from Component.onCompleted. cc |
||
|
|
d1d96e6918 |
fix(qt): the sidebar playlists menu stops going translucent under ambient
Sidebar.slint:920-922 gives that panel the sidebar's OWN ambient treatment (surface-card at 50%), and the port copied it. That made it the one menu surface in the app that reads through the artwork while the dynamic background is on. Every other menu and flyout paints flat and opaque: QbzContextMenu and therefore CardMenu, SidebarRowMenu, ViewModeMenu and the audio flyout, plus NavFlyout, SidebarFolderFlyout, QconnectFlyout, GenreFilterPopup and LocalFilterPopup. Swept all of them; this was the only outlier. A menu is text being read against art that moves underneath it, not chrome, so the outlier loses. cc |
||
|
|
da8fc598f8 |
fix(qt): the immersive trigger only moves right on macOS
|
||
|
|
3366cc3997 |
fix(qt): Metal sides with OpenGL on the RHI mirror, not with Vulkan
The rule was derived from two data points and generalised into a negation:
owner's Vulkan + mirror=false -> upside-down (Vulkan needs the mirror)
Xvfb Mesa GL + mirror=true -> upside-down (GL does not)
"mirror on everything EXCEPT OpenGL" fits both, and it was wrong about the one
API nobody had run. On the Mac mini M2 the Line Bed comes out upside-down, so
Metal wants mirror=false like OpenGL rather than =true like Vulkan — the
generalisation reached a third platform on the strength of a shape it had never
been tested against.
Kept as a NEGATION with Metal excluded rather than rewritten as "Vulkan ||
D3D". Naming the APIs that DO mirror would silently stop mirroring on any enum
this file failed to list: an unknown member is `undefined` in QML and compares
false, so a typo or a future backend would degrade to "no mirror" with no error.
This way only Metal's behaviour changes and every other backend keeps exactly
what it was verified with.
All four RHI scenes carry the line, because the convention belongs to the
graphics API and not to the scene — a per-scene split would be the surprising
result, not the expected one. Only LineBed made it observable: it is the one
directional scene, so a flip reads instantly, where Plasma and TunnelFlow are
near-radial and hide it. RIBBON IS THE ONE TO CHECK NEXT — it is a spectrogram
with a time axis, so if the convention really is per-API it should have been
flipped too, and if it now looks flipped the rule is per-scene after all.
The three sibling headers said "mirror on Vulkan/Metal/D3D" and were corrected
in place; they had become comments that contradict their own code.
Built and smoked on Linux (gate green). The observation this fixes is the
owner's, on the Mac.
cc
|
||
|
|
9c47d5b2e7 |
fix(qt): the immersive view trigger moves beside the window controls
It sat at the band's LEFT edge, which on macOS is the traffic lights' corner — x ~ 7-85, the pill's exact spot. The band was therefore being inset sideways to make room for one 44px control, and that inset is what read as wrong: the shell HeaderBar's rule is right for the shell, where the lights head a full toolbar row, and wrong here, where the row is three floating glass pills over artwork. Moving the pill instead of the row is the smaller change and the better one. The left half of the band goes empty and the lights get that corner to themselves; the search field is CENTRED on the band rather than laid out after the trigger, so nothing else shifts. Every other platform is unaffected — the trigger simply changed sides. Its right margin RESERVES the capsule's expanded width (112) plus a 12px gap rather than anchoring to the capsule's live left edge. The capsule grows leftward on hover, so anchoring to it would slide this pill sideways every time the pointer crossed the other one — motion with no meaning. Reserving the maximum keeps both still. The radius becomes height/2, the same silhouette as the capsule it now sits next to. An earlier attempt dropped the WHOLE band below the lights; it is reverted here. It moved the search field and the capsule down to solve a problem only the trigger had. The capsule stays. Worth recording why, since it looks redundant on macOS and is only half so: its fullscreen toggle writes `Window.visibility`, which the green light already does — but its X calls `QbzImmersive.open = false`, which EXITS IMMERSIVE, and no traffic light can do that. It is one of three exits (Escape and the player bar are the others) and the only visible one in the chrome. Built and smoked on Linux, built on the Mac mini M2. The macOS geometry needs eyes on a Mac. cc |
||
|
|
d1fd99f947 |
refactor(i18n): qbz-i18n owns its catalogues; macOS immersive band drops below the lights
QBZ-I18N WAS NOT FRONTEND-AGNOSTIC, whatever its own header said. No slint or tauri entry in its Cargo.toml — and eight `include_str!` reaching into `crates/qbz-ui/translations/`. Deleting the Slint crate, which is planned before the Qt release, would have failed to compile `qbz-i18n` and with it `qbz-qt`, i.e. the entire app, over a data directory nobody read as a dependency. The extraction that created this crate took the code and left the data behind. The eight .po files move to `crates/qbz-i18n/translations/` (git tracks them as renames, so history follows) and the includes become `../translations/`. `qbz-ui/build.rs` borrows them back through `../qbz-i18n/translations` for as long as that tree exists — the direction of the dependency is what matters, and it now points the way the deletion needs. MACOS: THE IMMERSIVE BAND GOES BELOW THE TRAFFIC LIGHTS, not beside them. It was inset to their right with the shell HeaderBar's rule, which is right for the shell — the lights sit at the head of a full toolbar row there — and wrong here, where the row is three floating glass pills over artwork and a 44px pill parked against the lights reads as debris. Vertical clearance comes from `SafeArea.margins.top`, the same question the left inset asks in the other axis: with ExpandedClientAreaHint the client area covers the titlebar region and SafeArea reports how much of it is unsafe, with a 28px floor for a 0 reading. The band keeps `leftMargin: 24` on every platform, which puts the trigger's leading edge in the same 20-24 band the lights start in, and the 16px gap under them is the band's own margin reused rather than a new number. The view trigger matches the cluster's width on macOS (SafeArea left margin, 78 floor): stacked, a 44px pill under a ~78px cluster reads as misaligned even with the left edges lined up. Its radius becomes height/2 rather than a constant 18, or the widened pill would have been a rounded rectangle beside three circles. Every other platform keeps the natural 44px, and the glyph was already centred so it needed nothing. The menu opens downward from a band that now has more room under it, so the flyout has more clearance than before rather than less. Built and smoked on Linux (gate green) and built on the Mac mini M2 (3m32s, no errors). The macOS geometry itself needs eyes on a Mac. cc |
||
|
|
1352e499f9 |
fix(qt): the hand-written RHI items now compile on macOS
`build_rhi_items()` compiles the four QQuickRhiItems in `cxx/` with its own
cc::Build, and its macOS arm had never run: those files landed in
|
||
|
|
931cb06d38 |
feat(qt): ease the visualizers on the pulse, and make Plasma react
TWO FINDINGS, both of which falsified something that was being assumed.
FLUIDITY IS NOT FRAME RATE. Driven at QBZ_PULSE_MS=10 the shell presented ~100
frames/s — qt-batches confirmed it, and the script now prints the pulse it ran
with so two logs cannot be confused — and the bars looked exactly as they had at
30. They would: every published frame was applied as a STEP, so the extra
presents repainted the same discrete values three times over. What the Slint
build does differently is not rate, it is `animate bar-h { 90ms }` — it moves
BETWEEN values.
So the interpolation is back, ON THE PULSE, and it costs nothing. The old
interpolator was expensive because of its private 16 ms Timer, not because it
interpolated: it presented at its own rate, unsynchronised with everything else.
Easing on the pulse presents exactly as often as stepping on the pulse did — one
repaint per period either way, only the value differs. Self-parking survives: a
converged ease writes nothing and schedules no repaint. The price is lag, and it
is the honest one — k = 0.45 is ~83% there in three pulses (~100 ms), matching
the reference's 90 ms, against 46 ms of FFT window and 77 ms of Rust smoothing
already in the chain.
The from/to/progress triple is gone with it, and its consumers moved to `cur`.
Note for the next reader: `SpectrumBand` drove all three of its modes from
`Connections` on those properties, and a Connections handler naming a property
that no longer exists is NOT a compile error — QML warns at runtime and simply
never fires. That is how the whole band went dead at once; grep for
`on<Name>Changed` when removing a property, not only for direct reads.
PLASMA WAS DRIVEN BY THE SLOWEST SIGNALS IN THE PIPELINE while the fast ones
travelled beside it unconnected. `transientAmp`, `bandsLo` and `bandsHi` were
published, parsed and stored, and never handed to the item — the shader declared
them and received zeros — so everything ran off the energy bands, which decay
x0.85 per frame (~473 ms) against the bars' x0.65 (~178 ms).
Worse, `beat` stops being a beat on dense material: it decays x0.88 per tick, so
above ~3 hits/s it never falls far enough to come back as a spike and settles
into a shallow ripple around 0.7. A splat multiplied by it becomes a steady
glow, and the busier the music the flatter the picture — the most frenetic input
produced the least motion. `beatAc` subtracts a slow density floor, so a hit
reads as a hit at any tempo.
The scene itself was rebuilt around the reference the owner filmed (Butterchurn
on a percussion-heavy track). Its recipe is three parts: one thin annulus
injected per frame that the feedback warp marches inward, so the concentric
rings ARE the last N frames of audio laid out in space; retention high enough
for that to stack (this held 0.85, where a trail died in ~15 frames and nothing
ever accumulated); and angular structure from reading the spectrum BY ANGLE.
But a CLOSED ring was wrong, and the owner named why: a perfect inner
circumference reads as a drawn shape, hard-edges the field, and at this
retention successive rings pack into one solid disc — which is what killed the
ambient feel. The annulus is gated by angle now, so a hit paints arcs the
conveyor carries inward, the circle never closes, and the gaps stay open for the
background. Noise bends, frays and thins those arcs so they are made of plasma
rather than of compass.
The worms are back at full strength with tighter cores and a contrast curve — a
raw gaussian is all shoulder and reads as haze — and their orbits now reach past
the arc band instead of being trapped inside it. Colour comes from ONE
album-derived triad, so everything used to be a shade of the same hue; a
Rodrigues hue turn splits it into worms, arcs and an ambient wash that sit apart
whatever album is playing. That wash is what stops the background being void for
the foreground to be a cut-out against. A soft limiter replaces the hard clamp:
at 0.945 retention a clamp pins the middle to white and the structure vanishes.
Release build clean, offscreen smoke green, 421/421.
cc
|
||
|
|
730ae6998e |
feat(qt): windowed View-all artwork, a real per-rail default, Play later, MyQBZ tabs
VIEW-ALL PAGES GOT THE LIBRARY > ALL TREATMENT, in both halves. PlaylistBrowse windowed nothing: every card mounted, and a View-all of playlists runs to hundreds, each a PlaylistCard carrying a four-tile collage. AlbumCollection had solved that for the album pages and this one never got it. The band is sampled on a timer rather than bound to contentY — a direct binding re-evaluates every delegate on every scroll frame — and out-of-band cards keep their footprint as bare Items, so the scroll geometry and the infinite-scroll trigger never move. Covers arrived in ONE batch: Rust downloaded every missing cover of the page and then republished the whole document to attach the paths, so a page had no artwork until it had all of it, and the republish rebuilt every delegate on arrival. Both browse pages now ask for what is near the viewport and take the answers one key at a time through the existing sidebarArtworkWindow -> libraryArtworkReady seam. `attach_card_art` stays — it is a pure disk-cache lookup, so anything already downloaded still renders on the first frame with no request. The two now-dead republish helpers are gone with it. The AlbumCollection change is ADDITIVE for its nine hosts: those that pass no `flick` never report, the map stays empty and `artOf` falls through to the `artPath` they already publish. ITEMS PER CAROUSEL now has a real default instead of a lie. The first entry read "All", which was wrong twice over: an "all" in Qobuz terms is what the View-all page holds, and /discover/index hands each rail a different count anyway — measured on a live response, new_releases 26, ideal_discography 26, playlists 20, most_streamed 15, and qobuzissims / album_of_the_week / press_awards 10. No single label could be honest about all of them, so the default became a real uniform cap of ten and the entry says so. That is a behaviour change and not only a label one: the rails that used to arrive at 26 now arrive at 10, and the select is how a rail gets more. Ten therefore stops being a separate option because it IS the first one, and the number comes from Rust so the label and the cap cannot drift. The select also went 82 -> 124 wide: "Default (10)" elided to "Defaul…" in both the closed control and the popup. PLAY LATER on the album page's track rows. It was off because `enqueue_album_track` had only two arms — "next" and "everything else appends" — so "later" and "queue" both plain-appended and offering both would have been two menu entries doing one thing. The third arm lands on the manual block's tail via `add_track_later`, which is the whole distinction, and the QConnect route stops collapsing "later" into "queue" (route_track_to_peer has had a real later arm all along). Only then does the entry turn on. MIXTAPES <-> COLLECTIONS as a tab bar, the Discover/Library pattern where the tabs stand in for the page heading. They are separate ROUTES sharing one component, so a tab click navigates rather than flipping a local property: the sidebar highlight, the back stack and the `kind` Binding then agree on where the user is, and the page transition is the one every route change gets. Eight locales. Release build clean, offscreen smoke green, 421/421 and 216/216. cc |
||
|
|
e9f09a59a4 |
feat(qt): opt-in — a click on a section row opens its first tab
Settings > Appearance, OFF by default. With it on, clicking Discover lands on Home and clicking Library lands on All, instead of only opening the section's flyout and waiting for a pick. The rule lives in NavFlyout because the catalog does: "the first tab" is `entries[0]`, and routing it back through `activate` means the My QBZ section — whose entries are plain views carrying no tab — needs no special case. Both hosts call it from their row's click next to the open they already do, so the sidebar and the title bar cannot drift. The pref rides `settingsJson` like the two Purchases toggles beside it, so nothing is wired beyond the write and the republish every settings toggle already does. The read is a guarded parse: a bare JSON.parse in that binding throws on the pre-publish frame and would take the whole nav catalog — and therefore both hosts — down with it. Eight locales. Release build clean, offscreen smoke green. cc |
||
|
|
81e172457f |
fix(qt): migrate playlist_meta, break a router binding loop, zebra the playlist
RECENTLY PLAYED PLAYLISTS STAYED EMPTY THROUGH EIGHT TRACKS, a restart and a manual refresh — while the plays were sitting in the database the whole time. Read off the live file rather than reasoned about: `playlist_play_events` held 3, 9 and 10 rows for three playlists, so the context edge was working perfectly; `playlist_meta` was empty, and its `.schema` said why — no `covers`, no `own_image`. `covers` and `own_image` were added by inlining them into the CREATE. `CREATE TABLE IF NOT EXISTS` does NOTHING when the table already exists, so anyone who had run the previous build kept the six-column table, every upsert failed on "no such column: covers", and the rail JOINs meta against events — so it rendered its empty state with the evidence one table away. The failure was invisible by construction: swallowed into a warn, and the UI's empty state is also its correct state for a user who has played nothing. A brand-new table is only new ONCE. The moment a build runs anywhere its schema is legacy and every later column is a migration, so the pair moves to the same idempotent ADD COLUMN the album history already uses, and the upsert's failure becomes an error rather than a warn. Verified against a copy of the real database: the columns land and all 22 events survive. A regression test builds the exact pre-covers schema, records a play into it, migrates, and asserts the upsert works and the old event still counts — it reproduces the bug without the fix, and it also asserts a second `init_schema` is a no-op, since every launch calls it. The meta is written by `playlist_qt::load`, so the three playlists already in the events table surface as soon as each is opened once. BINDING LOOP IN THE ROUTER, and this one was self-inflicted: "QML Loader: Binding loop detected for property source". `_stampRoute` runs inside the `source` binding, and it read the last path from a `property string` and then wrote it — a binding depending on a property it mutates. QML detects the cycle, warns, and stops re-evaluating, which is a broken router and not merely a noisy log. The holder becomes an object whose MEMBER is mutated: no change signal, so the dependency is satisfied once and never fires again. (The second warning on the same file, the `when` loop on the nav-flyout `activeTab` Binding, is untouched by any of this work and predates it. Left alone deliberately — it is a warning on a working feature, and rewriting that Binding is its own change.) ZEBRA on the playlist detail's track rows, like the local album page. TrackRow already owns the stripe, so it is one property. Release build clean, offscreen smoke green, 421/421 and 216/216. cc |
||
|
|
907d366d71 |
fix(qt): Library artwork loaded one row; add Refresh content to the configurator
LIBRARY ALL / ALBUMS LOADED EXACTLY ONE ROW OF COVERS and then nothing until the user nudged the scrollbar. The grid and the list reported their artwork window on contentY, model, width, visible and completion — but NOT on height, and their height is gated on `visible` (`height: visible ? parent.height - 16 : 0`, the 2026-08-17 change that stops a hidden body from refilling). QML gives no ordering between "the `visible` change handler runs" and "the `height` binding that depends on `visible` re-evaluates", so when the handler wins the reporter reads height 0 and computes `lastRow = ceil(0 / cellHeight) + 1 = 1` — a window of exactly `cols` items. One row. Nothing fires again until a scroll, which is why moving the scrollbar "fixed" it. Playlists were unaffected and that asymmetry is the tell: PlaylistCollage dispatches its own urls per delegate, independent of the window, while album cells live off `artMap`, which only the window report fills. The control case is in the same tree — LibraryAlbumsList, the playlists list and the Artists rail all report on height and none of them has the defect. The two bodies that lack the handler are exactly the two whose height is gated on visibility; `width` had one because width always changed, `height` never did before the gate. REFRESH CONTENT in the Discover configurator. The index is fetched once per shell entry (`HOME_LOADED` latches and `reload_home` is never called again), so a session shows the page it opened with — no new catalogue rows, and none of the plays made since, because the recently-played rails are built from the local history in that same pass. Everything else in that modal re-renders from the cached candidates, which is precisely what cannot produce fresh ones. The invokable already existed for the error-state retry; this is the door. The Reset button's markup became a shared FooterButton so the pair cannot drift. cc |
||
|
|
f0d753a751 |
fix(qt): fade on tab switches, playlist recents artwork, per-rail sizes, wheel
Four defects from the smoke of
|
||
|
|
26ceeab828 |
feat(qt): mixed playlists, page fade, category filter, per-context recents
Six parity fronts against the Slint/Tauri references, plus the applied-filters
tooltip the app never had.
MIXED PLAYLISTS — the bug. A mixed playlist is a QOBUZ playlist carrying local
and Plex rows through library.db's playlist_local_tracks / playlist_plex_tracks
sidecar tables. This port could WRITE those rows (the picker) and COUNT them
(the Playlist Manager badge), but playlist_qt::load built its rows exclusively
from the Qobuz API response, so a track added to a playlist never appeared on
the page it was added to. Two comments elsewhere claimed the merge already
happened; they were describing the reference. Ported literally:
read_sidecar_rows_blocking (healing + bulk Plex resolve) and interleave_rows
(absolute-slot claim, same-slot collisions all emit), merged in load, with the
merged queue adopted as the open-detail snapshot.
Routing had to split with it. local_playlist_qt::open_id() was the one
predicate for every action, and a mixed detail adopts that same snapshot — so
it now means "a local:<uuid> detail OR a mixed one", which is right for
play/shuffle/row-play and wrong for the writes. Remove and reorder route on the
new local_detail_open() instead; without that, removing a local row from a
Qobuz playlist wrote into the local-playlist repo, found nothing, and reported
success. Removal by source is a third arm: the Qobuz arm parses the display id
as a u64 playlist_track_id, and a local row's display id is a library.db rowid
in the same numeric space. Reorder is refused on a mixed detail for the same
collision, in Rust and in the view.
PAGE FADE. 300 ms OpacityAnimator on the mounted page, OutCubic, honouring
reduceMotion. Zero added latency: the router's Loader is synchronous, the UI
thread is frozen through the mount and no frame exists in which a fade-OUT
could be shown, so the page snaps transparent in the same turn as the rebuild
and only the reveal is animated. An Animator and not a NumberAnimation because
it runs on the render thread — measured 0.53 vs 0.11 of progress with the GUI
thread blocked — and not on the shared pulse because the pulse arrives on the
very thread the mount blocks.
The first cut faded a veil coloured from the pane, and that was wrong in a way
that only shows in the configuration the app actually runs in: AppShell paints
the pane surface-main@0.22 while the ambient background is on, so a veil at
full opacity covered 22% of the page and the fade read as a flicker. Fading the
page has no such failure mode and keeps the ambient art visible.
SPLASH COPY. The login screen was already 1:1 (18 msgids, verified by diff).
The splash was missing the legal disclaimer the reference pins to the bottom of
the screen, drew "QBZ" / "QOBUZ PLAYER" as bare literals (English in all eight
locales), and hardcoded the dark palette — black on the other 34 themes.
CATEGORY FILTER on the Home playlists rail. Slugs now travel per card and the
offered set comes from the same /discover/index response, so the filter is
client-side over the cached 40 with no extra request. The selection lives in
Rust because HomeView is destroyed on every navigation; the reference keeps its
own for the same reason. New trigger + popup, and a `leading` slot on
QbzSectionHeader to hang it off the title line.
APPLIED-FILTERS TOOLTIP. A third QbzTooltip mode taking [{group, values}],
below the control with an upward caret. Overflow is staged summary and never
scroll — the overlay is enabled:false by construction, so a scrollable tooltip
could not be scrolled. Wired where a filter is INVISIBLE (collapsed to a count
or a glyph): the genre chip in four hosts, the Home tags, the Local Library and
Purchases funnels, the Artist Scene genres.
RECENTS BY CONTEXT. QueueTrack already carried context_kind and exactly one
function threw it away: record_queue_track wrote both album histories on every
track start whatever the user had put on, so one 40-track playlist filled a
24-entry album rail with that playlist's contents. album_play_events gains an
additive context_kind column (the other frontend names its columns on insert,
so it writes '' and '' counts as an album — no history reinterpreted), plus a
new playlist_play_history.db and a "Recently Played Playlists" rail, default on.
A separate file rather than a field in recently_played.json, which is a
destructive round trip between frontends: serde drops what it does not know and
the next write puts the truncated object back.
ITEMS PER CAROUSEL, recovered from Tauri and never ported to Slint. It lives in
a NEW TABLE of discover_prefs.db, not in the prefs blob: to_json rebuilds the
object from the keys the running binary knows and save overwrites the whole
column, so a new key would survive only until the other build's next toggle.
Default is uncapped, i.e. today's behaviour.
Two DiscoverySectionId arms were added to the frozen Slint tree, obliged by the
shared enum's exhaustive matches — same two-line follow MostPlayedAlbums took.
Not verifiable here: compiling that crate pulls qbz-ui.
Also fixes two recommendations_qt tests that had been red since
|
||
|
|
047db8ea9f |
perf(qt): recycle the album page's track list, on a uniform-cell ListView
DID NOT DELIVER ITS GOAL. Read this before extending it: the wheel scroll on
a 189-track album is essentially unchanged. The page is correct and the
structure is better, but the theory that motivated the work is not supported.
What the work assumed: the ceiling was the `Repeater`, which has no windowing
and so built all 189 track blocks — each a Column with a disc-header Loader, a
work-header Loader (both gated on DATA, never on the viewport, so every act and
scene header in a Wagner box was alive) and the band-gated row. Roughly a
thousand structural items before counting anything visible.
What it is now: one ListView over a UNIFORM 10px cell tape. Each piece of
content owns a run of cells, its visual item lives in the first cell of the run
and paints over the inert ones — 50px row = 5 cells, 40px header = 4, rail =
29. Uniform cells are the point: measured in a standalone probe, a ListView
with variable delegates ESTIMATES contentHeight and ignores an explicit
override (5703 then 5396 against an exact 5400), which makes the scrollbar
thumb resize and skip as sizes are discovered. With uniform cells,
count * 10 is exact from the first frame, so the scrollbar the owner had just
called the best gesture on the page keeps its behaviour.
Falling out of the design, replacing machinery that was reverted a commit ago:
`cacheBuffer` incubates upcoming delegates asynchronously in idle time, so a
heavy rail is built before it is visible without a hand-rolled band, a settle
Timer, or an unload grace — and without the "reach the bottom, then wait" that
made the owner reject that approach.
Two correctness items that came with it:
* `header` is a ListView property. The page's own `header` had to become
`albumHeader` or every `header.x` binding inside the view would silently
resolve against the view's header instead. One handler was missed in the
rename and QML refused to create the component at all — the whole page
rendered black, and no static gate caught it because the failure happens at
instantiation.
* `recycleActive` on the shared TrackRow, default true, gates the row's
Connections. Without it a pooled row keeps listening and consumes a cache
or favourite settle signal meant for a different track. Every other host
is unaffected by the default.
WHAT THIS RESULT MEANS, for whoever picks it up: delegate count and
construction cost are NOT what makes this page feel heavy. Removing ~950
structural items changed nothing the owner could feel, which points at
per-frame cost instead — consistent with his own observation that the page is
sluggish ALWAYS, not only while it loads, and with the documented finding that
this shell's bill is full-window presents rather than scene work. Do not
re-attempt a structural rewrite here on intuition. Take the QSG_RENDER_TIMING
split (polish/sync vs render) on this exact album first; the earlier reference
scene measured polish=0ms, render=2ms, swap=12-24ms.
Design and probes came from an independent review; the rebase onto the current
tree, the black-screen fix and the API verification are mine.
cc
|
||
|
|
3fdccae22e |
Revert "perf(qt): the album page's rails exist only near the viewport, and never build mid-scroll"
This reverts commit
|
||
|
|
313a7e7e4e |
fix(qt): the local album page gets its zebra, across the whole row
The shared TrackRow has had a `zebra` flag all along, off by default, and the Qobuz album page turns it on. The local one never did — and turning it on at the call site would not have been enough either, because LocalTrackRow wraps the shared row without forwarding the flag. Forwarding it is still not the fix. TrackRow tints its own background rectangle, which is `parent.width` wide, and this wrapper hands the shared row `root.width - 26 - 46`: a 26px gutter for the source glyph and 46px for the context menu the wrapper draws itself. A stripe painted from in there stops 72px short and leaves both controls outside it — which is what the smoke caught. The stripe is therefore painted by the wrapper, full width, declared before the shared row so it lands underneath and takes no pointer events. Same colour literal and same parity rule as rows/TrackRow.qml, so a local list and a Qobuz list stripe identically. cc |
||
|
|
5b99a6698e |
perf(qt): the album page's rails exist only near the viewport, and never build mid-scroll
The three SectionRails at the foot of the album page were gated on `visible:`
alone, and in QML `visible: false` stops an item PAINTING, never building. With
data present all three were fully constructed the moment the page mounted —
three horizontal ListViews of AlbumCards, every cover a RoundedImage with two
render targets — whether or not the user ever scrolled down to them. On a long
album nobody reaches the bottom of, that is pure cost, and it sits in the SAME
Flickable as the track list, so those layers are in the scene for every frame
the list scrolls.
Each rail is now a fixed-size `RailSlot` with the real rail behind a
`Loader { active: inBand }` — the shape `rowSlot` in this file already uses.
Three things this had to get right:
* The slot reserves 286px whether or not it is built (28 header + 12 spacing
+ 246 card band, the figure RailSkeleton already stands in with). A slot
sized to its Loader would change the page length as rails build and slide
the content under the user mid-scroll.
* The band reads the slot's OWN `y`, which the Column assigns from preceding
siblings only. The variant measured against a parent-derived height is a
dependency cycle; it cost a day on 2026-08-18, because QML breaks the loop
silently and every slot then reads as in-band.
* An inline `component` does not share the enclosing document's id scope, so
`page` / `pageFlick` / `root` are not resolvable from inside RailSlot. The
geometry and the cover map are handed in as properties instead. Left as it
was, the failure would have been silent: a band evaluated against zeroes.
Two follow-ups from the owner smoke, both about WHEN the Loader flips:
* UNLOAD IS DELAYED 600ms. This page grows in stages (primary document, then
deferred rows), and while the list is missing the page is short enough to
put these rails inside the band. They began building, the rows landed, the
page grew by thousands of pixels and the Loader tore them down again —
and since a ListView creates delegates through incubation, tearing down
mid-flight is exactly the "Object or context destroyed during incubation"
warning that appeared three times, once per rail, on every album open.
* BUILD WAITS FOR REST. A Loader creates its content synchronously, in one
frame, on the GUI thread. Building the instant a rail crossed into the band
put that spike inside a wheel scroll — measured by the owner as the wheel
getting worse exactly as the scrollbar got much better, which fits: a drag
ends before the spike lands, a wheel scroll does not. Creation now waits
for `flickY` to stop ticking. That signal works for the wheel and the
scrollbar alike, unlike `Flickable.moving`, which a scrollbar writing
contentY directly does not necessarily raise. Already-live rails stay live:
this gates the build, never the keep.
cc
|
||
|
|
77e9feccff |
feat(qt): album header — 260px cover, no divider rule, a gradient that lands mid-toolbar
Four owner-requested changes to the Qobuz album page, validated by smoke. 1. THE DIVIDER RULE goes invisible rather than away. The gradient band sizes itself off that item's `y` and the whole column below is positioned after it, so deleting the item would move the page; only the paint was unwanted. 2. COVER 224 -> 260. Now one constant: the literal was repeated at five sites — the real header, its skeleton twin, and the two text columns that measure `parent.width - 224 - 32`. Changing only the header would leave the skeleton at 224 and make the page jump 36px when the real one replaces it. The header Row has no explicit height, so it grows on its own. The URL tier is unchanged (`best()`, per the 2026-08-16 ruling that no existing surface is re-tiered DOWN); what decodes is RoundedImage's scaled derivative at the drawn size, so 260 over 224 is ~35% of a small image. 3. THE ATMOSPHERE REACHES MID-TOOLBAR — the 8px spacer below the divider plus half the 52px toolbar band — so it dies across the quality chip and the search box instead of stopping at the divider. A named constant rather than a `mapToItem` chain: that chain crosses three nested containers and goes stale when an ancestor moves without notifying. 4. THE FALLOFF IS PROGRESSIVE. The tint held FULL strength to 0.82 and died inside the last 18%, drawing a visible seam — and extending the band made it worse, not better, because the falloff is a FRACTION of the height: the solid part grew while the fade stayed as short. The page-surface overlay underneath started at 0.82 too, putting a second seam on the same line. Both now decay over the bottom ~60% on an ease-out curve, stepped through explicit alphas because a GradientStop interpolates linearly between the two colours it is given. cc |
||
|
|
c02e7713d9 |
perf(qt): stop republishing the Recommendations document for artwork
Covers that resolved after the document was published forced a second
`publish_snapshot`, and a republish hands QML a brand-new array:
`QQuickItemView::setModel()` then resets each rail's scroll offset and tears
down its QQmlDelegateModel. So every landing batch rebuilt all nine rails'
delegates, which is what a screen capture of a cold Recommendations load
shows — the rows visibly reassembling while covers trickle in, filling out of
order both between rows and within a row.
The fix is the pattern this port already applies twice: covers travel as a
url-keyed PATCH and the document is left alone. `foryou_qt::emit_foryou_art`
is the precedent for the For You rails, and `myqbz_bridge` cites it verbatim
for MyQBZ; the reco rails were the remaining holdout.
The download path is deliberately untouched. This changes only how a resolved
path is ANNOUNCED, never what is fetched or cached, so every other consumer of
the shared image cache — the now-playing bar, the queue, the `recent` rails —
sees exactly what it saw before.
Two traps this had to close, both already paid for once on the For You side:
* `sectionArtOf()` falls back to `item.artPath`. `recoArt` only ever holds
Recommendations urls, so for a Home / Editor's Picks / For You card the
lookup misses and the binding returns what it always returned. No surface
that reads a baked `artPath` can lose its cover to this — by construction,
not by care. The pinned rail, fed by its own store, is untouched.
* `anyItemArtPending` now consults the map. A patched cover never gets its
`artPath` back through a republish, so the bare "artUrl set and artPath
empty" predicate would be true forever and pin the 900ms skeleton pulse
Timer on.
`refreshRecoArt()` on mount is the third: HomeView is destroyed on every
navigation while this document is published once per session, so a fresh mount
must be re-handed what already resolved or the covers come back blank.
Weekly's two rails still republish their own narrower property; their blast
radius is two rails, not nine, and widening this change was not worth it.
Verified by owner smoke: the three checks (nothing else lost its covers, the
rails stop reassembling, covers survive navigating away and back) all pass.
cc
|
||
|
|
101d0e25d4 |
perf(qt): one rebuild per query in the local grid, and reveal covers in order
Two changes to the local albums grid, both BUILT BUT UNSMOKED — Local Library is best-effort for the next release, so this is committed to preserve it rather than because it was validated. 1. ONE REBUILD PER QUERY. `rows` and `groups` are two bindings over one query: the host derives `albumsVisible` from the search box and `albumsGrouped` FROM `albumsVisible` (LocalLibraryView.qml:378-380). With a handler each, a single keystroke rebuilt twice, and the first of the two was WRONG as well as wasted — it chunked the new rows against the previous groups. Turning grouping off did not save it either, because `groupRows` returns a fresh `[]` every time and a new array is still a change. Coalesced onto a zero-interval Timer: one rebuild per event-loop turn, reading both new values. `Component.onCompleted` stays synchronous so a mount does not show a frame of empty grid. `[coltiming]` logs every rebuild with its cost, so a smoke run can falsify the coalescing: one line per keystroke means it holds, two means it does not. Timed around the work, never from a deferred callback. 2. ORDERED COVER REVEAL. The cold-thumbnail pool starts work in window order but finishes out of order, since decode cost varies per cover, so covers appeared scattered across the grid. Ordering the emission in Rust would have made it worse: one slow decode at index 3 would hold 4..N behind it, adding latency instead of hiding it. Instead arrivals are applied behind a front that walks the window at a fixed rate; a cover landing behind the front is applied at once. The front advances every tick unconditionally, which is what makes a stall impossible, and a key with no ordinal is released immediately. Note for whoever picks this up: this reveal covers the LOCAL path only. The uneven fill visible in ordinary navigation is HomeView's For You rails, which take a different route (`forYouArt` via `onForyouArtReady`). cc |
||
|
|
ed081cac6b |
fix(qt): put the local albums scrollbar and A-Z strip on the real edge
LocalAlbumsTab pads its content 32px on the right, and both the scrollbar and the jump strip lived inside that padding — so the bar sat 32px from the window edge (52 with the strip up) while LibraryView.qml:1281 puts the same control at 4, or 34 to clear its strip. Side by side the local grid's bar reads as floating away from the edge. The collection now takes two measurements, both from the WINDOW's right edge: `scrollBarInset` (where the bar should land) and `hostRightInset` (how far the component's own edge already is from it). The bar's margin is their difference, which goes negative and overhangs — safe, because the component root is a plain Item; the `clip: true` in that file is on the ListView, a sibling. Both default to 0, so LocalArtistsTab and LocalFoldersTab, which have not opted in, are untouched. The A-Z strip moves out to 12 from the window edge, matching LibraryView.qml:1269. cc |
||
|
|
c0fe8a80d3 |
fix(qt): silence the TypeError flood the constant-cell grid introduced
Regression from the previous commit's first fix. Pinning the inner Repeater
to `root.cols` is what made recycling free, but it also means the tail slots
of a partly-filled row are now real, live cells with no album behind them.
The delegate anticipated that — `modelData` resolves to null and the cell
sets `visible: false` — and that is not enough: in QML `visible: false` stops
an item PAINTING, it does not stop its bindings EVALUATING. Every
`cardCell.modelData.id`, `.title`, `.artKey` on an empty slot therefore threw
once per evaluation, which during a scroll is a continuous log flood (the
same ten line numbers, over and over, seen in a smoke run).
`modelData` keeps its meaning — "is this slot filled?" — so `visible` still
works. The bindings now read `cardCell.slot`, which is the same object when
the slot is filled and a frozen all-empty object when it is not.
`artWanted("")` is false and `artPathOf("")` is "", so an empty slot stays
silent through the skeleton's probe arm too.
Deliberately NOT a Loader gated on the data: `active` would flip every time a
full row recycled into a partial one, rebuilding the AlbumCard — precisely
the construction churn the constant cell count exists to remove.
cc
|
||
|
|
bd9aec1440 |
perf(qt): make the local albums grid actually recycle, and stop it asking
for the whole library
The owner's report is precise and is what this targets: the grid OPENS
fine; scroll, scrollbar drag and search are what feel sluggish, and "the
first two keystrokes hurt" — the ones where the filter still matches
hundreds. That shape is a model swap, not a slow filter.
UNSMOKED. Committed to save progress; no binary matches this tree.
1. FIXED CELL COUNT. `reuseItems` was already on the ListView and bought
nothing, because the pool recycles the row SHELL while the inner
`Repeater { model: modelData.items }` got a new array on every recycle
and rebuilt its AlbumCards anyway. The card is where the cost is: ~29
items, a RoundedImage with TWO render targets (its own layer plus a
separate mask layer), image probes, a skeleton. The shell was reused
and the expensive part was not. The Repeater now runs over `root.cols`
— a constant — so cells survive recycling and a scrolled or refiltered
row only reassigns `item`.
2. THE HEADER WINDOW BUG. `loIdx` fell back to 0 whenever the first
visible entry was a group header, because headers carried no `base`.
In grouped mode, scrolling with a header at the top of the viewport
asked `queueWindowReport` for artwork from album 0 through the current
letter — hundreds of covers dispatched and later evicted, per scroll.
Headers now carry the flat index their group starts at.
3. SEARCH DEBOUNCE on the albums box, which the Tracks box in the same
file already had. `albumsSearch` is not a query, it is the filter
input: writing it per keystroke refilters 1267 albums, rebuilds the
entry list and hands the ListView a new model, tearing down every
delegate. Note the tradeoff — this ADDS 200ms before any work, so the
box may feel more deliberate even as it stutters less.
Also here, from the album pages (lower priority per the owner — their
cost is paid once and the view is fine in use afterwards): the disc
header, which carried a CardMenu per TRACK, is Loader-gated; a genuinely
quadratic multi-disc scan in LocalAlbumView is hoisted out of the
per-row call; and the track rows are windowed. That windowing was
measured broken twice (a band computed against its own parent's derived
height — a dependency cycle) and the fix is unverified.
Credit where due: 1 and 2 came out of an independent review that also
judged the previous round's lazy menus and `reuseItems` "mostly beside
the main point". That is a fair call.
cc
|
||
|
|
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 |