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
get_tracks_for_eviction had no caller; the resolve-path comment claimed
check_cache_limit evicts by last_accessed_at, but it only refuses new
downloads — downloaded albums are kept by design.
cc
Swim Deep's Artist page showed Deep Purple's founding year and members. Two
defects combined: `search_artist` emitted `artist:Swim Deep` unquoted (the
second word leaks into the global query; Deep Purple ranks first at 100,
Swim Deep second at 88), and `resolve_artist` fell back unconditionally to
the first result when no exact name scored >= 90. Name-keyed caches then
made such guesses durable and shareable across homonyms.
New `qbz-integrations::musicbrainz::identity` — pure decisions plus
orchestration over injected fetcher/store/catalog boundaries:
- query `artist:"<lucene-escaped name>"`, URL-encoded once;
- containment rule: exactly one exact-name candidate with score >= 90, else
None — the first-result fallback no longer exists;
- source direction (Qobuz artist id -> MBID): the page's own Popular-Track
ISRCs (max 3, guests excluded) vote among the exact-name candidates using
every credit of every recording that carries the ISRC; ties, conflicts and
failures fail closed;
- scene direction (MBID + catalog scope -> Qobuz id): a unique exact-name
Qobuz artist is provisional; several homonyms are split by their own
top-track ISRCs (max 2 each); no evidence omits the row instead of taking
max(albums_count);
- four `*_v2` cache tables keyed by the stable identifier + matcher version,
carrying evidence kind, expiring at read time (verified 30 d, provisional
7 d, search windows 7 d, ISRC credits 30 d); legacy name-keyed rows are
neither read nor written; scene cache key bumped to v2 with the real
account territory (no scope -> no cache), rows re-derived on replay.
Core: `catalog_scope()`, `musicbrainz_resolve_source_artist`, store/catalog
adapters; `musicbrainz_resolve_artist` and the musician resolver go through
the containment rule. Artist page passes {qobuz id, own ISRCs} to core and
keeps its generation guard; QML, playback, auth and reco crates untouched.
Tests: qbz-integrations 59 (query encoding, #768 ordering fixture, ISRC
voting incl. collaborations/dupes/conflicts, cache keys/TTL/version/legacy,
bounded fan-out with request counting), qbz-core 40, qbz-qt 782.
cc
- musicbrainz_resolve_artist is now cache-first + write-after via
MusicBrainzCache::get_artist/put_artist (resolved_artists), the same shape as
the metadata path, so reopening an ArtistPage no longer re-runs a live
artist?query= search. Negatives are never cached; the existing cleanup(ttl)
governs expiry.
- Remove the scene's one-shot Retry-After backoff: the client already retries
503s, so stacking another wait could push the total past ~10s.
cc
Keep queue edits from resetting the playback clock, preserve Play next and Play later batches, and report observed renderer state and owned output telemetry. Guard duplicate-track gapless transitions by queue occurrence.
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
Playlist and PlaylistWithTrackIds deserialize the API updated_at stamp
(absent on some list shapes, hence optional), and the core facade
exposes the lightweight playlist/get?extra=track_ids fetch the
membership hydrator drives.
cc
Player::open_external_stream starts the CMAF download and returns a
shared buffer (exact final FLAC size known from the segment table) once
the first segments are in, instead of after the whole file. The finished
download is cached like a played stream. Core exposes it as
open_external_stream_resolved.
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
OfflineCacheDb::touch had no caller, so LRU eviction ordered by the
download date and evicted the most-played tracks first. Touch in
resolve_offline_bytes, the one resolver every offline play goes through.
cc
recommendations_qt and external_reco_qt built private MusicBrainzClient
instances, which start enabled and ignore the musicbrainz_enabled
opt-out that integrations_qt applies to the core's client. Expose the
core's Arc<MusicBrainzClient> and borrow it instead, so a disabled
MusicBrainz sends zero requests from Discover > Recommendations. The tag
editor's three private clients stay: those are explicit manual lookups
the user triggers by hand.
cc
detect_profile fell back to MemoryProfile::from_total_kb(u64::MAX) -- the top
tier -- on any machine without /proc/meminfo, and read_memory_pressure
returned None forever. The prefetch and buffer tiers were therefore pinned at
the top on Windows and the memory watchdog was blind: it continues on None, so
evict_l1_audio_cache and the prefetch halt could never fire at any pressure.
Both halves had to land together. With the old u64::MAX total, a working
pressure probe would have computed roughly 0 per cent available and reported
critical permanently.
ullAvailPhys is the analogue of MemAvailable: what an allocation can take
without going to disk. The Normal-fallback contract is preserved, so a probe
that fails still never throttles the machine. windows-sys 0.61 is already in
the workspace lock via qbz-local-catalog and qbzd, so nothing new is pulled.
Measured on a 32 GB box: 57.9 per cent available, class Normal, which is the
same class the old fallback produced -- the change is the true total and a
pressure figure that exists at all.
cc
Preflight fallible queue transitions and publish now-playing state only after the requested source is audible, leaving the prior stream intact when a file or server is unavailable.
Carry source-owned request headers through remote probes, streaming and fallback downloads without exposing sensitive values.
cx
The sibling of patch_plex_quality, for the other thing a row can learn after it
was enqueued. A disc carries no artwork — it is fetched, and the fetch outlives
the click that started playback — so a CD queued at second two is still holding
artwork_url: None when the cover lands at second ten. Every consumer downstream
reads the QUEUE row (the now-playing bar, the miniplayer, MPRIS through
art_url_for), so patching the session store alone left all of them blank.
Rows that already have art are left alone: this fills a gap rather than
overwriting a cover somebody else resolved. It answers two questions
separately, because they drive different work — any patch makes the queue
panel's thumbnails stale, only the current one needs the now-playing stamp
re-pushed.
cc
`#[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
Both views were absent from the Qt frontend, not partial. This lands them
together with the core engine they need and the two entry points that reach
them.
- qbz-core gains scene discovery ("artists from the same place"): a
cancellable run with a phase for the progress label and a typed
SceneDiscoveryError, so a view can tell offline, all-upstreams-failed and
an honest empty result apart. CancelToken, ScenePhase and SceneDiscoveryError
are re-exported for frontends.
- qbz-integrations: MusicBrainz location resolution plus its cache layer.
- qbz-qt: ArtistSceneView with its three scene components, MusicianPageView
and MusicianModal, backed by artist_scene_qt.rs, musician_qt.rs and the two
bridges. The five-branch musician dispatcher is wired to all six of its
consumers. Both scene doors are live: the sidebar location link and the
overflow row that shipped grey with action: "stub".
- Translations: the new msgids in all eight locales.
Three defects fixed on the way, all with the same signature — a comment
asserting a mechanism that does not exist:
- Volume opened at 100 %. The comment claimed the poll loop mirrored it from
the engine; now_playing::set_volume has two callers and neither runs in a
local session.
- mbAvailable was documented as "MusicBrainz enabled AND resolved to an MBID"
while being assigned from the opt-in alone. Consumers now test the MBID.
- Session restore could not resume from a cold cursor ("cannot resume - no
audio data available") for want of a has_loaded_audio() guard. Investigation
in qbz-nix-docs/qt-frontend/2026-08-14-cold-cursor-resume.md.
Contract and evidence:
qbz-nix-docs/qt-frontend/2026-08-14-artist-scene-musician/00-CONTRACT.md,
which wins over specs 01..09 and records 20 deliberate divergences from the
reference in section 6.
cc
- Sidebar + playlist manager sorts gain both directions with a caret on
the active row (re-select flips); 'recent' keeps the API order
(newest-first — the old reverse was the #657 bug).
- Import now inserts the new playlist optimistically and bounded-retries
until the API lists it (read-after-write lag forced a manual refresh).
- Play later: tracks land at the end of the manual block (after every
play-next/later, before the source resumes); Play next and Add to
queue are untouched. The queue sidebar shows the section divider
(Next in queue / Next up) with drag-math compensation, and the context
menu shows the landing position of each queue action.
km
SharedClientProviders implements the new language-aware LyricsProviders
boundary; both track-change and prefetch requests pass None (the default
original-only flow per spec section B.1) — the refetch-with-language is
triggered by the UI layer in a follow-up task. The committed LyricsDoc
now carries the translation into CURRENT_DOC automatically, so the sync
engine and the future UI toggle read the same document. qbz-core's
headless get_lyrics wrapper keeps its signature and fetches
original-only.
km
The per-track context menu in the Queue sidebar had two placeholder items
with no click handler: "Remove all after" and "Add to playlist". Wire both
end to end.
- QueueState gains remove-all-after(int) and add-to-playlist(int), resolved
from the page-local row index like remove-upcoming (search-filter aware).
- Core/player gain remove_upcoming_after(upcoming_index): truncates the
upcoming list in PLAY order (shuffle-aware, reuses remove_upcoming_track),
clears the stop-after marker if it lands in the removed range, and emits a
single QueueUpdated. The pre-existing remove_after (absolute-index, not
shuffle-aware) was dead code and stays only for its unit coverage.
- Add to playlist reuses the save-as-playlist picker handoff seeded with the
single row's track id.
cc
qbz-core tests did not compile since the Album model gained these
fields; the test-only helper never got them. Found while adding the
poisoned-item parse_page test.
cc
Phase 1 (headless). Adds the album-id filter axis on top of the artist
blacklist, orthogonal to it (an album is hidden by its OWN id regardless
of artist — the fix for Qobuz same-name artist merges).
- qbz-core: AlbumBlacklistFilter (HashSet<String>); album_blacklisted /
track_blacklisted / discover_album_blacklisted / pick_most_popular /
parse_search_all / search_all gain an album-id arm; fail-open guards
widened so an album-only block is not skipped. Artist arms (artist
search retain, pick_most_popular artists) stay artist-only. +6 tests.
- bridge: is_album_blacklisted / album_ids_snapshot / card_blacklisted;
stamp_row + is_track_blacklisted take the row's album id (source!="qobuz"
guard preserved; render/queue stay in lockstep).
- enforce at every album-bearing consumer, threading the album snapshot:
search (page-1 + load-more), home, discover, for-you (+spotlight),
album rows + queue + carousels, playlist/mix/favorites rows.
- close pre-existing leaks (both axes at once, owner decision E): label
pages (were entirely unfiltered — the prime merged-artist surface),
search load-more/replace-category, favorites albums grid, artist
discography (own-id), myqbz collection albums.
- albumview: full readable release date in the header ("Feb 19, 2026")
via the localized %b formatter, replacing the year-only slice.
cargo test -p qbz-app (12) + qbz-core (39) green; cargo check -p qbz green.
cc
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
MusicBrainz was hardcoded ON in the core client with no toggle, no
persistence and no startup wiring, so it could never be turned off. Add a
persisted opt-out (default ON: ui_prefs.musicbrainz_enabled +
Settings>Integrations toggle) that seeds the core client at startup and
flips it live via handle_bool.
Gate the MB-dependent surfaces when off: the artist Network/Scene sidebar
marks itself unavailable and opens on the MB-independent Magazine tab;
the playlist "Suggest songs" CTA hides and activate() early-outs; and the
musician page tolerates a disabled/failed MB resolve, falling through to
the Qobuz fallback instead of rendering blank.
cc
The heart on a Qobuz playlist detail was wired to Qobuz /favorite/create,
which does not accept playlist_ids, so it persisted nowhere and never
reflected in the library. Rewire it to the INTERNAL library favorite
(db.set_playlist_favorite, mirroring the Favorites card + Tauri's
v2_playlist_set_favorite) and seed it on open.
Add the two missing actions on a non-owned Qobuz playlist:
- Copy to library: clone it (create + add all tracks, carrying the source
artwork + attribution) as an internal owned copy.
- Follow on Qobuz: subscribe/unsubscribe via new qbz-core passthroughs to
the existing client subscribe_playlist/unsubscribe_playlist.
Surface hearted-but-not-owned playlists in Library>Playlists by merging
the internal favorite ids into the manager list (they don't come back
from get_user_playlists). Copies/follows already appear there (owned /
subscribed). New PlaylistState.is-following / is-copied.
cc
Add QbzCore::generate_playlist_suggestions + a per-user artist_vectors store
field (Arc<tokio::Mutex<Option<ArtistVectorStore>>>) with async
set_artist_vectors / clear_artist_vectors. It resolves each playlist artist NAME
to a confident MusicBrainz id (reusing musicbrainz_resolve_artist; the names come
from the playlist's own Qobuz tracks so they're already canonical, making the
Tauri command's extra Qobuz-name re-fetch unnecessary), then runs the
SuggestionsEngine over the core-owned clients + vector store.
Also aligns the qbz-reco builder/engine handle types to qbz-core's reality (the
plan's by-ref pattern doesn't fit): mb_client = Arc<MusicBrainzClient> (no lock,
Send+Sync); mb_cache = Arc<std::sync::Mutex<...>> (locked only in await-free
windows); Qobuz client = Arc<RwLock<Option<QobuzClient>>> (unwrapped per access).
The vector store stays tokio::Mutex (held across awaits by the engine). No
dependency cycle (qbz-reco does not depend on qbz-core). qbz-core checks clean;
23 qbz-reco tests green.
cc
Three restart-persistence features built and tested together (they share
ui_prefs + the enter_shell restore sequence, so kept as one change):
- Startup page: persist + restore the last SAFE top-level view via the
canonical apply_entry dispatcher (loads data, not a blank view). Wired to
Settings > Appearance > "Where you left off"; ui_prefs startup_page/last_view.
- Window geometry: persist logical size + outer position and restore them at
launch, clamped to the current monitor so a smaller/disconnected display can
never open an oversized or off-screen window (winit Resized/Moved hook).
- Playback session: new session_persist module wiring qbz-app's SessionStore.
Saves queue + current track + volume + shuffle/repeat + position on
track-change / pause / ~5s tick / exit (incl. hide-to-tray and tray Quit);
restores the queue PAUSED at launch. Position resume on first play threads
the player's existing start_position_secs (no protected-audio change beyond
passing that param). Gated on the persist_session / resume_playback_position
playback prefs; opened per-user on login + boot-restore + offline entry.
Known issue: the seek bar/timer do not visibly show the resume position until
play is pressed (the audio resume itself works); marked in code for follow-up.
cc
Bucketing now trusts the server release_type: drop the foreign-artist filter
that hid band/guest releases (e.g. Vicky Psarakis' Albums were credited to
Sicksense), render every non-empty bucket, hide 'Purchase Only' (download),
and move 'Other' to the end, collapsed by default. Drop the misleading
per-section item counts.
New 'Latest release' highlight (last_release) and 'Appears On' track section
(tracks_appears_on, previously mislabeled as Compilations).
Per-section persisted 5-mode sort (artist_prefs) + per-section load-more
capped at 4 pages, reusing get_releases_grid. New dedicated discography page
(ArtistReleasesView + artist_releases controller) with sort + infinite scroll,
reached via each section's 'See discography' link.
Magazine/Stories sidebar tab (artist/story) with 1:1 thumbnails and a
Network|Magazine tab switcher; hide the empty similar-artists block to remove
the 'no similar' over populated-discovery incoherence. Header Share action.
Per-release award ribbons.
Fix custom artist AND album images: decode + apply immediately on upload and
resolve the local override on navigation, so a custom image now shows for
artists/albums with no Qobuz artwork.
Data layer: type last_release as PageArtistRelease, add /artist/story endpoint
+ core/client methods + ArtistStory* models, export ArtistReleases* globals.
cc
Add ImmersiveSuggestionsPanel for the split-right slot (split-panel 2):
a cards row (up to 2 artist-playlist book-collage cards + a Song-Radio
diamond-collage card) and a recommended-tracks list. New suggestions.rs
controller assembles everything in Rust (ADR-006): artist tracks_appears_on
with an artist-tracks fallback under 5, dedupe by title, drop the current
track, shuffle, take 10; cards from the first two playlists plus a track
radio. New core method QbzCore::create_smart_track_radio mirrors the smart
artist radio (spawn_blocking + block_on, seed hoisted to index 0). Does NOT
use reco_store (live Qobuz queries only). Collage tilt deferred (flat for
now); blacklist drop happens downstream in play_tracks.
cc
The cast was slow because the resolver always did a full CMAF download
(~10s for a 190MB hi-res FLAC) and never read the cache — unlike the fast last
Tauri build, which resolved bytes cache-first.
- P1 cache-first: Player::fetch_for_external_stream now checks L1 (in-memory) ->
L2 (on-disk playback cache) before any download. The gapless prefetch already
warms the next 2 tracks at UltraHiRes while casting, so next/advance/replay are
now instant; only a cold track pays the download.
- Offline tier: CoreBridge::fetch_for_external_stream_resolved gains offline +
sink params and tries resolve_offline_bytes (local CMAF decrypt, no network)
before the network path — a downloaded track casts fast with no connection
(the same local-segments+decrypt path offline playback uses). Replaces the
hard "Qobuz cast needs a connection" gate (offline-cached tracks now work).
- P2 quality label: derived from the track's catalog metadata
(bit_depth/sample_rate/hires) in cast_track — no extra API call, consistent
whether bytes came from cache, offline, or network.
cargo check -p qbz-slint: clean.
cc
Add a frontend-agnostic API to resolve a track's bytes + MIME + actually-
delivered quality for an external renderer (Chromecast / DLNA), so the Slint
Cast port does not need an improvised downloader (ADR-006).
- qbz-models: ExternalStreamAsset { bytes, content_type, quality, duration,
origin }, StreamQualityInfo (kHz-normalized via from_raw, handles the
kHz-vs-Hz endpoint ambiguity), AssetOrigin.
- qbz-qobuz/cmaf: download_full_with_quality(_progress) returns the resolved
format_id/sampling_rate/bit_depth alongside the FLAC bytes; download_full is
now a thin wrapper (no duplicated segment assembly).
- qbz-player: Player::fetch_for_external_stream — CMAF-first (FLAC, accurate
quality) with legacy stream-URL fallback; never reads the local playback
cache (casting attempts the best tier and must report the real delivered
quality); never serves an empty content type (external_content_type helper).
- qbz-core: CoreBridge::fetch_for_external_stream_resolved wrapper.
Decision D3 (attempt UltraHiRes first + surface delivered quality) is satisfied
by passing Quality::UltraHiRes and returning StreamQualityInfo. Unit-tested:
content-type fallback by format id, sampling-rate unit normalization.
cc
Tauri's DailyQ/WeeklyQ /dynamic/suggest seeds a track_to_analysed payload
(~9 resolved {track_id,artist_id,genre_id,label_id} tuples) for the PRIMARY
mix algorithm; the empty-analysis call is only its fallback. The Slint port
only ever sent the empty-analysis path. Add a TrackToAnalyse model
(qbz-models) and a get_dynamic_suggest_full method on the qobuz client and
qbz-core that carries the payload; the existing get_dynamic_suggest now
delegates with an empty analysis (behaviour unchanged). Plumbing only — the
mix.rs build of the analysis tuples + the two-call fallback lands next (a2).
cc
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
New local_playlists + local_playlist_tracks schema in the shared per-user
library.db (ids 'local:<uuid>', sources qobuz/local/plex, position-compacted,
8 repo tests in qbz-library). PlaylistRef routing at the navigation seam
makes local ids unrepresentable in Qobuz playlist calls by construction
(all take u64). Local playlists appear in sidebar, manager and picker
(picker hides the Qobuz set while offline); detail view reuses the existing
playlist row machinery and renders offline; creation modal gains the
offline-only toggle (forced on while offline - creating offline always
produces a local playlist, D8) and Edit can flip it later; explicit
Upload-to-Qobuz conversion for non-offline-only locals (confirm dialog,
local rows attach via the existing sidecar, entity kept on failure).
Never-to-Qobuz guard: queues built from an offline-only playlist carry a
core-level stamp cleared by every queue replacement; the QConnect sync
skips the cloud push and play-on-peer falls back to local playback while
the stamp is set.
Deferred (logged in the slice report): plex-row attach on upload, custom
reorder for local playlists, folder/favorite/hide on local items, manager
sort interleaving, offline cover thumbs for cached qobuz rows.
cc
Start offline on the login screen now enters a full offline session instead
of an empty shell: session scaffolding at the last user, local library DB +
mixtape migrations, offline cache + cached-ids warm, per-user pref stores,
tray and media controls, playback poll loop, settings snapshot - mirroring
Tauri's activate_offline_session checklist. Refuses when no previous session
exists (never opens the empty user-0 profile).
New offline_mode glue binds the shared engine + connectivity actor for the
app lifetime and owns the per-user SubscriptionStateStore: mark_valid on
every successful login, mark_invalid only on an explicit IneligibleUser
verdict, binary 30-day-grace playback predicate, and the Tauri-mirrored
purge-at-activation consumer (previously the Slint build never opened the
store, leaving the grace model inert).
Token-survival fix: a network-failed session restore no longer clears the
saved OAuth token; only an explicit auth rejection does. login_with_token
now preserves the typed ApiError (CoreError::Api) so callers can tell the
two classes apart.
cc
Hydration:
- qbz-plex: additive plex_hydrate_album_quality(base, token, rating_keys) — per
key plex_get_track_metadata (batch 5, 5s/call timeout, skip failures) then the
COALESCE write-back plex_cache_update_track_quality; awaited before re-read.
- qbz-player/qbz-core: patch_plex_quality / patch_plex_queue_quality patch the
in-memory queue in place by rating_key (for the now-playing stamp).
- qbz-slint: album-open trigger (gated on DB-NULL, not a value heuristic) then
refresh of all badge surfaces — album-card grid (re-read aggregate + patch
every model + the raw filter source), album-detail header + rows, now-playing
stamp. Re-sync stays routed through plex_cache_save_tracks (carry-over).
Quality badge consistency (the four surfaces must agree — and pre-hydration
show the filetype, not nothing):
- One shared classifier crate::quality::badge(format, bit_depth, sample_rate) ->
(tier, detail, tooltip); the album card, album-detail header and track rows
all route through it (kills the per-surface duplication that produced
"96 kHz" vs "96000 kHz"). sample_rate normalized to kHz via a >=1000 guard so
Qobuz's already-kHz values are never re-divided (no "0.041 kHz").
- New "lossless" tier: an un-hydrated lossless file (FLAC/WAV/ALAC… with unknown
bit depth) shows a generic filetype badge ("FLAC") instead of an empty badge,
rendered in QualityBadge (compact chip) + QualityBadgeFull; upgrades to
"24-bit / 96 kHz" after hydration. Local/Qobuz/mp3 unchanged.
cc
The poll loop's seamless-transition branch guessed whether a track-id change
while still playing was a gapless hand-off via a peek_upcoming(1) == track_id
heuristic. When that heuristic missed (queue pointer lagging the engine), the
meta was never refreshed: the seek bar kept advancing while the title/art/album
stayed on the previous track (the reported now-playing populate bug).
Replace the heuristic with reconciliation to what is ACTUALLY playing. New
frontend-agnostic primitive QueueManager::sync_current_to_id(id) moves the queue
pointer to the track the engine advanced to (recording history + keeping the
shuffle cursor aligned) and reports whether it moved; QbzCore wraps it and emits
a queue update only on a real move. The poll loop now syncs to the live track_id
and always refreshes the card, doing the record/sidebar/prefetch bookkeeping only
on a genuine advance (a manual play already moved the pointer, so it is idempotent).
cc
A Local Library album, or the Tracks list, now plays through with auto-advance
across mixed local/offline sources.
The one change to the shared playback path is additive and provably safe for
Qobuz: play_audible gains a guard at the top — when the CURRENT queue track's
source is "local" (and its id matches), it plays the file from disk via
play_local_file_audible (read -> player.play_data + CUE seek); otherwise it
falls through to the EXISTING offline/Qobuz tier-walk, byte-for-byte unchanged.
Qobuz/offline queue tracks never carry source "local", so their behavior cannot
change. Auto-advance, next/previous and play-all all route through play_audible,
so they become source-aware for free. current_track() releases the queue read
lock before play (no nested lock / deadlock).
- qbz-player: QueueManager::current() (additive).
- qbz-core: QbzCore::current_track() (additive).
- qbz-slint playback.rs: source-aware play_audible + play_local_file_audible
(extracted) + play_local_tracks_now + play_local_album
(get_album_tracks_metadata) + play_local_tracks_from (search_with_filter);
replaces the Stage-1 single-track helper.
- main.rs: album card "play" -> play the album; Tracks row "play" -> play from
that row down the searched list. (play-next / queue still stubbed.)
Built green (cargo check). Local-file gapless isn't wired (local tracks aren't
prefetched, so local/mixed transitions use the simple track-ended -> next path);
Qobuz gapless is unchanged. Runtime smoke-test pending: (1) Qobuz streaming
still plays + advances, (2) an offline album advances, (3) a local album/list
advances.
cc
The shared tier-walk compose methods both frontends call (player untouched):
- play_track_resolved: offline-first (resolve_offline_bytes -> play_data, warms
L1) else Player::play_track (which already does L1/L2 -> CMAF -> legacy).
- fetch_for_gapless_resolved: L1/L2 (player cache) -> offline -> network; the
offline tier is only tried when the track is NOT in the player cache (the
CMAF decrypt is slow, so a cached copy wins — matches the Tauri gapless order).
Both take the frontend's OfflineCacheState by ref (no duplicate DB handle) and
an optional CacheEventSink for the unlock animation. Compiles green.
cc