Qobuz answers `purchasable` on every album and track object (captured in
the API samples); the models never read it. `Some(false)` is the catalog
saying a release is not sold, absence stays unknown. The exhaustive test
fixtures in qbz-core and qbz-playlist-import spell the field out.
cc
The match_qobuz test helper builds a qbz_models::Track exhaustively, so the
new favourites timestamp field has to be spelled out there too. Test-only
code: the release binary was never affected, only the CI-mirror gate.
cc
Correct dead platform declarations, stale rustdoc and test warnings; validate complete gettext headers. Install LLD in the Qt job and explicitly validate committed shader packs without rebaking them. Keep the independent shader bake gate and add three build-policy tests.
The full local gate passed 2398 workspace tests and 715 Qt tests, QML audits, shader baking and the isolated offscreen smoke without compiler, rustdoc or gettext warnings.
cx
Initialize the four new optional AlbumSummary fields in all six test fixtures. Preserve absent metadata semantics and restore workspace test compilation.
cx
The artist page's "In library" count was a snapshot of the Library feed
taken when the document was built, over a feed that only loads when the
Library view is opened and that never gains a row for a favourite created
afterwards. On a cold session, or after an account switch, every artist
opened with an invisible toggle; hearting an album on the page moved
nothing until the next session.
- library_qt: `artist_library_items` computes the tab's rows with the
Library > Albums/Tracks rule (favourites ∪ purchases, one row per
entity, purchase row representative, `is_favorite` = also hearted);
local/Plex and `following` rows never enter. A track is a member
through its performer OR its album's main artist (new
`FeedItem::album_artist_id`, fed by the new `AlbumSummary::artist`,
which the wire carries — search-results capture).
- library_qt: `reconcile_qobuz_favorite` inserts the row of a freshly
hearted track/album (one `/track/get` or `/album/get`, mapped by the
same producers the full load uses) and drops the `favorites` rows of a
cleared one, keeping a purchase row with its heart cleared.
- artist_qt: the rows ride inside the artist document (`libraryItems`),
and `refresh_library_items` / `apply_favorite_change` republish them
through `publish_patch` when the feed lands or a heart settles. A
debug-level log prints the membership split for the open artist.
- main: `settle_favorite` is the one settle point (reconcile, fan out,
republish on membership change) for the Library and queue-panel
hearts; `open_artist` warms the feed in the background; `reload_library`
hands the open page its rows; `warm_library` serves the toggle from
cache.
Tests: membership by performer or album artist, exclusion of local /
following, dedup on the purchase row, and the `top_queue` contract that
a foreign id starts the popular list at row 0 (the #737 defect class).
cc
QueueTrack gains two additive, serde-default identity keys (the ISRC and
the MusicBrainz recording id) that the listen log stamps; every
constructor in the workspace sets them (Qobuz-backed ones from
Track.isrc, local ones from the scanned tags, the rest None). They are
JOIN keys, not the row identity: numeric ids collide across sources, so
(source, native item id) stays the identity.
Track.audio_info (replaygain_track_gain / peak on DiscoverAudioInfo) is
modelled from the captured search envelopes; no reader yet and no
popularity field (0 in every sample).
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
`#[serde(default)]` on a bare bool means an absent key deserializes to FALSE,
so a catalog Track from any endpoint that omits `streamable` came back marked
unavailable. The models' own tests document this as a hazard rather than a bug
(the "streamable split": PurchaseTrack defaults true, catalog Track defaults
false, and the comment warns that a wrong default "inverts that whole screen's
behaviour"). The inferred OpenAPI disagrees with the code and documents
`default: true`.
That was survivable only because nothing read the field. It stops being
survivable the moment a frontend greys rows out and keeps them out of the
queue: one terse endpoint would blank an entire view, silently.
`None` now means the endpoint made no claim, and only `Some(false)` marks a
track pulled. `is_streamable()` is the ONE place absence is interpreted, and it
resolves to available: refusing to play a track because a response was terse is
strictly worse than playing one that turns out to be dead, which the reactive
skip path already handles. Album.streamable was already Option<bool> with a doc
comment naming the same causes, so this makes Track consistent rather than
novel.
Every consumer in the workspace moves to the helper: both matchers, the ISRC
validator, the daemon driver, the purchases service (which maps catalog Track
into PurchaseTrack and therefore feeds the very screen the split protects), the
radio pool, the mixtape resolver and the QConnect renderer. The replaced tests
pin both directions of the new contract.
Also here, because it lives in the same file as the matcher's streamable skip:
`rank_candidates`, an additive public entry point over the existing weighted
matcher (ISRC short-circuit, title/artist/album weights, duration bonus,
stop-word normalisation, quality tie-break). `match_tracks` is untouched.
cc
Run unit/doc tests for the shipping crates workspace on PRs without
compiling qbz-ui. Fix three green-blockers: local playlist schema for
tests, Track.work fixture, and a stale ListenBrainz doctest.
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