f539149a30cac930975459fa408fc9948e641e5e
215
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
69ed612077 |
refactor(frontend): retire Slint application
cx |
||
|
|
d244213a64 |
fix(library): harden cross-source catalog data
Preserve per-disc and collection artwork, multi-value genres and remote metadata across Local, Plex, Jellyfin and Subsonic. Apply source, format and quality filters before pagination, reject invalid CUE rows, and keep Qt library surfaces aligned with the selected physical version. cx |
||
|
|
480b4b46df |
feat(library): scan local roots incrementally
Stream each registered root into bounded extraction batches, persist fingerprint and checkpoint generations, and prune only after a complete reachable pass. Add local watcher hints plus separate periodic local and network reconciliation. Preserve CUE identities, retry unreadable files, and keep cancellation, traversal errors, offline roots, and overlapping roots from authorizing destructive cleanup. cx |
||
|
|
d39edf5850 |
feat(library): bootstrap derived catalog
Build the catalog side by side from read-only local, Plex, Jellyfin, and Subsonic cache snapshots after the first useful Qt frame. Persist bounded batch checkpoints, preflight disk capacity, validate materializations, and activate generations atomically while preserving the existing reader as fallback. cx |
||
|
|
a0d8a7fc94 |
feat(library): add derived local catalog
Introduce a frontend-agnostic generation schema with stable source identities, materialized album and artist structures, trigram FTS, matching sort indices, and descriptor-bound keyset queries. Add deterministic SQLite coverage and an explicit one-million-track query gate. cx |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
5bebbf0d91 |
fix(qobuz): replace the bundle token cache atomically
`save_cached_bundle` wrote in place with `std::fs::write`, which truncates the file before the first byte of the replacement is written. Any kill in that window leaves an empty or half-written `bundle_tokens.json`, which parses as nothing, so the tokens are gone and the next launch pays a full live extraction: a ~7 MB bundle.js fetch behind a 45s timeout, up to three attempts. The window is not rare. `refresh_bundle_if_changed` rewrites the whole file on every launch that finds the version unchanged, purely to touch `fetched_at`, so a warm cache is destroyed and rebuilt each time the app starts. The bytes now land in a sibling temp file that is flushed and renamed over the target, so a reader sees either the previous cache or the new one. The replacement is owner-only where writing in place left it at whatever the umask allowed; these are app-level values scraped from a public bundle rather than user credentials, so that is tidiness rather than a fix for a vulnerability. |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
9c67be4dcd |
fix(qobuz): give the offline-gate tests a rustls crypto provider
Both `client::tests::offline_gate_*` failed on every run, not intermittently: reqwest is built with `rustls-tls-webpki-roots-no-provider`, so BUILDING a client panics with "No provider set" until a process-level CryptoProvider is installed. The application installs one at startup via `qbz_app::ensure_crypto_provider`, but a test binary has no startup, and this crate sits below `qbz-app` and cannot call it. Confirmed pre-existing by stashing the unrelated work and re-running: same two failures, same panic. Worth knowing beyond these two tests: the panic fires at client CONSTRUCTION, before any request and regardless of scheme, so it hits a plain `http://` URL too. Any future tool or test that reaches for the download path without an application around it will land on it. cc |
||
|
|
e6c058c2f2 |
feat(qt): immersive shader scenes — RHI items, scene menu, GL link pairing
Port the immersive GPU scenes: Plasma, Tunnel, Aurora, Ambient, Spectral Ribbon and Line Bed as QQuickRhiItems/ShaderEffects under a pulse-driven ShaderSceneLayer, plus the Qt-only Tunnel Flow (Tauri Canvas2D port) fed by the Viz16 stream. Scene rows live in the header view menu (no Off row); g cycles the ring. A fresh immersive open defaults to Split > Lyrics (owner ruling). OpenGL link fix (owner smoke: Tunnel black with "uniform _22 declared as type buf and type buf"): every ShaderEffect fragment with its own uniform layout now ships a paired vertex stage declaring the identical block — the shared spectrum.vert only ever linked under Vulkan. RHI hardening: render pass descriptors on the ping-pong targets (the Tunnel Flow segfault / Plasma dead window), mirrorVertically tied to the backend (!== OpenGL), ribbon sub-uploads carry setSourceSize. km |
||
|
|
21e941bdf6 |
feat(qt): renderer tier, GPU selection and a real shader build
Closes PARITY-DEBT #83 and #104, and unblocks the immersive shader scenes. THE TIER. Slint resolves one bool, use_gpu_renderer, and hands it to three features: the shader scenes, the dynamic background and half of reduce-motion. This port had none of it, which is why the scenes could not be offered — off the GPU tier they render black and nothing could tell. QbzShell now carries rendererApi, gpuTier, shaderScenesAvailable and appBackgroundAvailable, all written from one latch. Only QML can source it: QRhi picks the backend after QGuiApplication, so the pre-init code knows only what it asked for. Per-item GraphicsInfo.api stays where it is. "Can this item draw?" and "should this feature be offered?" are different questions, and routing the first through Rust would make every cover wait on a round trip. SHADERS ARE BUILT NOW. build.rs bakes every .frag/.vert to .qsb with ten variants and the batching rewrite. They used to be baked by hand and committed carrying no GLES variants at all, so any GLES context failed them with "No GLSL shader code found" — which is exactly how the NPB Large visualiser went blank while this branch was being tested. Adding a scene is now dropping a .frag in the folder. GPU SELECTION enumerates real devices through Vulkan and lists them by model, never by class: a machine with no discrete GPU can no longer be told to use one. Legacy class keys still resolve, but only against hardware that exists. Picking a non-default device implies the Vulkan backend, which the row's own description states in all eight locales — measured as the only route that keeps a working context: DRI_PRIME lands on llvmpipe and the NVIDIA PRIME pair lands on OpenGL ES. The renderer row gains the auto-revert sentinel and the frame-liveness watchdog it shipped without. The sentinel arms only for a choice that truly forces a backend, and liveness means frames rendered over a window, not merely a window that opened. Also fixes the kiosk toggle overwriting the tier half of reduce-motion. Verified by 312 tests, both QML audits, the offscreen smoke, and a probe against the real hybrid GPU: both devices enumerate by name and the stored preference applies. cc |
||
|
|
8ba9483462 |
feat(qt): HiFi Wizard — guided DAC setup ported from Slint
Six-step modal (Welcome / Check / DACs / Configure / Test / Done) opened from Settings > Audio. It probes the audio stack and renders per-distro remediations, enumerates DACs with a manual node.name escape hatch, generates the PipeWire / WirePlumber snippets to copy, and plays four tracks spanning 16/44.1 to 24/192 with a live requested-vs-negotiated read-back. Read-only: it never writes a system file or runs a command. The logic was unreachable from Qt — every public function in qbz-dac-wizard takes a &AppWindow and writes straight into Slint globals. It is lifted into a new frontend-agnostic crate, qbz-dac-wizard-core, which returns plain data. qbz-dac-wizard itself is left untouched. Rust owns everything it computes plus `open`, so the reset and the probe are one act; QML owns the step navigation and the four progress checkboxes, which is how the reference splits them too. Two deliberate divergences from the reference: the row sits at the end of OUTPUT rather than BIT-PERFECT, and it is offered on every backend instead of PipeWire only — someone on ALSA direct is exactly who needs the walkthrough. The green glyphs use the accent tint, not success: success has no qrc bake directory, so a degraded tint set would render nothing at all. Every success-toned text keeps theme.success (#3fae6a verbatim). Verified by 309 tests, both QML audits and the offscreen smoke, plus a forced-route probe that mounts Settings and walks the wizard's steps — the standard smoke only mounts Home, so it is no evidence for a modal. The modal was also driven and screenshotted over VNC. cc |
||
|
|
934a83fdac |
fix(secrets): enable the real keyring backends on macOS and Windows
keyring 3.x ships no `default` feature contents, and every platform arm reads `not(feature = "...-native")` -> `pub use mock as default`. Naming no features therefore selected the in-memory mock on macOS and Windows, whose writes succeed and whose master key dies with the process, while `Backend::new` still reported `BackendKind::Keyring`. The consequence is that every offline track's content key becomes undecryptable on the next launch, so playback treats it as a cache miss and silently re-streams what the user had already downloaded. Linux is left off the list because qbz-credentials already enables `async-secret-service` and Cargo unifies features; naming `sync-secret-service` here would trip keyring's own compile_error for enabling both halves of a store. Proven with a probe wrapping in one process and unwrapping in another, on the same machine, changing only this line: before: backend = Keyring, UNWRAP FAILED: cipher error: aead::Error after: backend = Keyring, UNWRAPPED OK |
||
|
|
5e1eb46a71 |
feat(qt): window chrome parity — macOS tray, native chrome, appearance wiring
Three Slint-parity pieces that share the chrome surface: - macOS menu-bar tray (NSStatusItem, tray_macos.rs) ported from the reference — the K2 'macOS is a new implementation' ruling read the reference backwards (it avoids muda entirely), and its absence made close-to-tray quit the app on the Mac. Plus the custom-chrome window mode (macos_chrome.rs): Qt 6.9 ExpandedClientAreaHint / NoTitleBarBackgroundHint instead of fighting QCocoaWindow's style-mask recompute, traffic-light centring, and the SafeArea inset in HeaderBar. - Appearance rows that rendered, persisted and drove nothing now push live: hide/show title bar, window-controls cluster side, track in the window title, invert swipe navigation, auto-theme source selection (wallpaper/image/system) with regeneration on source change. - use_system_title_bar now defaults per-OS (custom chrome on macOS), matching the reference — the hard-coded 'true' also leaked into the Slint build through the shared ui_prefs.json. cc |
||
|
|
1b18adcde0 |
fix(qt): MainThreadMarker comes from objc2-foundation in this generation
The Mac build caught it on the first try, which is exactly what it was for: `use objc2::MainThreadMarker` does not resolve — in the objc2 0.5 / objc2-foundation 0.2 generation the type is exported by FOUNDATION (objc2-foundation-0.2.2/src/lib.rs:152, ungated); it only moved to the objc2 root later. The reference imports it from the same place (crates/qbz/src/tray/macos.rs:38), which is where this should have been read from in the first place rather than assumed from a newer API. The direct `objc2` dependency goes with it: nothing here names a type from that crate. cc |
||
|
|
dc604bdf62 |
feat(qt): wire the macOS Hide-Dock setting — Spotify behaviour on close
The row rendered, persisted and drove nothing, which is the exact defect class the miniplayer/tray contract existed to end. The owner named the behaviour they want: close puts the app in the menu bar AND drops the Dock icon; minimize still goes to the Dock like any app. So only the CLOSE path touches the policy. The wiring point is `QbzTray.setWindowShown`, which QML calls from exactly two places — `hideToTray` and `showFromTray` — so this needs no third visibility owner and cannot drift from the real window state. Hiding drops the icon only when the setting is on; SHOWING always restores it, with no setting check. A user who turns the option off while the app sits in the menu bar must still get their icon back, and restoring an icon that is already there costs nothing. NOT COMPILED AND NOT TESTED. This tree builds on Linux, the arm is behind cfg(target_os = "macos"), and a cross-check dies in the C build scripts for want of an Apple SDK. So the API was verified by READING objc2-app-kit 0.2.2 instead — which immediately earned its keep: `NSApplicationActivationPolicy` lives in NSRunningApplication.rs, not NSApplication.rs, and the NSApplication feature does not pull it. The three features here match the reference's own proven set. First real check is a build on the Mac mini. cc |
||
|
|
721841a4b8 |
fix(qt): Qt.quit() was vetoed by our own windows, so Quit never quit
The owner reported it three times and their second wording was the whole
diagnosis: "Quit QBZ debe cerrar la aplicación, NO SOLO LA VENTANA."
ROOT CAUSE. `Qt.quit()` in Qt 6 is a REQUEST, not an order. It posts
QEvent::Quit, and QGuiApplication answers it by calling close() on every
top-level window that has a platform handle — hidden ones included. If any
window refuses its close event, the quit is silently cancelled. Two of ours
refuse:
1. Main.qml's closeOrHide refuses whenever trayLive && closeToTray, and
hides the window on the way out. That IS "cierra la ventana, no la app".
2. MiniWindow's onClosing refuses unconditionally, because for the mini a
close means "leave the mini" — so once the mini had ever been opened,
quit was blocked regardless of any setting.
It also explains "Close to tray does nothing": with the setting ON, the X
hid (correct) and Quit hid too (the bug), so the two were indistinguishable
and the toggle looked inert. Nothing was ever wrong with that plumbing.
FIX. Both quit sites call Qt.exit(0), which exits the event loops directly —
no QEvent::Quit, no window to veto it, and no need to touch the miniplayer's
onClosing, whose refusal is correct for what it means. A one-shot 5 s
watchdog thread, armed only at the three quit sites, flushes the log and
_exit(0)s if anything downstream ever wedges; it cannot fire in a live
session. main() now names each teardown step, so a future hang says which
statement it hung on instead of going quiet.
closeOrHide reports its decision through the bridge — tray_live,
close_to_tray and the arm taken — because a report of "the tray does
nothing" was not previously separable from "the signal arrived and QML
ignored it".
MEASURED, with waitpid on a child rather than /proc (a zombie reads as alive
and that produced a false diagnosis earlier in this session). Old binary:
alive ≥25 s with the window visible, hidden, and with the mini open. Fixed:
exit 0 in 0.35-0.68 s across eleven driven scenarios — the tray Quit in all
three window states, the drawn X and the app-menu Close with the setting
both ON and OFF, the mini's own close button, and the MPRIS Quit method.
One arm remains static-only: a real WM close event with close-to-tray OFF.
VNC delivers no such event, so the owner's first titlebar X is its first
real execution.
cc
|
||
|
|
5dd450fb9f |
feat(qt): MPRIS — the desktop can finally see the player
Owner ruling K3 kept MPRIS out of the miniplayer/tray contract, and its default shipped. The owner reversed it after smoking the tray: QBZ did not appear in KDE Plasma's now-playing widget. Plasma reads MPRIS, so without this the desktop cannot see the player at all. src/media_controls_qt.rs (new) — a plain module over the SHARED qbz-media-controls crate, following the qbzd precedent rather than the Slint one because qbz-qt is also not a Slint app. Outbound: metadata on the track edge, playback state on the play/pause edge, both carrying position. Inbound: play, pause, toggle, next, previous, stop, seek and volume, plus Raise — which calls tray_qt::present() unchanged, the mini-aware verb the tray contract shaped for exactly this caller. src/artwork_qt.rs — cached_raw_path, the remote arm of cached_path without its file_url step, so the art URI is built for MPRIS rather than for QML. src/playback_qt.rs — four sibling pushes beside the tray's: both arms of refresh_now_playing, and BOTH is_playing != was_playing blocks. One of two is the silent half-port, so the grep is the instruction. Verified on the session bus, not by reading: qbz-qt owns org.mpris.MediaPlayer2.com.blitzfc.qbz, Identity "QBZ", DesktopEntry com.blitzfc.qbz — that is what gives Plasma the icon — with CanControl, CanPlay, CanPause, CanGoNext, CanGoPrevious, CanSeek, CanRaise and CanQuit all true and PlaybackStatus tracking. Metadata is empty until something plays, and that half is NOT verified: it needs audio. cc |
||
|
|
56d4387172 |
feat(qt): system tray via ksni
Block B5 of the miniplayer/tray contract. The tray settings rows have rendered and persisted since the Settings port and driven nothing; this is what they drive. src/tray_linux.rs (new) — the ksni StatusNotifierItem: the twelve icon pixmaps, the variant ladder, the tooltip, the six-item menu, left/middle click and scroll, and the qbz-tray-updater thread every mutation crosses. Note the filename inversion it reproduces: tray-light-* holds the BLACK glyph, so the "mono-light" setting loads tray-dark-*.png. src/tray_qt.rs (new) — the portable half: init's three gates, the window verbs, the 400 ms toggle debounce, the transport dispatchers and in_gamescope(). toggle_window is mini-aware — while the mini is open a tray click raises the MINI, because a mini-blind toggle would put the main window beside a live mini on the first click. src/tray_bridge.rs (new) — QbzTray: trayLive, closeToTray, and the four signals the ksni thread reaches QML through. Rust cannot touch a QQuickWindow, so every verb is a signal. icons/ — the twelve PNGs, copied rather than include_bytes!'d across crates, so this crate does not depend on a file inside the Slint binary crate. src/playback_qt.rs — the three live-update pushes: set_track and clear_track in both arms of refresh_now_playing (the publisher with 23 call sites, so a QConnect or cast track change updates the tooltip too), and set_playing in BOTH is_playing != was_playing blocks, each beside the existing discord_push. src/settings_qt.rs — tray() widened to pub(crate) for the session-entry read; the tray_close_to_tray snapshot default corrected from false to true, which was cosmetic while nothing consumed it and becomes behavioural now; and the tray-icon-theme select arm re-themes the running icon with no restart. Cargo.toml + Cargo.lock — ksni 0.3 with default-features off. Never the tokio feature: the blocking API panics inside tokio and the panic is swallowed, which is why the two dedicated threads exist. src/main.rs, build.rs, qml/Main.qml — the registrations, and tray_qt::init in on_session_entered. That call site is also deliberately the process's first settings_qt::tray(), because the store binds per user and a read from the bridge's construction seed would latch an empty one at the login screen. The Connections block that answers the four signals lands with B6, together with the window verbs it calls. Until then the tray renders, its tooltip and menu are live, and its clicks reach Rust and stop there. cc |
||
|
|
4e3638866e |
build(deps): bump lofty to 0.24
Every 0.23.x release of lofty is yanked on crates.io, so the version requirement only resolves from the committed Cargo.lock. A fresh resolve of qbz-library or qbz-offline-cache as a dependency fails outright. 0.24.0 needs no code changes across the 19 call sites. |
||
|
|
edbc037b0f |
feat(qt): Rust share seam, and fix the dead label share link
The port had no clipboard seam at all. share_qt.rs ports the URL builders and the clipboard from crates/qbz/src/share.rs:1-70. The long-lived OnceLock<Mutex<Option<Clipboard>>> is copied verbatim with its reason: arboard ties the offer's lifetime to the LAST live Clipboard object, so a create-per-copy port passes on KDE, which runs a clipboard manager that accepts the handoff, and silently loses the text everywhere else (#514). arboard carries the wayland-data-control feature at the same pin the reference uses; without it arboard is X11-only and every copy fails on a Wayland-only session. The label header's Share was not merely silent - it copied a link that does not resolve. open.qobuz.com has no /label/ route. The host is per-ENTITY, not per-app: track and album on open.qobuz.com, artist, playlist and label on play.qobuz.com. That asymmetry is why the five builders are ported as a set and why no call site formats its own URL any more: this one did, and got it wrong. QbzHome::label_share now copies play.qobuz.com/label/{id} and raises the "Link copied" toast, 1:1 with main.rs:13164-13178, and the QML Loader/TextEdit clipboard hack it replaced is deleted - its own comment claimed there was no toast seam, which stopped being true when toast_qt.rs landed. Only the URL builders and the clipboard are ported. share.rs:72-184 is the Song.link/Odesli resolution, which applies to tracks and albums; artists and labels are link-only, so porting it now would be dead async HTTP. LabelView also adopts QbzLoadMore here - its two changes share the file. cc |
||
|
|
c8559a68b3 |
feat(qt): port Qobuz Connect — service, flyout, routing and touchpoints
Full-parity port of the Slint QConnect integration per the contract at qbz-nix-docs/qt-frontend/2026-08-01-qconnect-port/00-CONTRACT.md (three adversarial review rounds on the spec, §12 trap sign-off PASS on both halves, RFB-verified incl. a real cloud connect, live session owner-tested). The shared crates are consumed verbatim — zero backend changes. - adapter quartet: qconnect_transport_qt (credentials, identity on the shared qconnect_settings.db), qconnect_engine_qt (start_track_stream fallback chain + feeder helpers + CMAF cursor sync), qconnect_event_sink_qt, qconnect_qt (the facade: connect/disconnect recipe, controller routing, echo guards, offline parking B12, startup auto-connect) - QbzQConnect bridge singleton + on_session_entered wiring; one shared flyout for both bars (tooltip on the large bar only), Developer > Connect diagnostics modal, device-row glyphs, settings rows live - poll loop: peer-reflection branch before cast, ~2 s renderer reports, sync-on-add on every Qobuz-castable add path - routing: cast → QConnect → local (mute QConnect-first), play arms after the set_queue funnel, reorder cloud arm with no local fallback, volume/mute/seek remote-first - touchpoints: lyrics remote anchor, scrobble guarded / Discord unguarded, cast suspend/resume, volume-lock lifted when remote km |
||
|
|
a16cf56c86 |
feat(qt): playlist manager, folders, importer and the Library parity round
Ports the whole playlist-organisation domain, which had no counterpart in this frontend, plus the Library work the owner asked not to defer. Playlist Manager. New controller (playlist_manager_qt, _rows, _ops) over the already-ported folders_qt repository, which until now had zero consumers. Grid, list and folder-tree modes; sort, filter and search; folder cards and chips; drag reorder and up/down arrows. Reached from the sidebar's "..." menu, whose New folder / Manage playlists / Import rows had been rendering dimmed and inert behind a comment saying the seam did not exist. Folders. QbzFolderEdit plus the create/edit modals and PmFolderIcon: preset or custom-image icons, colour, hidden flag, delete. Deleting a folder nulls its LOCAL members' folder_id explicitly, because the app keeps the foreign_keys pragma off, so the schema's ON DELETE SET NULL never fires for them. QbzPlaylistEdit. One shared rename/description/delete editor replacing the inline popup in PlaylistView. Three corrections the old path had wrong: the description is carried through a rename instead of being written as NULL, a FOLLOWED playlist routes to unsubscribe (playlist/delete returns 200 and silently no-ops on a playlist you do not own), and the post-delete back navigation is conditional on actually being on that playlist's page, so deleting from the manager no longer navigates you off it. Importer. Spotify / Apple Music / Tidal / Deezer, over the shared qbz-playlist-import crate. Picker local mode. The reference's four-case matrix — target local or Qobuz, payload local refs or Qobuz ids. Local playlists list unconditionally and Qobuz ones only when online. Local refs onto a Qobuz playlist write the library.db sidecar at next_playlist_sidecar_position(pid, qobuz_count); the 0-based enumerate the reference used before collided slots and lost rows. The carried payload can no longer be parsed as a catalog id when it is a library row id. Unblocks the local bulk add-to-playlist. Library. A-Z jump strip, Albums list mode, Artists side panel, per-tab persistence and multi-select. Preferences land in the same <data-dir>/qbz/favorites_ui.json the shipping build writes, so switching between the two frontends keeps them. LibraryView is split into views/library/ on the way past 1800 lines. Local library. Group-by now persists; playback queues the order on screen rather than the SQL page order; switching album-identity mode invalidates the Artists tab; artist matching folds diacritics and compares normalized credit parts, so "Air" stops listing "Airbourne" and "Beyonce" stops being two artists. Offline. activate_offline never persisted a last-user marker, so every per-user path resolved to None and all local reads came back empty; a session that started offline saw no local playlists at all. The sidebar's load-once latch was function-local and therefore unreachable from the logout reset, so a second login never rebuilt the tree. Both are session-scoped now, and the sidebar Refresh row runs the local-safe verb instead of being dimmed with no way to recover. Also fixes, found while verifying rather than reported: artwork derivatives were written straight to their final path, so a QML Image resolving one mid-encode opened a truncated png and Qt cached that failure against the URL for the rest of the session; Library's track group separators referenced a Component declared in another delegate's scope and could never render; the genre filter was drawn on Tracks and Albums but applied only on All; an All-feed row called a player method that does not exist; and the sidebar's drop-hot highlight used Slint's #RRGGBBAA literals in Qt's #AARRGGBB slot. 17 new msgids across all 8 locales. cc |
||
|
|
b6cf06ff2a |
feat(source): wire My QBZ through the seam, and land the owner's container rules
Stage 2. The measured win: opening a 34-item collection now costs **2** library.db opens instead of 23-34 — the local source owns a pooled handle and hands out no guard, only `with(|db| ...)`, so a `!Send` `&LibraryDatabase` cannot cross an await either. Bug 1 from the seam design is dead by construction, not by care. The owner's rules, decided today, now live in one place instead of eight: - **Collection** takes albums, singles and EPs from ANY source — qobuz, folders, Plex, and whatever is added next. **Mixtape** takes albums, playlists and tracks. This was a single boolean (`restrict_to_mixtape`) that could express neither release types nor "collections accept local". - An unsupported combination must never be VISIBLE: the picker stops offering the impossible action, and the error stays a log-only safety net. Owner: "el bloqueo no debería ser visible nunca, más bien esto hay que ajustarlo en la UI". - **Ephemeral honours its name** — playable, queueable, and offerable to nothing else. - A row's source comes from the ROW. Eight QML sites built the add-to-mixtape payload with `"source": "qobuz"` written as a literal, so a Plex or local track was stored as Qobuz and afterwards could not play at all: the app asked the Qobuz API for an id that never existed there. `QobuzSource` reads the client through a lens on every call instead of holding a clone. That is not style: `qbz-core` REPLACES its client (core.rs:346 and :384, both on paths that run before `set_session`), so a cached clone goes stale and fails silently — the hazard the crate's own doc comment described. The lens is installed in `on_boot`, first of everything, and finding out why is the lesson of this commit. The gate was green — build, 142 tests, both QML audits, a clean offscreen smoke — and the feature was visibly broken: every Qobuz row showed "No results found", an empty Quality cell and a wrong "SINGLE" type, while the Plex rows resolved fine. The registry is a OnceLock, so whoever touches it first BUILDS it, and one built without the lens leaves Qobuz permanently detached. Only driving the real app surfaced it; the crate's error string named the cause exactly, which is the one thing that made it a ten-minute fix instead of an afternoon. cc |
||
|
|
f1b4241342 |
feat(source): add qbz-source, the source-agnostic media seam
Stage 1 of the seam the owner asked for: "son vistas súper híbridas donde se mezcla Qobuz/Local(Folders/Plex) ... hay que hacer una solución agnóstica porque no tardamos en meter a Jellyfin y quizás Navidrome". Nothing is wired yet — this lands the crate, its three source implementations and its tests. `qbz-qt` and `qbz` are untouched, so this commit changes no behaviour. The problem it exists to end, measured rather than assumed: 129 source-branching sites across 30 files in qbz-qt alone, SEVEN distinct item->tracks resolvers none of which can reach the others, FIVE non-interchangeable artwork pipelines over one correct taxonomy, and six incompatible source vocabularies — three of which cannot express Plex at all, including the PERSISTED one (`AlbumSource` is `Qobuz|Local`). Every hybrid view re-wires the per-source handling by hand, which is why something is always missing. One `Source` trait covering the four concerns together — tracks, metadata and origin, artwork, playback — because splitting them is exactly what lets a view forget one. A `SourceRegistry` with claim-priority ordering (Qobuz, Plex, then Local; Plex-before-Local is load-bearing). `RegistryResolver` implements `qbz_mixtape::ItemResolver`, so that crate and the Slint glue stay byte- untouched and keep compiling. The three measured bugs die structurally, not by discipline: - The local DB was reopened PER CALL, so a My QBZ collection logged one "Opening library database" per item (34 measured live). `LocalSource` owns a pooled handle, and it hands out no guard — only `with(|db| ...)` — so a `!Send` `&LibraryDatabase` provably cannot cross an await. `qbz-mixtape`'s sync-resolver constraint becomes an API property instead of a comment. - A `plex:<hash>` album key reached an API that wants a rating key. `MediaRef` has no public constructor: only `registry.claim` mints one, and `PlexSource::claim` rejects that shape in track position. The survey also found a second, undiscovered face of the same bug — "Go to album" on a Plex track row opens an empty page. - Local rows in a hybrid list rendered placeholder discs. Traced to its real mechanism: those items are stored with `artwork_url: None` and the only backfill is behind a Qobuz-client gate, so offline or pre-login the art is on disk and never shown. `Source::artwork` is one call per row whatever the source, and keeps the cheap/cold split the local path already had. Adding Jellyfin is one new file, four diff lines inside this crate, and one line in `auth_qt.rs` — and the design states this only holds BECAUSE artwork sits on the trait. 36 tests. The id tests derive their fixtures from `PLEX_TRACK_ID_FLOOR` instead of transcribing them, which is not pedantry: the survey's own id table — the artefact meant to be the definitive reference — paired a namespaced id with the wrong rating key, and the transcribed test caught it on the first run. Design and adversarial review (6 BLOCKER / 10 MAJOR / 7 MINOR, all corrected): qbz-nix-docs/qt-frontend/2026-07-30-source-seam/. cc |
||
|
|
acf23abc9b |
feat(qt): port the My QBZ domain — mixtapes, collections and the discography builder
The Slint reference is 3,885 lines of .slint across ten files plus 5,514 lines
of Rust glue over the shared qbz-mixtape crate. None of it existed in the Qt
port; the nav flyout carried a "GAP:" comment and two disabled entries.
Rust (flat in src/, per QTBUG-93443): three bridges rather than one God-object
— QbzMyQbz (grids + detail + edit + cover + mix), QbzMyQbzAdd (the app-wide
picker, kept apart so TrackRow, PlayerBar and the Local Library bulk bars
cannot couple to detail state) and QbzDisco (artist-scoped, reached from the
artist page, never coexisting with the MyQBZ views). Twelve controllers behind
them, ported function by function from myqbz{,_detail,_play,_edit,_cover,_mix,
_add,_prefs,_view_prefs,_builder}.rs. src/toast_qt.rs finally gives the port a
toast host.
QML in qml/views/myqbz/ plus four global overlays. Reused rather than forked:
TrackRow, TrackListHeader, QualityBadgeFull, QbzContextMenu, CardMenu,
QbzCheckbox, QbzSkeleton, QbzScrollBar, RoundedImage. LocalSegToggle and
LocalMultiSelectBar are PROMOTED to controls/QbzSegToggle and
controls/QbzMultiSelectBar (they were ports of shared Slint primitives that
MyQBZ needs too) — a move plus an import fix, with the Local metrics kept as
the defaults so the four Local Library toggles are byte-unchanged and the MyQBZ
call sites pass ViewToggle's own numbers.
Every list is windowed at write time: GridView/ListView with reuseItems and a
cacheBuffer, and expanded mode is an outer recycled ListView whose delegate is
a Column + Repeater, because a nested ListView inside a recycled delegate
cannot size itself. Single-row updates patch in place; replacing the array
resets contentY and throws the user to the top.
Shared controls gained the arms the new views need, with the numbers taken from
the .slint and not guessed: SettingsButton (btnHeight / minWidth /
trailingIconName), QbzEmptyState's four metrics, QbzSelect's `sm` variant
(30px, r6, no outline, 12px label, 14px chevron, 40px narrower —
QbzSelect.slint:74-148), QbzContextMenu.openBelowLeft, and QbzLineEdit gains a
distinct `accepted` signal fired on ENTER ONLY: a modal that submits on
`committed` submits TWICE, because closing it removes focus and fires
`committed` again — a rename would have run twice and a create made two
collections. clearSearch() no longer assigns root.text either; that JS write
destroyed the call site's binding permanently, so Rust could never re-seed the
field after one click on the X.
SourceIcon gained the plain "qobuz" arm it was missing, which had every Qobuz
row in a collection drawing itself as a local file.
Two icons (turntable, image) are baked from the Slint masters into the seven
full tint dirs and registered in MASTERS; three more (circle-check-big,
circle-alert, triangle-alert) back the toast. A missing tint dir or glyph
renders nothing and logs nothing, so this class never surfaces in a build.
cc
|
||
|
|
87b18fa7f7 |
feat(qt): wire local-network casting end to end
The two cast buttons in the now-playing bars rendered and did nothing: no service, no bridge, no picker. This ports crates/qbz/src/cast_service.rs onto the shared qbz-cast crate (discovery, both device families and the media server are the crate's, not reimplemented) and hangs a QbzCast singleton off it. Discovery is armed only by openPicker() and stopped by closePicker(), so a user who never opens the picker sends no mDNS/SSDP traffic. Connecting stops the local backend through its public seam and re-casts the current track at the streaming preference clamped by the per-renderer cap. Playback is routed at four seams in playback_qt (new track, toggle, seek, volume) and the local poll yields while casting — without that the two polls fight over the bar every second. Logout and exit tear the connection down, or the renderer keeps playing after the app is gone. Deliberate gaps, each named in the module header: no offline-cache tier, no QConnect suspend/restore, no Plex casting (the Slint carries the same TODO), and lyrics do not follow a remote position yet. cc |
||
|
|
d76f694781 |
feat(qbz-qt): close the two declared debts on the album and artist pages
The Last.fm similar-albums row exists now. The recommendation building was already frontend-agnostic in qbz-external-reco, so this only adapts QbzCore to its catalog trait and publishes the result; the row seeds on the album's artist and excludes what the two Qobuz rows already show, including ids the pre-resolution dedup misses. Resolved rows cache per album for thirty days in the same store the Discover tab uses, so re-opening an album makes no Last.fm or Qobuz calls, and only a non-empty result is cached — a transient failure must not hide the row until restart. With Last.fm not connected it resolves to nothing and makes no network call, so the rail simply never appears. Relationship rows navigate. They carry a name rather than a catalog id, so the click resolves through MusicBrainz first and only a CONFIRMED match opens an artist — landing the user on a same-name musician is worse than a row that does nothing. The role travels with the name so a match in another role is not mistaken for this one. cc |
||
|
|
31b1484c01 |
feat(qbz-qt): Local Library complete — five tabs, routed album page, Plex
The view had roughly a folder tree and an album grid inside one 1,847-line file. Slint's is 2,671 lines across five tabs, and the local album detail is a routed page of its own. This closes that gap and splits the result: the view becomes a composition root over qml/views/local/ modules, and local_library_qt.rs becomes a narrow facade over local_* modules by concern. Plex is wired for real. The port hardcoded the cache path to None, so the union was local-only and Plex thumbnails were skipped. The settings store binds per user at session activation, the album and track union includes Plex, thumbnails resolve through the artwork path, rows carry their source badge, and the manual re-sync is on the toolbar behind the availability gate — as in Slint. Tabs: Albums with its quality/format/source filter popup, Tracks paginated, Folders in both flat and two-pane tree modes, Artists as an A-Z master/detail. Every list is windowed from the start, artwork id-keyed and evicted, because 16k tracks is exactly where the Slint build freezes. The tree grows a real select mode with its bulk bar, the ad-hoc "ephemeral folder" pane plays music that was never indexed, and the album page gets its version picker — a local album with two physical copies now switches between them without a database round-trip. Two agents built the QML and the Rust halves in parallel and the QML called thirty QbzLocal members that did not exist. QML resolves lazily, so each would have been a control that renders and does nothing; a static cross-reference caught them before a build ever ran, and they are implemented here. Deliberately logged seams, not half-paths: add-to-playlist, add-to-mixtape and the tag editor have no picker or modal in this port yet, and local favourites are not wired, so those actions log rather than route a local id into the Qobuz API. Plex playback downloads the part instead of range-streaming it, which is Slint's own documented fallback. cc |
||
|
|
ea644f5e05 |
feat(qbz-qt): phase 19 — full Settings subnav, Appearance + Integrations, live theming
Theme system as data: qbz_theme registry (36 themes + auto/custom) resolved from the shared ui_prefs slug into a themeJson document; QbzTheme rebound to it so the whole app repaints live (OLED->Light probe-verified). Full subnav (8 sections + Share logs footer); Appearance 1:1 (theme dropdown with dark/light filter, ambient/titlebar rows in their real home, typography, library visuals, window chrome, tray via tray_settings.db, renderer); Integrations 1:1 (MusicBrainz, Last.fm 2-step browser connect + authed state, ListenBrainz token validate, Discord toggle -> live DiscordRpc.set_enabled). Offline/Local Library/Blacklist/Developer render honest placeholders. km |
||
|
|
9f86a40f52 |
feat(qbz-qt): phase 14 — app-wide ambient background (modo Cider)
Port of the spec's palette derivation (16x16 downscale, coverage- weighted 24-bin hue histogram, genuine secondary cluster or deeper same-hue, +180 accent, B/W defaults) driven by the now-playing artwork on track change. QPainter AmbientField with the WGSL scene's exact four blob orbits, dim scrim, 30fps cap paused when inactive; chrome surfaces translucent per the AppShell alpha tokens (content frame surface-main @ 0.22 frosted). Same shared app_background pref (owner has it 'ambient'), app-menu toggle applying live, QBZ_BG_* env knobs. 4 new unit tests (14/14 total). km |
||
|
|
d57cec974f |
feat(qbz-qt): phase 10 — Settings > Audio and Playback 1:1
settings_qt.rs controller over the shared stores (AudioSettingsState, PlaybackPreferencesState, ui_prefs.json, qconnect_settings.db) with the upstream cascades (dac-passthrough->skip-sink-switch, streaming-only-> gapless, backend switch resets routing) and live apply through public player APIs (reload_settings/reinit_device/clear_audio_cache). ALSA device regrouping (Defaults/Bit-perfect/Plugin/Other + BP badges), searchable QbzSelect, QbzToggle/QbzSlider/SettingRow replicas, exact row order and gating. Persistence probe verified (gapless + streaming-quality round-trip, original values restored). km |
||
|
|
b10ff98620 |
feat(qbz-qt): phase 9 — Queue homologation + Lyrics side panel
Queue panel 1:1: History tab (click replays per queue.rs semantics), #442 'Next in queue'/'Next up' section markers from manual_next_count, 40-row pagination + live search + page clamp, row actions (play, remove, remove-all-after, heart), press-drag reorder via move_track, clear queue, plural-aware footer count, exact empty states. New Lyrics panel: qbz-lyrics service + per-user cache wired, Qobuz-first chain with LRCLIB fallback, synced line view with line-level highlight + centered auto-follow + click-to-seek, exact status bodies. Both panels stack Feishin-style in the right column; NPB lyrics button wired. km |
||
|
|
1b81b289d1 |
feat(qbz-qt): phase 5 — Library view 1:1 with All grid/list and perf marks
Six live tabs (All/Tracks/Albums/Artists/Playlists/Labels) with real count badges, paged favorites/playlists/purchases loaders, library_all merge semantics, heart routing (Qobuz API vs local store — LocalFavoritesService now initialized at session entry), sort popup with direction carets, grid/list toggle with natively windowed GridView/ListView delegates, id-keyed artwork via signal with hand- rolled eviction. Perf marks on the owner's account: 344 items, fetch wall 2.7-3.4s (97% network, sequential), map+merge ~11ms, JSON serialize 10ms, QML parse 7ms (~20us/item => ~140ms at the 2200-item reference scale), RSS +29MB after publish. km |
||
|
|
b405755c1a |
merge: pull smoke-2026-07-25 backend for the Library All POC phase
Brings the windowed Library All backend (qbz-library) and the latest Slint reference implementation into the POC base. POC branch only; pre-release untouched. km |
||
|
|
95df643a25 |
feat(qbz-qt): phase 3 — Discover > Home with live Qobuz data and artwork
Pure mapping port of home.rs/foryou.rs (quality tiers, award ribbons, slim grids) into Slint-free rows; discover index + favorites + release-watch + top artists fetched concurrently (12 sections, ~170 cards live-verified). Artwork via the shared qbz-cache ImageCacheService (semaphore-16 downloads, file:// rows, native QML decode). HomeView.qml: tab pill, five rail kinds, shimmer skeleton, error+Retry, offline placeholder — exact msgids throughout. Sections travel as one JSON document (cxx-qt-lib cannot nest QVariant containers — documented). km |
||
|
|
df7eb3588e |
feat(qbz-qt): phase 1 — login screen with full offline guardrails
QbzBridge QML singleton (12 properties, 8 invokables) driven by tokio tasks via CxxQtThread. Port of auth.rs (browser OAuth loopback listener with qbzd-style nonce, 180s timeout, cancel via JoinHandle abort, scoped offline-gate lift for cold bundle init, D1 token-keep on network failure / clear on explicit rejection, D4 subscription grace marks). OfflineModeEngine + ConnectivityActor started at boot with live watch forwarding (connectivity/captive portal/recovery banner/has-previous- session). Boot: splash -> silent token restore -> shell | login. Start-offline enters the guest profile. LoginScreen.qml is a faithful port with exact msgids routed through qbz-i18n. 5 unit tests green. km |
||
|
|
cbc96f762f |
chore(deps): bump serde_json from 1.0.149 to 1.0.151 in /crates
Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.149 to 1.0.151. - [Release notes](https://github.com/serde-rs/json/releases) - [Commits](https://github.com/serde-rs/json/compare/v1.0.149...v1.0.151) --- updated-dependencies: - dependency-name: serde_json dependency-version: 1.0.151 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> |
||
|
|
09380d4634 | Merge branch 'pre-release' into chore/internal/smoke-2026-07-25 | ||
|
|
62cc4f9200 |
remove ring from the dependency tree
The crate `ring` was transitively pulled due to the feature `rustls-tls` being selected for `reqwest`. In order to ensure `ring` is not pulled, `rustls-tls-webpki-roots-no-provider` must be used instead. |