Commit Graph
67 Commits
Author SHA1 Message Date
vicrodh 64ec3e2bc8 fix(updater): restore Qt release checks and signed installation
cx
2026-09-12 15:20:23 -06:00
vicrodh 74e1610433 feat(orbit): add shared library foundations and gated host tools
Extract the HTTP host and catalog layer into qbz-control, preserve the
existing daemon API, and keep private session credentials out of SSE.

Scope library stores and scan workers to their host. Share bounded source
search and show Local results alongside Qobuz in the Qt search page.

Add the launch-gated Qt Orbit laboratory and persistent remote-context
banner. Let qbzd run --orbit own and administer its file library through
instance-scoped API and CLI operations. Full remote UI control remains
pending.

Validate library/control/daemon suites, Qt search and QML regressions,
two isolated daemon processes, Qt release build and final cargo check.

cx
2026-09-12 13:02:29 -06:00
vicrodh c82e624188 release: bump workspace version to 2.1.1
The About modal reads CARGO_PKG_VERSION, so it showed 2.1.0. Bump the workspace
version (and the Cargo.lock members) to 2.1.1 and add the 2.1.1 metainfo release
entry.

cc
2026-09-10 20:33:15 -06:00
vicrodh 3aed91fa0a fix(cast): localize UPnP URL compatibility and harden crash diagnostics
Keep http 1.5 and tolerate empty or relative service URLs in a local rupnp patch. Cover standard DLNA SOAP requests and malformed descriptions.

Avoid logger locks in fatal handlers, flush emitted log batches, and preserve CI symbols after smoke failures.

cx
2026-09-08 12:08:53 -06:00
vicrodh 800255ac58 feat(account-migration): snapshot, plan, apply and local-profile copy
A frontend-agnostic crate for moving a Qobuz account's favorites and
playlists into another account, additively (contract
qbz-nix-docs/specs/2026-09-02-account-migration-contract.md §9):

- snapshot: capture the signed-in account (five favorite kinds with
  favorited_at, owned playlists with track ids in order, followed
  playlists) into users/<uid>/account_migration/snapshot-*.json; the same
  capture is the target's live state at migration time.
- plan: the pure delta against the target and the ledger. Same-name
  playlists holding nothing foreign are the same playlist and get the
  missing tracks appended; anything foreign means a different playlist and
  a " (migrated)" copy. Favorites are written oldest first so the target's
  own stamps keep the chronology.
- apply: one favorite per call with pacing and bounded retries, 50-track
  chunks, subscriptions re-followed; every success lands in the ledger
  before the next write, so a run resumes and a second run adds nothing.
- local: copy the old profile directory row by row (library folders and
  scanned tracks on the fast path, playlist folders, settings keyed by
  qobuz_playlist_id remapped through the ledger, pinned items, orders,
  preferences, blacklist; media servers, scrobblers and listening history
  behind options). Never the favorites cache, the subscription clock, the
  session or the purchases.

The writes are the ones the app already issues when a user hearts or
creates a playlist; nothing touches subscriptions, purchases or account
data.

cc
2026-09-02 08:24:01 -06:00
vicrodh 4f065eb45f chore(release): bump workspace version to 2.1.0
Workspace version + lock, and the metainfo gains its 2.1.0 release entry
(the release workflow re-stamps version and date at tag time). flake.nix
and the Flathub manifest are post-tag and stay untouched; the snap-qt
recipe stamps its version from the tag.

cc
2026-08-31 11:39:20 -06:00
mazipani 658524c404 fix(cast): accept X.509 v1 device certificates in TLS handshake
Connecting to Cast devices failed with:
Connection error: invalid peer certificate: Other(OtherError(UnsupportedCertVersion))

Many Cast devices (notably AV receivers with Chromecast built-in) serve a
self-signed X.509 version 1 certificate on port 8009. rust_cast 0.21's
NoCertificateVerification verifier accepts any certificate in
verify_server_cert, but its signature checks delegate to rustls helpers
that parse the certificate with webpki, which only supports version 3
certificates. The handshake therefore aborts for v1 device certificates;
this affects every rustls-based rust_cast release (0.19+) and only devices
happening to serve v3 certificates can connect.

Vendors rust_cast 0.21.0 under crates/vendor with a minimal patch: the
verifier's verify_tls12_signature/verify_tls13_signature now assert
validity, matching the verifier's no-validation contract and the rustls
danger verifier examples. Wired up via [patch.crates-io]; see
crates/vendor/rust_cast/QBZ_PATCH.md for the full analysis and how to drop
the patch once upstream releases the fix.

Verified against a real device serving a v1 certificate (TLS 1.2,
ECDHE-RSA-CHACHA20-POLY1305): handshake, receiver channel and heartbeat
now establish; unpatched fails at the first channel write. cargo
check/test -p qbz-cast pass (13 tests).

Refs: rustls/rustls#1298, rustls/webpki#29, #439
2026-08-31 14:50:01 +02:00
vicrodh f30abd12ac feat(qconnect): add official LAN wire service
cx
2026-08-30 13:50:32 -06:00
vicrodh 8964544891 chore(deps): reqwest 0.13 (rustls-no-provider; query/form are opt-in features now)
cc
2026-08-29 09:00:56 -06:00
vicrodh 0b4c9e28b5 chore(deps): base64 0.23
cc
2026-08-29 08:24:28 -06:00
vicrodh b6c69509df build(deps): unify lofty at 0.25.1
cx
2026-08-26 21:31:47 -06:00
vicrodh 69ed612077 refactor(frontend): retire Slint application
cx
2026-08-26 11:10:18 -06:00
vicrodh a0d8a7fc94 feat(library): add derived local catalog
Introduce a frontend-agnostic generation schema with stable source identities, materialized album and artist structures, trigram FTS, matching sort indices, and descriptor-bound keyset queries.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

cc
2026-08-20 23:28:28 -06:00
vicrodh 0dfec607c2 feat(media-cache): one cache table for every remote server
The Local Library union works by ATTACHing a remote server's mirror and
UNIONing it with local_tracks, and today that mechanism names Plex
inside a shared crate: `get_albums_metadata_page(..., plex_cache_path)`
with `FROM plex_cache.plex_cache_tracks` hardcoded twice. Copying it per
source means three Option<&Path> parameters and six UNION arms, and
every future source pays again.

This is the generic form: one table with a `source` discriminator, one
ATTACH, one UNION arm. The schema is `plex_cache_tracks` with its
columns renamed — all 19 map 1:1 onto Jellyfin and Subsonic, which is
what made "generic" a measurement rather than a guess.

Plex is deliberately NOT migrated here. It is a live table with 17145
rows on the owner's install, and folding it in alongside two new sources
would make one commit where a regression could come from either. This
table proves itself with Jellyfin and Subsonic first; Plex moves in as
its own change with its own verification. The cost until then is a
second ATTACH in one SQL builder, which is cheaper than the risk.

Identity: the namespaced Local Library id is `FLOOR | rowid`, with
Jellyfin at 1<<41 and Subsonic at 1<<42 — disjoint from Plex's 1<<40 and
the ephemeral store's 1<<48, asserted against both. The payload is this
table's AUTOINCREMENT rowid, NOT a hash of the server's id. Plex had to
hash because its ids predate its cache, which is why resolving one costs
a table scan; a new table has no such debt, and at ~50k tracks a 40-bit
hash carries ~0.1% odds of a collision that would play the WRONG TRACK.

Writes are UPSERT on (source, item_id), never delete-then-insert: the
row id is published identity — it sits inside queue entries and
session.db — so a re-scan must not mint a new one for a track that was
already there. AUTOINCREMENT for the same reason: a freed id must never
be handed to a different track. Both are pinned by tests.

`prune_stale` keys on updated_at rather than a set of ids, so a 50k
sweep needs no giant IN(...) — with the documented precondition that it
only runs after a sweep that COMPLETED, since a dropped connection
halfway through would otherwise read as "the server deleted everything
it did not get to".

11 tests, including the id-namespace matrix against the two existing
floors and the untagged-track ordering (75 of 4924 measured Jellyfin
rows carry no track number and must not sort above track 1).

cc
2026-08-20 07:49:55 -06:00
vicrodh 0f41c44dac feat(subsonic): the protocol crate, with the HTTP-200 trap enforced in code
Stage 5's second protocol crate. One client for Navidrome, Gonic,
Airsonic, Astiga and Ampache — which is why the owner scoped this as
"Subsonic, not just Navidrome". Only Navidrome 0.63.2 was on the bench,
so the places servers are likely to differ are named rather than
smoothed over.

The dangerous property of this protocol is that it reports FAILURE with
HTTP 200. `error_for_status()` passes on a refusal. Three defences, all
in code rather than in a reviewer's memory:

- `parse_envelope` is the only way a body becomes a payload, and it
  checks `subsonic-response.status` first. It also accepts XML on the
  failure path, because the measured no-credential getCoverArt reply came
  back as XML DESPITE `f=json` — a JSON-only parser would report "not
  understood" for a perfectly clear "missing parameter: 'u'".
- `looks_like_audio` exists because a refused stream is a ~200-byte JSON
  blob served as the audio body. That is the one way this feature could
  reach the protected decoder with garbage.
- code 40 and 70 become their own variants, so a caller can act on
  "re-authenticate" without matching on prose.

Two mapping decisions worth stating: `duration` is SECONDS on the wire
and milliseconds in QBZ, and `bitDepth` is **0** for a lossy track where
Jellyfin reports null — both sentinels fold to None so downstream sees
one vocabulary rather than two.

The salt is FIXED per install, not rolled per request. Rolling looks
more careful and would make every cover URL unique per request, which
re-downloads every cover on every pass since the artwork cache keys on
the URL. It buys nothing: the password is not transmitted either way.
The consequence is recorded where it matters — a cover URL carries
credentials, so callers must key their cache on the opaque coverArt id.

`detect_sweep_mode` ASKS rather than checks the server's name: search3
with an empty query is 14 requests for this library and getAlbumList2 +
getAlbum is 675, but the fast path is the behaviour most likely to
differ on a server that was never tested, and probing one page costs
~100 ms.

14 unit tests + 6 live. The live suite runs in PARALLEL by design —
Subsonic auth is stateless, so unlike Jellyfin nothing here can revoke
anything else's session. Against Navidrome 0.63.2, three consecutive
runs, 6/6 each:

  ping ......... navidrome 0.63.2, openSubsonic=true
  wrong pass ... Unauthorized, and the raw request really answered 200
  sweep ........ Search3 detected; page 0 = 500 rows, 344 bit depths
  per-album .... fallback returns 500 album ids, 17 tracks in the first
  stream ....... 206 audio/flac, Content-Range total == the API's own
                 reported file size, so nothing was re-encoded
  cover ........ authed image/webp 69 KB; unauthed HTTP 200
                 application/xml 251 B, rejected by the guard

cc
2026-08-20 07:43:46 -06:00
vicrodh e578d8a54a feat(jellyfin): the protocol crate, verified against a live server
Stage 5's first half. Sibling of `qbz-plex` and deliberately shaped like
it, so `qbz-source`'s JellyfinSource will read like PlexSource:
frontend-agnostic, HTTP + DTOs, no cache schema and no settings store.

Three measured facts shape the file, and each one is a comment because
each one is a decision somebody could otherwise undo:

- `?static=true` is bit-perfect. Verified by md5 against the file on the
  server's own disk, not inferred from a content type. `is_direct_
  response` encodes the tell a transcode leaves — chunked with no
  Content-Length — so a server-side policy silently resampling a hi-res
  track is caught rather than played.
- Quality costs 45.8 s per 4924 tracks and there is no cheaper path.
  `Fields=MediaStreams` trims 29% of the bytes and saves nothing: the
  cost is server-side media-info hydration. The scan will own that
  knowingly, which is why `track_count` exists to give a progress
  denominator.
- Cover urls need no credentials, so unlike a Plex thumb they are STABLE
  and their cache key can be the url itself.

`stream_url` / `image_url` are free functions rather than methods. Two
reasons and both are load-bearing: `Source::artwork_token` may not touch
the network by contract, and a `reqwest::Client` cannot even be
CONSTRUCTED without a rustls provider — which a unit test does not have.

The mapper picks the audio stream BY TYPE, not by position: an embedded
cover rides in the same MediaStreams array as an `mjpeg` with BitDepth 8,
and taking [0] would badge a 24-bit FLAC as 8-bit. A lossy row keeps an
absent bit depth rather than defaulting to 16, which would badge every
MP3 as CD quality.

10 unit tests, plus 5 LIVE tests that skip unless QBZ_JELLYFIN_URL names
a server. The unit tests prove the JSON shape is understood; only the
live ones can prove the endpoint still exists, the token still opens it,
and the stream is still direct. Against Jellyfin 10.11.11: 5/5 — 500-row
page with 221 real bit depths of 4924 total, a Range request answered
206 with a `fLaC` bitstream, a tokenless cover, and a future-dated delta
returning zero.

cc
2026-08-20 07:15:20 -06:00
vicrodh 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
2026-08-11 11:26:50 -06:00
vicrodh 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
2026-07-30 16:14:30 -06:00
vicrodh 469f83781d feat(qbz-qt): phase 0 — crate skeleton + cxx-qt toolchain gate
New qbz-qt bin crate (cxx-qt 0.7 + QML) as the only default workspace
member in this worktree, so plain cargo build produces just the Qt
binary. Gate verified against system Qt 6.11.1 (codegen, moc,
qmltyperegistrar, qrc load, offscreen run all green).

km
2026-07-27 11:36:18 -06:00
François Laignel 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.
2026-07-26 21:12:44 +02:00
vicrodh f9713591fe chore: bump version to 2.0.2
Workspace version 2.0.1 -> 2.0.2 (Cargo.toml + lock, workspace crates only —
no dependency re-resolution), a new metainfo <release> entry, and the release
docs: TAG_DETAILS.md (human-facing, About-modal/website format, 2.0.2 section
prepended above 2.0.1/2.0.0) + CHANGELOG.md (full detail since v2.0.1 — the
headless daemon, kiosk mode, dynamic background, cast/quality #638, audio,
rendering #617, library, themes and security work). Codename inherited on the
patch line; About-modal literal untouched.

cc
2026-07-20 19:49:09 -06:00
vicrodh 02c2dba68d feat(qbzd): scaffold slint-free qbzd crate with clap verb tree and CI gate
IV6 build peak: 795 MB max RSS (summed across the cargo-capped build's
process tree via /proc VmRSS polling; /usr/bin/time -v is not installed
on this box). aws-lc-rs in graph: 0 (single rustls provider: ring
v0.17.14 throughout).

cc
2026-07-14 12:53:09 -06:00
vicrodh 7ea9e2567b chore: bump version to 2.0.1
Workspace + lockfiles, the dead-Tauri consistency version fields, the metainfo
<release> entry, and the release docs (TAG_DETAILS.md / CHANGELOG.md).

The About-modal codename and the TAG_DETAILS h1 are left as __CODENAME__ /
__SUBTITLE__ placeholders on purpose — the owner names the release before the
tag (2.0.0 was "Rebuild 破 (You Can (Not) Advance)").

cc
2026-07-08 18:43:27 -06:00
vicrodh e5fcb9d1af fix(text): load Korean TTC faces the macOS Skia renderer refused
Vendor i-slint-renderer-skia 1.16.1 with a second-chance typeface
workaround: skia bug 310510989 makes new_from_data fail for TTC faces
at index > 0 on macOS, and for AppleSDGothicNeo.ttc — which packs
every weight at index > 0 — the upstream read/write-fonts rebuild is
ALSO rejected by CoreText, so draw_glyph_run silently dropped every
Korean glyph run at non-regular weights (Japanese survived because
Hiragino ships one file per weight, always index 0). Resolve the same
face through CoreText by family name (typographic name preferred,
localized names resolve too) + OS/2 weight; the match points at the
same installed file so parley's glyph ids stay valid, enforced by cmap
spot checks that reject a mismatching face rather than draw wrong
glyphs. Probe-verified on the Mac Mini: matched face returns identical
glyph ids for U+C0AC across w500/600/700.

cc
2026-07-05 22:36:48 -06:00
vicrodh 7401d2c3fd fix(text): always draw the first line under overflow: elide
Vendor i-slint-core 1.16.1 with a one-rule patch in sharedparley's
line culling: with overflow: elide, every line whose bottom edge
exceeded max_height was dropped — INCLUDING the first one, eliding the
text to literally nothing. CJK fallback fonts report line metrics
taller than snug fixed-height boxes (Hiragino at 14px measures ~21px
inside TrackRow's 20px title line), so on macOS every CJK track title
rendered blank while Latin titles fit; Linux survived because Noto's
metrics fit the box. Root-caused with a headless render matrix on the
Mac Mini: the verbatim TrackRow structure reproduced the blank title,
removing elide rendered it, and the patched core renders all variants.
The first line still receives the ellipsis treatment as the last
visible line.

This is the real #543 mechanism. The earlier femtovg render_glyph
retry (3a1c367d) addressed a different, hypothesized layer and stays
as harmless defense in depth.

Fixes #543

cc
2026-07-05 18:53:02 -06:00
vicrodh 3a1c367d1a fix(text): render CJK glyphs at non-regular weights on macOS
Vendor femtovg 0.23.2 with a targeted patch: the swash raster path can
fail to scale some variable fonts at a non-default variation instance
(the CFF2-based CJK system fonts on modern macOS), and femtovg cached
those glyphs as permanently missing — so track titles and any other
text at medium/semibold/bold weight rendered blank for CJK while
regular weight (empty coords) worked. render_glyph now retries the
glyph at the font's default instance before giving up: worst case the
glyph draws at regular weight instead of not at all. Fonts and
instances that already render are untouched.

Fixes #543

cc
2026-07-05 08:23:17 -06:00
vicrodh 7252918d4d chore: bump version to 2.0.0
QBZ 2.0 "Rebuild". Workspace, Tauri manifests, npm manifests and the
Flatpak metainfo all move to 2.0.0; TAG_DETAILS for the release notes.
snapcraft.yaml uses version: git and needs no bump.

cc
2026-07-03 18:36:06 -06:00
vicrodh 1cc1b3d922 feat: add qbz-dsd crate (DSF/DFF demux, DSD to PCM, DoP, native DSD)
New frontend-agnostic crate for DSD support. In-house DSF and DFF
demuxers (up to 5.1, DST rejected with a typed error), embedded ID3
tags and artwork via the id3 crate, a verbatim BSD port of Sebastian
Gesemann's dsd2pcm table engine plus a half-band decimation chain
(uniform 88.2 kHz output, ITU stereo downmix for multichannel), DoP
frame packing per the DoP 1.1 standard (marker alternation, 0x69
silence), and native DSD_U32 packing (BE/LE) for kernel-granted
devices. 20 tests over synthesized fixtures.

cc
2026-07-03 17:56:26 -06:00
vicrodh fa3ee1e410 fix(fonts): pre-register macOS CJK fallback families in vendored i-slint-common
fontique's CoreText-based Han-script fallback fails to resolve a family on
modern macOS (the suggested PingFang is not always in the scanned family
map), so Japanese kanji rendered blank while kana worked. Vendor
i-slint-common 1.16.1 and pre-register Hiragino / PingFang / Apple SD
Gothic Neo / Arial Unicode MS in the fallback map at collection creation
(Japanese-biased default; the map takes priority over the CoreText query).
macOS-only cfg; Linux keeps its working fontconfig fallback. Type-checked
against aarch64-apple-darwin.

cc
2026-07-03 10:00:31 -06:00
vicrodh 6e6153b2ef build: strip symbols in the release profile
The release binary carried 173 MB of symbol tables (.strtab 151 MB +
.symtab 22 MB, no .debug_* sections) inflated by Slint's generated
generic names: measured 400 MB -> 218 MB (-45%). Panic backtraces
become address-only; the release workflows archive the unstripped
binary as an artifact per tag for offline symbolication.

cc
2026-07-02 10:11:16 -06:00
vicrodh fe111c3ff6 feat: renderer tier auto-detect with Settings override and auto-revert
Weak-GPU hardware (Raspberry Pi 4 / v3dv, Haswell / hasvk) exposes a
real Vulkan adapter, so the old binary wgpu-or-software detection handed
it the slowest possible path: femtovg-wgpu over an immature Vulkan
driver. Mesa's GL path is the fast one on that class of hardware.

- Three-tier chain: wgpu -> femtovg GL -> software. Auto-detect
  classifies adapters (weak-driver markers incl. hasvk, integrated-on-arm,
  tiny texture limits); adapters only reachable through wgpu's GL backend
  never select the wgpu tier (the renderer masks Backends::GL out, so
  detection must too). renderer-femtovg (GL) feature now compiled on
  Linux.
- wgpu tier requests PowerPreference::LowPower (WGPU_POWER_PREF still
  wins).
- Per-window swapchain alpha in the vendored femtovg renderer: only the
  miniplayer gets a transparent (blended) swapchain; the main window
  keeps an Opaque one, sparing a full-window compositor blend per frame.
- Settings > Appearance gains a Renderer row (Linux only): Auto
  (default) / GPU / GPU compatibility / Software, persisted in
  ui_prefs.json, applied on restart. QBZ_RENDERER env still overrides
  (now also accepts gl|gles|femtovg).
- Auto-revert sentinel: a non-auto choice is armed before backend init
  and disarmed after the window survives 5s; if the previous start died
  before that, the setting reverts to Auto with an explanatory toast, so
  a bad renderer pick can never lock the user out of Settings.
- Developer tools > Diagnostics now reports the active renderer, why it
  was chosen, and the enumerated GPU adapters (panel + markdown report).

cc
2026-07-01 18:05:28 -06:00
vicrodh 99da4c381b feat(slint): pre-launch dev panel, queue controls, appearance persistence, reco cache settings
Sleep timer (queue footer): tokio monotonic deadline + 1Hz countdown that
fires core().pause() at expiry; presets + custom minutes via a popover.

Stop after this song: 4 QbzCore passthroughs + playback consume-before-advance
seam and the load-bearing gapless-prefetch gate, plus the queue marker/menu so
the Queue persists when playback halts.

qbz-log crate: TeeLogger wrapping env_logger (stderr + 5000-line ring +
~/.local/share/qbz/logs/qbz.log) with write-time secret redaction (regex +
live-secret registry) and a diagnostics bundle builder.

Dev panel logs: in-app viewer that shows content the moment it opens, advanced
controls behind a discreet [+], "Copy all" as the sole footer button, a copy
button on the upload URL, and a redacted public paste upload.

Diagnostics: qbz-app::diagnostics (extracted, src-tauri untouched) feeding a
7-section panel with the live active output device + sample rate/format (pactl)
and a complete redacted report for the upload.

Report an Issue: wires the header menu item to a 1:1 Tauri modal that opens the
GitHub bug-report template.

Appearance persistence: 6 toggles that were live-only now persist via ui_prefs
+ handler arm + startup seed (window-title-show, show-volume-steppers,
sidebar-playlist-collage, local-library-track-artwork, in-app-toasts,
theme-filter).

Recommendations cache (per-tab only): TTL select 24/36/48/72h + Refresh now in
ExternalRecoView; no change to the other Discover tabs.

Bump workspace version 0.1.0 -> 1.2.15 so app_version is correct in the
diagnostics report and the QConnect About string. i18n es/de/fr/pt for all new
strings.

cc
2026-06-29 13:19:06 -06:00
vicrodh 408b3c8308 feat(reco): external-recommendations engine (Last.fm + ListenBrainz -> Qobuz)
New frontend-agnostic crate qbz-external-reco: blends Last.fm + ListenBrainz
recommendation signals into the Discover "External Recommendations" carousels,
validating every candidate against the Qobuz catalog (ISRC direct -> MusicBrainz
inc=isrcs bridge -> fuzzy text scorer, gated) before display. Four carousels
(C1 similar artists / C2 similar tracks you haven't heard, C3 listened-but-not-
recently, C4 deep cuts from known artists) capped at 20 with daily rotation over
a larger cache-backed pool, plus a Qobuz-editorial cold-start fallback (top
albums + artists) when no external source is connected. Round-robin blend across
sources. Resolution cache (per-user SQLite, 30d hits / 7d misses, WAL).

Supporting public read methods added to qbz-integrations:
- Last.fm: get_top_artists / get_top_tracks / get_loved_tracks /
  get_recent_tracks / get_similar_tracks (public reads via the Cloudflare proxy).
- ListenBrainz: get_cf_recommendations / get_recent_listens /
  get_metadata_recordings (204/404 -> empty, never error).
- MusicBrainz: get_recording_isrcs (the MBID -> ISRC bridge).

Engine is headless and unit-tested (matching + cache); no qbz-ui dependency.

cc
2026-06-27 02:37:32 -06:00
vicrodh 65de984988 feat(reco): scaffold qbz-reco crate + port SparseVector math
New frontend-agnostic crate qbz-reco (ADR-006) for the playlist Suggested Songs
engine (sub-plan 2). T1: 1:1 port of the Tauri artist_vectors SparseVector
(sorted parallel index/value arrays, set/get, dot merge-join, magnitude/
normalize, serde) with its full test suite (13/13 green). Drops the dead
top_k_similar helper (its only caller find_nearest is dropped per epic D3);
keeps cosine_similarity for parity + tests but not for ranking.

cc
2026-06-26 20:15:00 -06:00
vicrodh 998ab9d5c0 refactor(slint): booklet button downloads the PDF instead of the in-app MuPDF reader
Not every album ships a booklet and the in-app PDF reader was clumsy. The album
booklet button now DOWNLOADS the goody PDF (native save dialog, app HTTP client)
instead of rendering it in-app, and the MuPDF rasterizer is dropped entirely:

- crates/qbz/src/booklet.rs: reduced to set/clear URL + download_booklet (fetch
  + save dialog). Removed the reader (open/render/paging/zoom/rotate/close).
- main.rs: the ("album","booklet") action now calls download_booklet; removed the
  BookletActions reader wiring block.
- Removed the qbz-booklet crate (MuPDF) from the workspace + qbz deps; Cargo.lock
  drops mupdf/mupdf-sys. ~26 MB static MuPDF (~6 MB linked) + its heavy C build
  are gone, lightening the build (more headroom under the 16 GB rustc wall).

AlbumBookletModal.slint + the BookletState/BookletActions globals are now unused
(left in place; remove in a UI cleanup pass that recompiles qbz-ui).

cc
2026-06-26 17:20:15 -06:00
vicrodh d1437c70eb refactor(slint): extract toast into qbz-slint-common crate
New shared crate qbz-slint-common (will also host the artwork primitives via a
dependency-inversion pass). toast.rs is a clean leaf over qbz-ui: only Slint
types (AppWindow, ToastKind, ToastState) + slint core (Timer/Weak). Moved
verbatim; the only edit is the import (`crate::` -> `qbz_ui::`). Bin re-exports
via `pub use qbz_slint_common::toast;` so all 154 call sites across 21 files are
unchanged. slint dep is `default-features = false` (adversarial review: a
default-features slint here would turn on the default renderer for the whole
build via feature unification). `cargo check -p qbz` green.

cc
2026-06-25 18:15:03 -06:00
vicrodh 6f9ad4668e refactor(slint): extract strip_html + dates into qbz-text-utils crate
Both are pure leaves (no bin-module refs, no Slint coupling). Moved verbatim
into qbz-text-utils. Caught by adversarial review: dates.rs needs chrono's
`unstable-locales` feature (Locale + format_localized) and an inline
`qbz_i18n::current_language()` ref (no `use` line — the qbz_models::Track
trap). Bin re-exports via `pub use qbz_text_utils::{dates, strip_html};` so the
14 call sites are unchanged. `cargo check -p qbz` green.

cc
2026-06-25 18:13:55 -06:00
vicrodh 7678bceb3e refactor(slint): extract DAC wizard into qbz-dac-wizard crate
First per-feature crate split off the qbz binary. `dac_wizard.rs` (794 LOC)
was the cleanest candidate: zero cross-feature module deps — it only used
qbz-ui generated types (AppWindow, DacWizardState, …), qbz-audio, qbz-models
and qbz-i18n. Moved verbatim into `qbz-dac-wizard/src/lib.rs` (import
`crate::` -> `qbz_ui::`); the bin now `use qbz_dac_wizard as dac_wizard;` so
its ~20 call sites in main.rs are unchanged. `cargo check -p qbz` green.

cc
2026-06-25 17:54:10 -06:00
vicrodh c8ef2a1b1d refactor(slint): split qbz-slint into qbz-ui lib + qbz bin; reclaim qbz binary
Slint cannot split the .slint compilation across crates, but a Slint LIBRARY
crate exposes its generated Rust types to dependent crates (verified with a
minimal proof). So:

- NEW crate `qbz-ui` (lib): owns the whole .slint tree + build.rs + bundled
  translations; `slint::include_modules!()` + the generated types (AppWindow,
  MiniPlayerWindow, all globals/structs) are re-exported. Translation domain is
  now `qbz-ui` (the .po files renamed accordingly).
- `qbz-slint` (bin) -> renamed to `qbz` (package + `[[bin]] name = "qbz"`),
  reclaiming the binary name from the retired Tauri app. It depends on `qbz-ui`
  and does `pub use qbz_ui::*;` so existing `crate::AppWindow` / `crate::*State`
  references resolve unchanged.

This splits the single ~20 GB rustc into two crates (UI/codegen vs the 75k LOC
of logic) — the foundation for lower build peak, incremental caching, and the
upcoming per-feature crate extraction.

Also fixed for the move: qbz-i18n bundled-.po include paths, the font/WGSL
`include_bytes!`/`include_str!` paths (-> ../../qbz-ui/ui/), workspace members,
dev scripts (`-p qbz`), and the CI workflows. `cargo check -p qbz` is green.

cc
2026-06-25 17:51:09 -06:00
vicrodh 337062f316 feat(slint): port keyboard shortcuts + Open Qobuz Link from Tauri
Adds the full keybindings system (26 actions, 5 categories, customizable
and persisted) plus the Ctrl+L "Open Qobuz Link" cross-platform resolver
to the Slint frontend, matching the Tauri implementation 1:1.

- New shared crate qbz-music-link: de-Tauri'd cross-platform link
  resolver (Qobuz/Spotify/Apple/Tidal/Deezer/song.link via Odesli) behind
  a QobuzSearchBridge trait. src-tauri left untouched.
- src/keybindings.rs: action model, winit shortcut grammar, conflict
  detection, ui_prefs persistence, main-window + miniplayer dispatch.
- src/link_resolver.rs: controller + bridge over QbzCore.
- New globals + 3 modals (read-only cheatsheet, customize editor with key
  capture, link resolver), hamburger menu entries, and a UiFocusState
  text-input guard wired across the app's TextInputs.

cc
2026-06-25 09:43:50 -06:00
vicrodh f92c95ee21 build(qbz-i18n): scaffold pure-Rust i18n crate
Add new no-dependency qbz-i18n crate to the workspace members.
Frontend-agnostic translation catalog per ADR-006 (reusable by
Slint/TUI/headless; no slint or tauri deps).

cc
2026-06-23 13:45:29 -06:00
vicrodh 74880009f6 feat(slint): AlbumView polish — booklet reader, carousels, external links
- Booklet: new frontend-agnostic qbz-booklet crate (MuPDF render to PNG;
  the Tauri Cargo stanza + device_rgb/alpha=false render workaround ported
  verbatim). In-app reader modal (paginate/zoom/rotate/download) driven by
  BookletState/BookletActions; header booklet button now opens it.
- "From the same artist" carousel (/artist/getReleasesGrid) and "Listening
  suggestions" carousel (/album/suggest), reusing the Discover Carousel,
  appended below the track list with their own artwork pipeline targets.
- External Links sidebar block (Last.fm / Discogs / MusicBrainz) built from
  artist + title, opening in the system browser.

cc
2026-06-22 01:27:12 -06:00
vicrodh ff5536a02e feat(qbz-theme): add frontend-agnostic theme registry crate
Introduce the `qbz-theme` crate (ADR-006): pure Rust, no Slint/Tauri/heavy
deps, hand-rolled color + contrast math. It holds the scalable theming data
model the Slint migration consumes.

- `Rgba` + WCAG relative-luminance/contrast-ratio + an APCA Lc approximation
  (secondary a11y gate for P3 tests).
- `ThemeColors`: the complete token contract (surfaces incl. bg-hover, text,
  accent, danger/warning/success families, border-subtle/muted/strong,
  focus-ring, favorite, card-shadow, 24-tier alpha superset with polarity
  baked in).
- `ThemeId` enum (all 33 themes) with stable slugs, display names (proper-noun
  data, not i18n keys), Core/Dark/Light/Accessibility categories, and a
  luminance-derived light/dark flag. `default_theme_id()` = OLED.
- Registry materializes the 4 P1 themes (Dark/OLED/Tokyo Night/System-fallback)
  from src/app.css, preserving the legacy translucent alias values so the
  existing themes stay pixel-identical; derives the new token families.
- 22 unit tests (registry fully populated, default is OLED, legacy alpha
  aliases unchanged, slug roundtrip).

cc
2026-06-20 14:49:53 -06:00
vicrodh 5fc1beb869 fix(slint): vendor femtovg-wgpu renderer for transparent window surface
Slint 1.16's femtovg-wgpu renderer configures the wgpu surface via
get_default_config() and leaves the composite alpha mode at Auto, which
resolves to an opaque surface on KWin/Wayland — a borderless transparent
window then shows a black rectangular backing behind the rounded card.
WGPUConfiguration exposes no surface/alpha hook, so the only fix is a
renderer patch.

Vendor i-slint-renderer-femtovg-1.16.1 with a preferred_alpha_mode() that
picks a supported non-opaque mode (PreMultiplied / PostMultiplied /
Inherit, guarded by the surface's advertised alpha_modes, else Auto) and
wire it into set_surface(). Pulled in via [patch.crates-io]; Slint stays
pinned =1.16.1. The main window is unaffected (it paints opaque).

cx
2026-06-17 18:35:03 -06:00
vicrodh 3689a5fda8 feat(slint): add-to-playlist duplicate modal + playlist importer port
Add-to-playlist (Slices 0-3):
- qbz-core: check_playlist_duplicates + pure compute_playlist_duplicates
  (reuses qbz_models::PlaylistDuplicateResult) + unit tests.
- PlaylistDuplicateConfirmModal (Add all / Add new only / Cancel); the
  duplicate check runs on the Qobuz->Qobuz picker branch only (local/Plex
  add directly, mirroring Tauri); the track-id Vec lives Rust-side in a
  stash, never in Slint.
- Inline create-and-add in the picker (online create_playlist, offline ->
  local playlist per D8) + client-side filter (Slint 1.16 has no string
  contains, routed through a Rust callback) + success toasts.

Playlist importer (1:1 Tauri port):
- New headless crate qbz-playlist-import (Spotify/Apple/Tidal/Deezer
  scrapers, Qobuz matcher, importer, progress sink, shared http client;
  copy-not-move from src-tauri, zero tauri deps; 40 unit tests green).
- PlaylistImportModal (two-step fetch-preview -> import with live progress
  bar / log / summary) + controller + sidebar PLAYLISTS menu entry point.

Reco logging staged behind TODO(reco-v1) markers (golden rule: playlist
recommendations ship in Slint v1 once the reco engine is extracted to a
shared crate).

cc
2026-06-11 22:03:46 -06:00
vicrodh d12039371b feat(lyrics): qbz-lyrics engine crate with Qobuz-first chain, LRC parse/emit, providers, cache
S2 of the Lyrics epic: new shared crate crates/qbz-lyrics (headless,
frontend-agnostic per ADR-006), the whole fetch/parse/cache engine in one
testable home for Slint, Tauri and the TUI.

- Domain model: LyricsDoc/LyricsLine/Word (native Qobuz wsync word stamps
  preserved) + the Tauri-wire-compatible LyricsPayload (camelCase serde);
  LyricsProvider gains the qobuz variant and round-trips it.
- LRC parser ported from the Tauri TS semantics (gap markers as end-of-vocal,
  8s MAX_SUNG cap, median last-line end) with the F5 defects fixed forward:
  multi-stamp lines split per stamp, [offset:] honored. Emitter renders wsync
  line bounds as LRC-with-gap-markers (Q5 persistence form, lossy at word
  level only).
- Providers ported verbatim from src-tauri/src/lyrics/providers.rs: LRCLIB
  search-first + scorer and lyrics.ovh, request shapes byte-identical.
- Cache: same per-user SQLite schema/path Tauri uses (WAL per ADR-002,
  no TTL) plus the additive qobuz_wsync_json column (amended Q5) with an
  idempotent in-place migration for Tauri-era DBs; readers prefer wsync.
  Stats read entries AND size from the per-user path (F1 fix).
- Orchestrator LyricsService: cache probe with uniform plain-only soft miss
  (Q4) -> Qobuz primary (wsync authoritative; plain held while LRCLIB is
  probed for synced, the no-sync-regression rule) -> LRCLIB with one retry
  on transport error -> lyrics.ovh; offline = cache-only with typed
  NotAvailableOffline (F3); in-flight dedupe via shared futures (F6);
  request-key echo on every response for the caller stale guard (F2).
- 55 unit tests: parser/emitter round-trips, scorer matrix, cache lifecycle
  + schema migration, chain priority/offline/dedupe with mocked providers.

cc
2026-06-10 16:22:57 -06:00
vicrodh 5458f3aa74 feat(mixtape): extract shared qbz-mixtape crate (MyQBZ backend, headless)
New crate crates/qbz-mixtape holding the Mixtapes+Collections backend, ported
1:1 from src-tauri/src/mixtape and re-typed against the real shared crates so it
runs headless (no tauri::State, no #[tauri::command] wrappers) per ADR-005/006:

- schema.rs / repo.rs / shuffle.rs copied verbatim (SQLite schema + migrations,
  CRUD repository, DJ-mix similarity sampler). enqueue.rs re-typed to
  qbz_qobuz::QobuzClient, qbz_library::LibraryDatabase and qbz_plex, dropping the
  src-tauri crate::api / crate::library shims.
- ItemResolver is frontend-agnostic: ProdItemResolver takes a caller-supplied
  sync local-resolution closure so &LibraryDatabase (!Sync) never crosses an
  .await. resolve_local_item centralizes the full Local dispatch matrix
  (Album / Track i64-parse / Playlist-unsupported) so frontends cannot drift.
- src-tauri left untouched: Tauri is removed at the end of the Slint port, so the
  temporary duplication is intentional and zero-risk to the working Tauri build.
  Adds qbz-mixtape to the workspace members + rand/strsim workspace deps.

Phase-2 Slice 1 of the MyQBZ Slint port. 57 crate tests pass.

cc
2026-06-09 02:16:20 -06:00
vicrodh 7a7e5f82c4 feat(slint): system media controls — MPRIS with GNOME app icon + SMTC/MediaRemote
New frontend-agnostic crate qbz-media-controls (ADR-006): one MediaIntegration
trait, cfg-split backends. Linux uses mpris-server and publishes the MPRIS
org.mpris.MediaPlayer2.DesktopEntry property as com.blitzfc.qbz — the only way
GNOME Shell resolves the app icon for its media widget (souvlaki, the old
cross-platform crate, never set it, so GNOME showed no icon; KDE is lenient).
macOS/Windows keep souvlaki (MediaRemote/SMTC), where the icon comes from the
app bundle, not a DesktopEntry. The Linux server runs on a dedicated thread
with its own tokio runtime; state arrives over an async channel.

Wired into qbz-slint via src/media_controls.rs: started on shell entry,
metadata/playback pushed from the playback paths (mirroring the tray), and
inbound control events (media keys, the desktop media widget, Now Playing)
dispatched to the player through the existing QConnect-aware helpers. Both
Linux and macOS compile clean.

cc
2026-06-06 11:03:11 -06:00