Add bounded native library models, complete grouping rails and scan progress. Bring source-aware metadata editing, media information and collection offline preflight to Qt, and repair inherited Plex album artwork without overriding healthy tokens.
cx
The preset swaps the BASE shortcut table underneath the user's overrides
instead of rewriting them, so Default <-> Vim is lossless. Vim binds single
modifier-free keys: n/p, h/l, Shift+J/K, /, and Ctrl+o / Ctrl+i for the jump
list; rows with no vim idiom keep their defaults.
j and k are deliberately left UNBOUND. They belong to a per-list cursor,
which QBZ has no notion of yet, and a global action bound there now would
have to be taken back the day one lands. A test holds them free.
Where an override has already claimed a shortcut the new base wants, that
action goes unbound rather than double-binding a combo that
action_for_shortcut would otherwise resolve by BTreeMap id order.
Seven new tests, including a gate that fails if any preset ever binds one
shortcut to two actions.
Eight Playback actions the table never had, on Spotify's desktop defaults:
volume up/down (Ctrl+Up/Down), mute (M), shuffle (Ctrl+S), repeat (Ctrl+R),
like (Alt+Shift+B), and a surface-independent seek (Ctrl+Shift+Left/Right).
Each reuses an existing transport seam, and the immersive bare-arrow seek
rows are untouched. Volume honours the player bars' volLocked predicate, so
the hotkey cannot move the slider on an ALSA-Direct hw path where the engine
documents the write as a no-op. Like rides a QML seam, as nav.search does,
because the track id is a QbzPlayer property rather than part of the Rust
now-playing model.
Two new tests; labels added to all eight catalogs.
Three strings: the modal's title, its "Don't show this again" box and its
button. The BODY is not among them and stays English on purpose -- it is the
author's statement of what the Windows build does and does not promise, and the
wording is the point.
`en` is deliberately untouched. That catalogue is sparse, carrying only the
strings whose English differs from the msgid, and a missing lookup falls back
to the msgid, which is already the English text.
The register follows what each file already had, "I understand and accept these
terms" in particular, so the button reads like the rest of the app rather than
like a second voice arriving.
cc
Port the Tauri immediate and accumulated offline policies into the active Qt fire path, retain independent scrobbler sessions across Qobuz logout behind a default-on opt-out, and flush queued Last.fm/ListenBrainz plays only when the effective policy permits it.
cx
Analyze audible tracks into a progressive 512-bin RMS document, cache complete results, and preserve gapless track boundaries. Render the waveform through a scenegraph item in both player bars behind a default-off preference.\n\ncx
Add a theme-aware goniometer and pitch-locked oscilloscope to the large dock and immersive focus modes.
Gate scope DSP and scenegraph updates by actual panel visibility so hidden modes remain idle, including while shader scenes cover immersive panels.
cx
Consume preflight data when loading clears so the modal cannot spin indefinitely.
Reuse the editor for ephemeral folders and bind sidecar or direct writes to the exact active session.
cx
Restore the selected-version editor with sidecar-safe defaults, inspected tag layers, guarded MusicBrainz and Discogs lookup, and confirmed verified direct writes. Keep paths Rust-owned and reject stale or injected drafts.
cx
Local-only mode already WORKED — verified by driving a genuinely fresh
profile (no credentials, keyring unreachable): "Start offline" enters the
guest profile, Local Library opens with its proper empty state, Settings
renders in full, zero QML complaints. The capability was never the
problem. Two pieces of the screen were.
1. THE SIGNPOST WAS OFF FOR EXACTLY THE RIGHT PERSON. The callout with
the caret pointing at the Start-offline link was gated on
`hasPreviousSession && connectivity === 2`. `hasPreviousSession` is
seeded from `load_last_user_id()` — None on a fresh profile — and only
ever set true by a successful Qobuz login. So the one piece of UI that
says "you can use this without an account" could not be seen by
anybody who had never signed in. The gate was written for the
returning-user-with-no-wifi case and the guest case was bolted onto
the link alone.
2. THE COPY WAS FALSE. Once that box is visible to a first-run user with
working internet, "No internet connection" is simply untrue, and the
offline placeholder said the same thing after entering: measured on a
fresh profile, the log read `connectivity Up, induced false,
offline_session true` while the screen claimed the network was down.
Both now branch on which of the THREE states this is — induced
offline, no connectivity, or no account — and the captive-portal line
is gated on connectivity too, so it cannot appear under a "No Qobuz
account?" heading.
Two new msgids in all seven non-English catalogues.
Verified after the change on a fresh profile: the callout appears, the
link is reachable, and the placeholder reads "You're using QBZ without a
Qobuz account."
HARNESS NOTE for whoever tests this next: XDG_CONFIG_HOME sandboxes the
token FILE but NOT the system keyring, and a rejected token there calls
`clear_oauth_token`, which deletes the keyring entry for the real user.
Point DBUS_SESSION_BUS_ADDRESS at nothing so the Secret Service is
unreachable. (The file stays authoritative, so nothing is lost — but the
run is not clean either.)
cc
The msgid IS the English string, so adding "(requires restart)" to the
description re-keys the entry in all seven non-English catalogues. Same
wording Interface size two rows above already carries.
cc
Five new group headers, the Font row's description, and the two strings
the Library folders subsection needs, in all seven non-English
catalogues. The English catalogue carries only overrides, so a msgid
whose English form IS the string needs no entry there.
Checked by switching a running app to Spanish: every new header renders
translated.
cc
481 entries across en, es, de, fr, pt, ru, ja and nl: the rip wizard and its
progress panel, the metadata modal, the per-disc blocks and the track filter,
every new toast, and the tooltips.
Spanish is Mexican/neutral and was scanned for voseo and Castilian before
landing.
cc
The button lives on the ephemeral pane's header and appears only for a
PHYSICAL disc — an image is already a file, so offering to rip one would be
offering to copy something the user already has. Whether the session is a disc
is DERIVED from its rows rather than passed in: a flag somebody has to
remember to set is a flag that eventually is not.
The destination is always asked. The app knows several plausible music folders,
which is exactly why it must not pick one — a ripper that decides where your
music goes is a ripper you clean up after.
While a rip runs the button gives way to its progress ("3/7 · 45%") and a
spinner: one control, one state, rather than a button that looks pressable
during a job it cannot start twice. The bar is per-DISC and not per-track,
because a listener watching a 79-minute album wants to know how far the ALBUM
is, and a per-track bar that resets seven times reads like a stall.
Driven end to end on the owner's Fear Inoculum: seven tracks written into
"Tool - Fear Inoculum/", every one decoding clean and seekable, all tagged,
no `.part` left behind.
Four new strings in all eight locales.
cc
`Open > Open SACD image…` picks a .iso, reads its stereo area and publishes it
as the ephemeral session. Nothing lands in library.db.
Unlike a CD, a SACD needs no network to be named: the Master TOC carries the
album title and artist, and the area TOC a title per track. The owner's disc
opens as "Wagner: Das Rheingold", 24 tracks, every one with its German title
and "Berliner Philharmoniker, Herbert von Karajan, etc." — all of it read off
the image.
Routing is one line in `LocalSource::playback` and none in the player: a
`sacd:` reference rides the existing DsdFile ticket, because `open_dsd` is the
funnel all six player-side DSD constructions already go through. Teaching it
there is what makes the PCM, DoP, native and gapless paths inherit disc images
for free.
Driven end to end on the owner's Rheingold: the tab reads "Wagner: Das
Rheingold 24", the badges read HI-RES / DSD64, and pressing play took the DoP
route —
Player: DSD track — DSD64 via DoP (176400 Hz carrier)
[ALSA Direct] DoP hardware configured: 176400Hz, 2ch, S32_LE
Audio thread: DoP playback STARTED
which is the outcome that matters: the disc's DSD bits reach the DAC without
being converted to PCM at all.
Six new strings in all eight locales. Unsupported cases are named rather than
approximated — a multichannel-only area, a DST-compressed one and a file that
is not an image each get their own message.
cc
`Open > Open audio CD` reads the disc, builds an ephemeral session and lands
on its tab. Nothing touches library.db, which is the whole point of the
ephemeral path: a disc is content from outside the index that leaves with the
plastic.
It does not invent metadata. CD-DA carries no titles, and the disc measured
here reports its MCN as all zeros, so tracks are "Track N" and the album is
"Audio CD". A DiscID lookup is the obvious next step and is deliberately not
faked: a wrong title is worse than an honest number.
The feeder is the interesting part, and its liveness check took three designs
because the first two were wrong in opposite directions — both caught by
driving the app, neither by any test:
1. `current_track_id() != play_id`: `play_streaming` posts to the audio
thread and returns, so the id is not ours yet and the feeder retires
before reading a sector. Nothing played at all.
2. The same test with a wall-clock grace: a DEADLOCK. That field is stored
only once the audio thread starts playback, which needs the initial
buffer, which is what the feeder is filling. Waiting on it is waiting on
ourselves; the grace expired and the track stopped mid-play.
3. A feeder slot the audio thread plays no part in. A newer disc play claims
it and the older thread retires on its next block. The acknowledgement
path stays for "something else superseded us", but only AFTER the player
confirms our id, and with no timeout — a timeout is what kills a healthy
feeder.
A 16-bit WAV header, not `qbz_dsd`'s: that one is hardcoded to 24-bit, and
reusing it would either lie in the header or promote 16-bit samples to 24.
The shift is arithmetically lossless but it makes the DAC open at 24 bits for
a 16-bit source, which is precisely the silent conversion a bit-perfect path
must not contain. `play_streaming` also does not publish bit depth, so the CD
states 44 100/16 explicitly instead of inheriting the previous track's.
Driven end to end on the owner's disc: seven tracks with the right durations,
CD / 16-bit / 44.1 kHz badges, ALSA Direct opened at 44100 Hz in exclusive
mode, playback past 2:25 with no read errors, and two rapid skips retiring
their feeders in ~160 ms each.
Seven new strings in all eight locales.
cc
"Open", "Now Playing" and "Playing: <album>" were all tried and dropped. A
verb is a claim the tab cannot keep: a folder can sit open while something
else plays, and then the tab and the now-playing bar name different tracks on
the same screen. The content's own name cannot go stale that way, and the tab
appears the instant you open something, so nothing has to explain it.
The chain lives in Rust so the tab and the nav flyout read ONE value
(`localEphemeralLabel`) instead of deriving it twice — the flyout does not
parse the ephemeral document and should not start:
single album -> its title (a folder IS an album, and so is a disc)
otherwise -> the medium's display name, what the pane's header shows
neither -> "Media"
Elided at 24 CHARACTERS, not bytes: `QbzTabBar` sizes a segment to its text,
so an unbounded folder name pushes the whole bar off screen — and slicing a
UTF-8 string by byte index panics mid-codepoint on a Japanese album title.
There is a test for exactly that.
New msgid in all eight locales. Verified on screen: the tab reads "Live 8".
cc
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
QBZ-I18N WAS NOT FRONTEND-AGNOSTIC, whatever its own header said. No slint or
tauri entry in its Cargo.toml — and eight `include_str!` reaching into
`crates/qbz-ui/translations/`. Deleting the Slint crate, which is planned before
the Qt release, would have failed to compile `qbz-i18n` and with it `qbz-qt`,
i.e. the entire app, over a data directory nobody read as a dependency. The
extraction that created this crate took the code and left the data behind.
The eight .po files move to `crates/qbz-i18n/translations/` (git tracks them as
renames, so history follows) and the includes become `../translations/`.
`qbz-ui/build.rs` borrows them back through `../qbz-i18n/translations` for as
long as that tree exists — the direction of the dependency is what matters, and
it now points the way the deletion needs.
MACOS: THE IMMERSIVE BAND GOES BELOW THE TRAFFIC LIGHTS, not beside them. It
was inset to their right with the shell HeaderBar's rule, which is right for the
shell — the lights sit at the head of a full toolbar row there — and wrong here,
where the row is three floating glass pills over artwork and a 44px pill parked
against the lights reads as debris.
Vertical clearance comes from `SafeArea.margins.top`, the same question the left
inset asks in the other axis: with ExpandedClientAreaHint the client area covers
the titlebar region and SafeArea reports how much of it is unsafe, with a 28px
floor for a 0 reading. The band keeps `leftMargin: 24` on every platform, which
puts the trigger's leading edge in the same 20-24 band the lights start in, and
the 16px gap under them is the band's own margin reused rather than a new
number.
The view trigger matches the cluster's width on macOS (SafeArea left margin,
78 floor): stacked, a 44px pill under a ~78px cluster reads as misaligned even
with the left edges lined up. Its radius becomes height/2 rather than a constant
18, or the widened pill would have been a rounded rectangle beside three
circles. Every other platform keeps the natural 44px, and the glyph was already
centred so it needed nothing.
The menu opens downward from a band that now has more room under it, so the
flyout has more clearance than before rather than less.
Built and smoked on Linux (gate green) and built on the Mac mini M2 (3m32s, no
errors). The macOS geometry itself needs eyes on a Mac.
cc
Ports PR #561 by @pbaart into the live gettext system (the PR
translated the retired Svelte catalog): full nl.po (1481 msgids, 13
plurals, msgfmt-clean, zero untranslated), 262 strings taken directly
from the contribution via the legacy-English bridge and the rest
translated using the contributor's terminology as the style guide
(nummer/afspeellijst/wachtrij/bibliotheek, informal je). qbz-i18n
registration (2-form plural, resolve_auto nl_NL->nl, 21/21 tests) and
the language dropdown at index 8 (Nederlands) across ui_prefs,
reseed_i18n_labels and state.slint — same shape as the ru/ja commits.
Dutch translation credit: pbaart (PR #561).
cc
- Album view: classical "work" section grouping (PR #536 port) — new
Track.work model field + run-length per-work headers in AlbumView.
- i18n: add Russian + Japanese locales (1365 msgids each); extend plural.rs
(ru 3-form one/few/many, ja single-form) + tests; fix the language-selector
reseed (main.rs set_languages + ui_prefs index maps). Seven languages total.
- About modal: native port of the Tauri About (version, links, build info,
one-paragraph acknowledgments, inline contributors with GitHub avatars,
signature). Contributors credit hoyon (PR #536) and mxnix (PR #517, ru).
- What's New modal: renders the GitHub release-tag description (markdown
sections + TOC); release fetch + build.rs emits build date/commit.
- Discover Recommendations: move Cache window + Refresh now into the Customize
modal, gated to the reco tab; hide the section-config UI there; add an
explainer paragraph. New strings translated to all six non-English locales.
- About modal width +120px; splash screen gains the legal disclaimer footer.
cc
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
Adds qbz_i18n::mark() (identity, extraction-only) and applies it at the
definition sites of literals later translated through t(variable):
release-type classifier, discover section labels, artist release-section
titles + month names, label jump tabs, and audio settings dropdown tables.
Fixes the discover_prefs double-t() (label_for now returns the marked
literal, translated once at the consumer).
cc
POSIX locale precedence is LC_ALL > LC_MESSAGES > LANG. resolve_auto
previously only checked LC_MESSAGES and LANG; add LC_ALL as the
highest-priority env var, skipping it when empty. Extend the
resolve_auto test (env-mutation serialized via the existing Mutex).
cc
Implement the frontend-agnostic translation layer test-first:
- plural.rs: PluralRule parsing nplurals + the two expressions our
locales use ((n != 1), (n > 1)); index(n) selects the form, with a
safe English default for anything unrecognized.
- po.rs: Catalog::parse handles the header entry, msgid/msgid_plural/
msgstr/msgstr[N], multi-line continuations, and \n \t \" \\
escapes; empty msgstr means no translation (get returns None so
callers fall back to English). Reads Plural-Forms from the header.
- lib.rs: 5 catalogs embedded via include_str! and lazily parsed
(OnceLock); AtomicU8 language state; set_language/current_language/
resolve_auto (LC_MESSAGES then LANG -> 2-letter prefix); t/tn/t_args/
tf with left-to-right {} substitution and English fallback.
17 unit tests, all green via cargo test -p qbz-i18n. Content tests use
inline fixtures since the bundled .po files are header-only for now.
cc
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