Long sessions died when a single transient blip flipped the app to offline and
the queue then skipped every cached-but-not-downloaded track. Harden the
detector:
- Consecutive-failure hysteresis: declare offline only after 2 failed polls,
not a single blip.
- Replace the HEAD https://1.1.1.1 probe with a concurrent race of cheap
generate_204 endpoints, so a saturated link (long Hi-Res tracks) no longer
times the probe out.
- Positive liveness override: if a segment downloaded within the last 45s we
are online by definition, regardless of the probe.
cc
Plex cache lives in a separate SQLite database
(`<data_dir>/qbz/plex_cache.db`), which is why `v2_library_get_albums_page`
previously only saw local_tracks and the LocalLibraryView chunked
path had to fall back to the legacy virtualizer when Plex was on.
That fallback meant Plex-dominant users (which is the common case)
got none of the chunked-store / recycling-pool perf work.
Now consolidated server-side via SQL `ATTACH DATABASE` + `UNION ALL`:
Backend (qbz-library):
- `get_albums_metadata_page` accepts `plex_cache_path: Option<&Path>`.
When the file exists it `ATTACH`es the plex DB to the connection,
runs a CTE that aggregates `plex_cache.plex_cache_tracks` by
`album_key` into the same column shape as local_aggregated, and
UNIONs both before the filter/sort/pagination step. DETACHes at
the end so subsequent queries don't see stale attachment.
Plex group_key prefixed with `'plex:'` so it doesn't collide with
local group keys; `'plex'` literal in the `source` column drives
the frontend's SourceBadge mapping.
- Schema differences smoothed via `CAST` in the plex arm:
`duration_ms / 1000 → total_duration` (matches local seconds),
`sampling_rate_hz → sample_rate` (INTEGER → REAL).
- `count_albums_metadata_for_page` mirrors the same UNION so total
is consistent across empty-page edge cases.
Backend (Tauri command):
- `v2_library_get_albums_page` accepts an `include_plex` bool.
When true, resolves the plex cache path the same way
`plex/mod.rs::open_plex_cache_db` does and passes it through.
Frontend:
- Chunked store's `LocalAlbumsQueryParams` carries an `includePlex`
flag, included in the `paramsEqual` check so toggling Plex
visibility invalidates the chunk cache.
- LocalLibraryView drops the `!isPlexLibraryEnabled()` guard from
both the `$effect` that calls `setParams` and the render
condition that picks chunked vs legacy. The new path serves
Plex-only, local-only, and mixed libraries the same way.
cc
Phase A of the Plex-style port: the Albums grid no longer keeps the
full collection (1200+ rows on the user's library) in reactive Svelte
state. Instead, it pulls a single chunk at a time from a new Tauri
command, with the recycling-grid pool firing `requestIndex` for
indices whose data hasn't arrived yet.
Why this matters: Phase B (recycling pool against an in-memory array)
removed mount/unmount churn during scroll but left two costs in
place — every reactive tick still ran the `$derived` filter/sort
pipeline over all 1200 records, and the records themselves still
sat in Svelte's reactivity graph. Under software compositing the
combined cost was still the dominant scroll-time spike.
Server-side pagination eliminates both: sort + filter run in SQL,
only the visible chunk's records are in memory, and the per-tick
$derived work disappears.
Backend:
- `qbz-library/src/models.rs` — new `AlbumsMetadataPage` struct
(`{ albums, total }`) returned by the paginated query.
- `qbz-library/src/database.rs` — `get_albums_metadata_page`:
reuses the existing track-aggregation CTE from
`get_albums_metadata_grouped`, adds a HAVING-style search filter
over the aggregated title/artist (mirroring the legacy
`matchesAlbumSearchFast` semantics), validated allowlist-based
`ORDER BY` for artist/title/year × asc/desc, and
`LIMIT ?/OFFSET ?` on the filtered set. Total comes from
`COUNT(*) OVER ()` in the same query for one round-trip; a
fallback `count_albums_metadata_for_page` covers the empty-page
case where the window function has no row to attach to.
- `src-tauri/src/commands_v2/library.rs` — new
`v2_library_get_albums_page` Tauri command, registered in
`lib.rs`. `include_qobuz_downloads` is read from
`DownloadSettingsStore` to match the legacy
`v2_library_get_albums_metadata` behavior.
Frontend:
- `src/lib/discovery-v2/localAlbumsChunkedStore.svelte.ts` — new
class-backed store with `version` + `total` as `$state`, sparse
`Map<chunkIdx, LocalAlbumLite[]>`, in-flight `Set<chunkIdx>`
dedup, and a generation counter that drops stale responses if
the user switches filters mid-flight. `getAlbum(globalIdx)` is
sync (null for missing chunks); `requestIndex(globalIdx)` fires
the fetch.
- `src/lib/components/views/LocalLibraryView.svelte` — when the
Albums tab is in grid mode AND grouping is off AND no quality
filters are active (the common case), render through
`AlbumGridPool` bound to the chunked store. Other paths
(grouped, filtered, list mode) keep the legacy
`VirtualizedAlbumList` so we don't lose features we haven't
server-side yet. A `$effect` mirrors search/sort/source state
into `setParams` whenever they change; the store no-ops on
unchanged params.
Plex consolidation (other source listed in the audit) deferred to a
follow-up: `plex_cache.db` is a separate SQLite file, so unifying
needs `ATTACH DATABASE` + `UNION ALL` from the same aggregation,
which is a clean delta once the local-only path is verified.
cc
Backend additions for the ephemeral folder feature, all gated behind the
existing track_id >= EPHEMERAL_ID_FLOOR discriminator so library/Qobuz
paths stay untouched.
CUE handling
- Iterate scan.cue_files alongside audio_files. Parse via CueParser,
resolve + canonicalize the referenced audio file, extract properties +
format via MetadataExtractor, expand into virtual tracks via
cue_to_tracks. The referenced audio file is added to a HashSet so the
audio_files loop skips it (otherwise we'd list both N CUE-derived
tracks and a single-row entry for the underlying FLAC).
- Whitelist of decoder-friendly extensions (flac/mp3/m4a/alac/wav/aiff/aif).
Symphonia can't decode APE or raw BIN — those used to ride through
silently and produce white noise on play; now they're skipped with a
warn log and counted in skipped_files.
- v2_library_play_track ephemeral branch now honours track.cue_start_secs
with a post-play_data seek, mirroring the regular local-file branch.
Artwork perf
- Cache extracted artwork by album_group_key. Most CUE-album folders
share one cover across every virtual track, so calling extract_artwork
+ generate_thumbnail 155 times for a 155-track album was wasted work
(the bottleneck behind the prior ~2-minute opens). The folder-level
cache is kept as a secondary fallback for find_folder_artwork lookups.
- Skip standalone APE files entirely from the ephemeral pane — Symphonia
can't decode them so showing them as a row that explodes on click is
worse than not showing them at all. Counted in skipped_files.
Gapless
- v2_play_next_gapless gets an ephemeral branch at the top: track ids
in the high range resolve through EphemeralLibraryState and feed the
player.play_next path directly. Without this, gapless prefetch always
reported "not in any cache" because the lookup keyed off DB rows.
- CUE-derived ephemeral tracks (cue_start_secs is Some) return false
from the gapless branch — they share an audio file, so a play_next
swap with no seek would start the next virtual track from the FILE's
beginning instead of the CUE offset.
cc
The original design used negative i64 ids to distinguish ephemeral
tracks from DB rows, but the queue and playback-context commands
serialize ids as Vec<u64> end-to-end (V2QueueTrack, v2_set_playback_context).
serde rejects negative numbers at the boundary, so setQueueForLocalTracks
and setPlaybackContext failed the moment the user clicked an ephemeral
track:
invalid value: integer `-1`, expected u64
Switch synthetic ids to a high-positive range (EPHEMERAL_ID_FLOOR =
1<<48) instead. Pros over the negative scheme:
* Valid u64 — passes through the queue/context commands untouched
* Below 2^53 so JS Number round-trips without precision loss
* Far above any realistic DB autoincrement id, so the discrimination
test (`track_id >= EPHEMERAL_ID_FLOOR`) stays unambiguous
Also fixes the snake_case mismatch on EphemeralFolderState — the Rust
struct serializes with its native field names (folder_path,
skipped_files), but the TypeScript type was declared in camelCase, so
ephemeralFolder.folderPath was undefined at render time.
cc
Adds an "Open Folder" button in the LocalLibrary > Folders tree toolbar
that lets the user point QBZ at any folder on disk and play it without
adding anything to local_tracks. Lets the app act as an ad-hoc media
player for downloaded albums, external drives, etc.
Backend:
- New ephemeral_library module: in-memory HashMap<i64, LocalTrack> keyed
by synthetic negative ids. Reuses LibraryScanner and MetadataExtractor
to walk the folder and build LocalTrack records.
- Three V2 commands: v2_ephemeral_open_folder (scan + populate, replaces
any prior session), v2_ephemeral_clear, v2_ephemeral_get_track.
- v2_library_play_track gets a small branch at the top: track_id < 0
resolves through the ephemeral state and goes straight to the regular
local-file playback path (read file, hand to player). DB-backed paths
(Qobuz cache, CUE start positions) are skipped — ephemeral tracks
can't be any of those.
Frontend:
- "Open Folder" button (FolderOpen icon) next to the collapse-all button
in the tree toolbar. Click opens the OS file picker, the result lands
in a new ephemeralFolder $state.
- Right pane gets a new branch above the existing album/folder views:
when ephemeralFolder is set, render a dedicated pane with header
(folder name, path, track count, Play All, Close) and a flat track
list with play-on-click rows.
- Lifecycle per spec: a session survives until (1) a different album or
tree folder is selected — handled via $effect that watches
selectedAlbum and selectedFolderPath, (2) the user clicks the Close
button, or (3) the backend reports the file/directory is gone (caught
in the play handler and folded into the same close path).
- Translations for en, es, de, fr, pt.
Notes:
- The audio_cache key is `track_id as u64`, which for negative ids wraps
to a large u64 value — unique and safe (DB ids are positive).
- Edit-metadata gating for ephemeral tracks is deferred: there is no
edit trigger from the new pane yet, so the sidecar/direct toggle
behaviour is unchanged. Will gate when an edit path lands.
cc
v2_library_list_folder_children, v2_library_list_folder_tracks,
v2_library_list_folder_tracks_recursive accept and forward the
exclude-network-folders flag.
cc
Tauri wrapper for the qbz-library primitive added in the previous
commit. V2 cleanroom: direct call into qbz-library, no legacy
delegation. Registered in lib.rs alongside the existing folder-tree
commands.
cc
Includes Phase 2 (rename Albums to Folders + per-user tab order
preferences) and Phase 3 (new metadata-grouped Albums view + V2
commands + frontend wiring) of the Local Library design.
cc
v2_library_get_albums_metadata and v2_library_get_album_tracks_metadata
mirror the existing folder-grouped commands but call the new
get_albums_metadata_grouped and get_album_tracks_metadata. Frontend
wiring lands in the next commit.
cc
A code review of the previous commit surfaced a second scan loop in
legacy_compat.rs that had the same destructive update_album_group_artwork
pattern. Apply the same fix: per-folder cache + drop the destructive
call. Same intent and shape as the main loop fix.
The deprecated marker also surfaced three callers that are NOT bugs
and stay as-is, with #[allow(deprecated)] + a comment to make the
intent explicit:
- CUE ingestion: a CUE sheet is one physical album, so a single cover
for all CUE tracks is semantically correct.
- v2_library_set_album_artwork: user-initiated set-cover-for-album, by
design propagates to all tracks.
- Discogs backfill: only fires for albums with no existing artwork, so
there is nothing per-track to destroy.
cc
Adds cache_limit_bytes column to offline_settings (idempotent ALTER
TABLE) plus get/set methods on the OfflineState store. v2_set_offline_cache_limit
now writes the value to disk in addition to the in-memory cache_state.
At session activation, OfflineCacheState::apply_persisted_limit reads
the stored value and seeds the in-memory limit so the user's chosen cap
survives app restarts. Legacy installs with NULL persisted value keep
the in-memory 5 GB default seeded by init_at.
cc
The reco_store/commands.rs module no longer hosts any registered Tauri
commands. Its surviving content is purely helper logic consumed by V2
home recommendations in commands_v2/library.rs.
- Rename reco_store/commands.rs -> reco_store/helpers.rs
- Drop the unregistered/uncalled #[tauri::command] entries
(reco_log_event, reco_train_scores, get_playlist_suggestions,
reco_backfill_genres, reco_needs_genre_backfill) and their dead
scoring helpers (build_scores, build_track_entries, decay_factor,
current_timestamp, etc.) along with RecoTrainStats / RecoScoreCounts
/ BackfillStats structs that only those commands referenced.
- Update commands_v2/library.rs to import from
crate::reco_store::helpers::* (still uses get_home_seeds_internal,
resolve_albums, resolve_tracks, resolve_artists, album_to_card_meta).
cc
Inlines bodies of get_offline_status, get_offline_settings,
set_manual_offline, check_network, and get_pending_playlists from
offline::commands directly into the v2 wrappers in commands_v2/library.rs.
Deletes the entire offline::commands submodule (the inlined commands and
the additional dead Tauri command shims that lived alongside them — none
were registered with Tauri or referenced outside the submodule). Prunes
the now-unused tauri AppHandle/Emitter imports from offline/mod.rs.
Step 8 of legacy cleanup, group H.
cc
Inlines bodies of reco_get_home, reco_get_home_ml, and reco_get_home_resolved
from reco_store/commands.rs directly into the v2 wrappers in
commands_v2/library.rs. Promotes their shared helpers
(get_home_seeds_internal, resolve_tracks, resolve_artists,
album_to_card_meta) from private to pub(crate) so the v2 wrappers can
call them without crossing back into a legacy command module.
Deletes the corresponding legacy #[tauri::command] definitions and
prunes now-unused imports from reco_store/commands.rs.
Step 8 of legacy cleanup, group G.
cc
Inlines bodies of library_get_album_tracks, library_get_tracks_by_ids,
and update_playlist_folder from library/commands.rs directly into the
v2 wrappers in commands_v2/library.rs.
Deletes the corresponding legacy #[tauri::command] definitions and
removes their re-exports from library/mod.rs.
Step 8 of legacy cleanup, group E.
cc
Inlines bodies of library_fetch_missing_artwork, library_get_thumbnail,
and library_get_thumbnails_cache_size from library/commands.rs directly
into the v2 wrappers in commands_v2/library.rs.
Deletes the corresponding legacy #[tauri::command] definitions and the
unused legacy library_clear_thumbnails command. Removes their re-exports
from library/mod.rs.
Step 8 of legacy cleanup, group D.
cc
Inlines bodies of library_get_scan_progress, library_get_artists,
library_search, and library_get_stats from library/commands.rs directly
into the v2 wrappers in commands_v2/library.rs.
Deletes the corresponding legacy #[tauri::command] definitions and
removes their re-exports from library/mod.rs.
Step 8 of legacy cleanup, group C.
cc
Inlines bodies of library_add_folder, library_cleanup_missing_files,
library_get_folders, and library_get_folders_with_metadata from
library/commands.rs directly into the v2 wrappers in commands_v2/library.rs.
Deletes the corresponding legacy #[tauri::command] definitions and
removes their re-exports from library/mod.rs.
Step 8 of legacy cleanup, group B.
cc
Wires the qbz-library methods added in the previous commit to Tauri
invoke handlers:
* v2_playlist_add_plex_track(playlistId, ratingKey, position)
* v2_playlist_remove_plex_track(playlistId, ratingKey)
* v2_playlist_get_plex_tracks_with_position(playlistId)
All three lock LibraryState::db, mirror the existing local-track
commands, and go nowhere near the Qobuz API surface — no chance of a
Plex ratingKey being forwarded to /playlist/addTracks by mistake.
Registered in lib.rs invoke_handler next to v2_playlist_add_local_track.
cc
Plex has the same cast routing problem as local library: the Qobuz-only
play_track commands can't resolve Plex ratingKeys. Add dedicated commands
that fetch bytes through plex_resolve_track_media (existing), proxy them
through the cast media server, and load the URL on the renderer.
Proxying through the media server (rather than giving the renderer a
direct Plex URL) keeps the user's Plex token in-process. Renderers would
need the token to fetch from Plex directly and that's not a surface we
want to expose.
Content-type fallback to audio/mpeg when Plex omits it (transcode path),
with a plex_key_to_u64 namespaced hash (high bit set) so Plex ratingKeys
can't collide with Qobuz/library ids in the MediaServer keyspace.
cc
Companion to v2_dlna_play_local_track. Same bug, same fix applied to the
Chromecast protocol branch: reads library row + file, infers content_type,
registers bytes with the shared media server, loads the URL on the
Chromecast receiver. Reuses the local_track_content_type helper.
User flagged this on the #332 thread — same Qobuz-only assumption in the
Chromecast play command as in the DLNA one.
cc
Mirrors v2_dlna_play_track but for library row ids. Reads the file from
disk, infers content_type from AudioFormat (with extension fallback for
Unknown), registers bytes with the DLNA media server, sends AVTransport
URI + Play to the renderer.
Without this command the cast path had no way to send local audio to a
DLNA device — v2_dlna_play_track resolves the id as a Qobuz track and
fails (404 in offline sessions, wrong-song match in online sessions where
the library row id happens to collide with a real Qobuz track id). Issue
#332 reported the symptom as "DLNA not operational"; logs showed the
renderer connected successfully but never received a SOAP SetAVTransportURI.
Frontend wiring in the cast dispatch + playbackService guard removal lands
in follow-up commits.
cc
User feedback from live testing: even with gapless working correctly,
the cold first-play of an offline CMAF bundle takes ~500ms to decrypt
on AES-NI hardware, 5-6s on the software fallback. No visible
indicator of what was happening — UX felt like the app was stalling.
Solution: when a track is being decrypted, replace its play glyph in
the track row with an animated padlock (lucide-svelte Lock icon +
CSS keyframe shake/scale). Tells the user "we're unlocking your
content", which is literally what's happening.
Implementation:
- Backend: new helper `load_cmaf_bundle_with_ui_events` wraps the
spawn_blocking decrypt with two Tauri events:
- `offline:unlock_start { trackId }`
- `offline:unlock_end { trackId, success }`
The helper takes two ids: `display_track_id` (what the frontend
knows this track as — Qobuz id or library row id depending on
flow) and `cmaf_track_id` (the Qobuz id used to key the bundle
inside load_cmaf_bundle itself). Same track shows the animation
whether clicked from Qobuz view or Local Library.
- All three v2 decrypt call sites now route through the helper:
v2_play_track offline branch, v2_play_next_gapless (both the
Qobuz-id and Library-qobuz_download branches), and
v2_library_play_track.
- Frontend: new store `unlockingStore` maintains a `Set<trackId>`
updated from the events, with subscribe/isUnlocking API. Started
from +page.svelte alongside the existing player/queue pollers.
- TrackRow.svelte: new `isUnlocking` reactive state derived from
the store, a new branch in the play-number template that renders
the Lock icon with `lock-shake` animation, scoped CSS keyframes
(qbz-unlocking — slight rotate + scale + opacity pulse at 1.2s
loop, accent-primary colour, cursor: progress).
The animation fires on cold decrypt only; warm L1/L2 hits bypass the
decrypt entirely and don't emit the events, so the user never sees
the padlock for a cached track — correct behavior.
cc
Live testing from Local Library with a v2 CMAF-cached Qobuz track
showed a cascade of UI breakage: seekbar never appeared, track never
auto-advanced, queue-manager fired next_track every 1s for 30+
iterations in a loop, currently-playing song card stayed on the first
track. Audio was playing fine; the UI just couldn't see it.
Root cause: my v2_library_play_track CMAF path was passing the Qobuz
track id (from the library row's qobuz_track_id field) to
player.play_data. Every other piece of the UI state machine — queue
sync, seekbar progress events, auto-advance trigger, now-playing card
— keys off whatever id the frontend invoked with, which is the
library row id (e.g. 542), not the Qobuz id (e.g. 95787326). So the
player was emitting updates about "playing 95787326" while the
frontend was waiting for updates about 542 that never came.
Fix: play_data now takes track_id (library row id) on both the v2
CMAF decrypt branch and the v1 plain-FLAC branch. The offline-cache
DB lookup still uses qobuz_track_id (that DB is keyed by Qobuz id),
the player gets the library id. AudioCache insert also keyed by
library id so Library-side replay hits L1 correctly.
cc
Live testing showed v2 offline tracks had a 3-6s gap on gapless
transitions even after the track_id + spawn_blocking fixes, because
the decrypt itself takes 5-7 seconds for a typical 60-200MB FLAC
(disk read of encrypted segments.bin + AES-CTR per-frame decrypt is
slower than the track's natural tail). By the time load_cmaf_bundle
returns, the previous track has ended and the player's gapless engine
has been dropped.
Workable only second time: once a track's decrypted bytes are in L1
memory cache, subsequent accesses are ~60ms. That's what happened on
the second transition we observed — prefetched tracks hit L1 for
free, so gapless landed in time.
Fix: after successfully decrypting a v2 bundle, insert the resulting
plain-FLAC bytes into the AudioCache (L1) before handing them to the
player. Applies to three call sites:
- v2_play_track offline cache branch → warm on first play
- v2_play_next_gapless CMAF v2 branch → warm on gapless success
- v2_library_play_track CMAF v2 branch → warm on Library click
Next access to the same track (replay, scrub, gapless re-queue,
upcoming-track peek) is now a <100ms L1 hit regardless of the v2
cache_format the row has. The decrypt cost is paid once per track
per session, not once per access.
This still leaves the "first play of an offline track" taking 5-7s
before audio starts. That's a separate optimization — candidates are
(a) offline-track prefetch that pre-decrypts upcoming tracks in the
background, (b) storing decrypted FLAC in L2 disk cache so cold
restarts don't have to decrypt, (c) speeding up the decrypt itself.
cc
Two bugs the live testing surfaced.
1. v2_library_play_track was passing the library row id (autoincrement
small integer) to the offline-cache DB lookup. The offline cache is
keyed by the Qobuz track id (the big id like 95787327). So the
lookup always missed and my earlier "orphan" error fired for EVERY
v2 track clicked from Local Library. Fix: read qobuz_track_id from
the library row and use THAT for the offline-cache lookup.
2. load_cmaf_bundle is synchronous (std::fs::read + AES-CTR decrypt +
FLAC assemble). When called directly from an async fn on the tokio
executor, a 200MB offline track took ~22 seconds end-to-end during
concurrent prefetch activity — because the sync work starved the
executor thread, delaying the queue's auto-advance event, which
fired play_track as a fallback AFTER the old track stopped (which
killed the gapless engine). Net: 6-8 second audible gap between
two offline-cached tracks even though the bytes were on disk.
Fix: wrap load_cmaf_bundle in tokio::task::spawn_blocking in all
three call sites (v2_play_track, v2_play_next_gapless,
v2_library_play_track). The executor stays responsive, gapless
finishes before the engine drops, transition is clean.
Together these close the two loudest symptoms from the live session:
"Is a directory" when playing v2 from Library, and the seconds-long
gap between consecutive v2 tracks even with gapless enabled.
cc
Two symptoms with the same root cause — code paths that touched the
offline cache read file_path as "a plain FLAC file" and handed it
straight to std::fs::read + Symphonia. For v2 bundles the file_path is
the encrypted segments.bin, so both paths exploded.
1. v2_library_play_track: error "Is a directory (os error 21)" when
clicking a v2-cached track from Local Library.
Fix: for source='qobuz_download' rows, the offline-cache DB is the
authoritative source of file paths, not the library index (library
carries display metadata only). Query get_cmaf_bundle:
- cache_format=2 → load_cmaf_bundle (decrypt + play)
- cache_format=1 → read row.segments_path (which for v1 IS the FLAC)
- no row → clean error instead of reading the library directory
2. v2_play_next_gapless: "Symphonia probe failed: end of stream" with
~6 failed retries per track transition → ~6s gap between two v2
offline-cached tracks.
Fix: same cache_format branching as v2_play_track already does.
Reuse load_cmaf_bundle so the probe sees plain FLAC bytes, not
encrypted mp4 boxes.
The prefetch AES-CBC unpad error during concurrent downloads is the
other piece of today's gap story — session race in ensure_cmaf_session.
That's a separate fix in the next commit.
cc
Two loose ends after v2 landed:
1. Local Library integration. Qobuz-cached offline tracks already show
up in the Local Library view (via insert_qobuz_cached_track_with_
grouping with source='qobuz_download'). v2_library_play_track was
reading track.file_path and doing std::fs::read() — that works for
plain-FLAC v1 entries, but for v2 the path is the bundle directory,
not a playable file. Fixed:
- Extract the CMAF-load-and-decrypt logic into a shared module
(offline_cache::playback::load_cmaf_bundle) used by both
v2_play_track and v2_library_play_track.
- v2_library_play_track checks source='qobuz_download' +
cache_format=2 on the row and routes through the shared helper.
Legacy v1 entries and regular user local files unchanged.
2. purge_all_cached_files. Previously deleted individual file_path
entries only. For v2 the file_path points inside a track dir
(segments.bin) — removing just that file orphans init.mp4 and
manifest.json. Fixed:
- When the file_path looks like tracks-cmaf/<id>/segments.bin
(matched by the path shape, not a schema flag), remove the whole
enclosing <id>/ directory.
- Belt-and-suspenders: also delete the top-level tracks-cmaf/ dir
after the per-row pass, so orphaned bundles from corrupted rows
are swept up too.
Net: Local Library view preserves the "cached Qobuz tracks appear
here" UX, AND the 30-day grace wipe still removes every encrypted
segment + every library row in one atomic operation. No contradiction
between the two behaviors.
Also extends the compliance whitepaper with a section explaining the
Local Library integration so Franck's team can see that the wipe
contract isn't worked around by the UX shortcut.
cc
AirPlay was scaffolded in both crates/qbz-cast/src/airplay and
src-tauri/src/cast/airplay but never worked at runtime: the RAOP
sender required an Apple MFi-licensed stack with no free/open
alternative, and the backend state was already disabled via the
commented `.manage(airplay_state)` line in src-tauri/src/lib.rs.
The CastPicker UI already hid the AirPlay tab pending streaming
support that was never going to ship.
Delete:
- crates/qbz-cast/src/airplay/ (entire directory)
- src-tauri/src/cast/airplay/ (entire directory)
- 10x v2_airplay_* commands from commands_v2/library.rs + their
tauri::generate_handler entries
- AirPlayError from qbz-cast errors
- 'airplay' from CastProtocol + every case arm in castStore.ts,
CastPicker.svelte, AudioOutputBadges.svelte
- Corresponding i18n keys if present
Zero behavior change for Chromecast / DLNA / QConnect / MPRIS. If
AirPlay ever becomes feasible we'll revisit from scratch with a
real streaming backend.
cc
Addresses the reported behavior where tracks marked available offline
still buffered from Qobuz after the v1.2.2 upgrade. Three layers:
1. **Snapshot / restore stream-first-track.** offline_settings gains a
new nullable column pre_offline_stream_first_track (SQLite ALTER
migration). v2_set_manual_offline now coordinates with
AudioSettingsState: entering offline mode stashes the user's current
stream_first_track preference and forces it to false; exiting
restores the snapshot. User's preference survives the round-trip
even across app restarts.
2. **Explicit offline refusal.** v2_play_track now takes
OfflineState, reads manual_offline_mode up front, and — when every
local cache miss has been exhausted — returns a new
RuntimeError::TrackNotAvailableOffline instead of silently reaching
for the network. Low-quality offline hits still play (better than
nothing). The playback path also masks stream_first_enabled while
offline as a belt-and-suspenders defense.
3. **Cache-miss visibility.** Every call site that consults the
offline cache DB now logs [V2/CACHE STALE] at warn level when the
DB says a track is cached but the filesystem path is missing. This
made the issue #279 behavior invisible; the orphan entries from
per-user isolation migration (aab3e6764) are now diagnosable from
logs.
Frontend:
- playbackService surfaces TrackNotAvailableOffline as a toast ('…
isn't saved for offline playback') instead of the generic error
- SettingsView listens for a new audio-settings-changed event so the
stream-first-track toggle in the UI flips when the offline toggle
mutates it
- i18n key offline.trackNotAvailableOffline added to all 5 locales
cc