Commit Graph
34 Commits
Author SHA1 Message Date
vicrodh 133e90f752 fix(offline): hysteresis + cheap probe + streaming liveness override (#467)
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
2026-05-22 21:46:33 -06:00
vicrodh 1aea33cf19 feat(local-library): Plex consolidation in chunked albums page query
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
2026-05-12 15:19:18 -06:00
vicrodh 8f2cd965b4 feat(local-library): server-paginated chunked store for Albums grid
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
2026-05-12 13:25:47 -06:00
vicrodh ca17ad4da0 feat(ephemeral): CUE support, format whitelist, artwork cache, gapless prefetch
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
2026-05-09 05:13:53 -06:00
vicrodh 9c745e595c fix(local-library): use high-positive ids for ephemeral tracks (queue/context u64 compat)
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
2026-05-09 02:36:55 -06:00
vicrodh aa015829aa feat(local-library): ephemeral folder playback (no DB persistence)
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
2026-05-09 02:13:44 -06:00
vicrodh fb825587a4 feat(library): expose v2_library_count_folder_tracks
Tauri wrapper for the qbz-library count helper.

cc
2026-05-08 17:28:02 -06:00
vicrodh 9b5a9ffaf5 feat(library): plumb excludeNetworkFolders through tree commands
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
2026-05-08 17:02:55 -06:00
vicrodh db2e0136d1 feat(library): expose v2_library_list_folder_tracks_recursive
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
2026-05-08 16:17:26 -06:00
vicrodh 24558d2a7d feat(library): expose v2_library_list_folder_children + folder_tracks
Tauri command wrappers for the qbz-library SQL primitives. V2 cleanroom:
both commands call directly into qbz-library, no legacy delegation.

cc
2026-05-08 14:21:07 -06:00
vicrodh 2b8e1a66e1 Merge branch 'feature/internal/local-library-tab-order' into pre-release
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
2026-05-06 17:35:08 -06:00
vicrodh bf02556837 feat(commands_v2): expose metadata-grouped Local Library album queries
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
2026-05-06 17:16:28 -06:00
vicrodh 523cce46fd fix(library): apply per-track artwork fix to second scan loop, suppress legitimate users
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
2026-05-06 16:38:14 -06:00
vicrodh 362f3154ac feat(offline_cache): persist cache size limit across restarts
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
2026-05-06 10:23:18 -06:00
vicrodh 1a138d8e2f chore(reco_store): rename commands.rs to helpers.rs and drop dead commands
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
2026-04-26 22:39:05 -06:00
vicrodh c647cd0373 refactor(v2): inline offline status, settings, and pending playlist delegates
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
2026-04-26 20:30:38 -06:00
vicrodh d98d35796e refactor(v2): inline reco_get_home, reco_get_home_ml, reco_get_home_resolved
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
2026-04-26 20:26:19 -06:00
vicrodh 7858c16b64 refactor(v2): inline library album, track, and playlist folder delegates
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
2026-04-26 20:18:14 -06:00
vicrodh 44ad3392ef refactor(v2): inline library artwork and thumbnails delegates
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
2026-04-26 20:15:52 -06:00
vicrodh 056480a0be refactor(v2): inline library scan progress, stats, and search delegates
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
2026-04-26 20:12:24 -06:00
vicrodh 2af3e2059f refactor(v2): inline library folder management delegates
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
2026-04-26 20:09:09 -06:00
vicrodh 753487582a feat(commands): expose Plex-in-playlist Tauri commands
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
2026-04-21 11:32:22 -06:00
vicrodh 42d432469a feat(cast): v2_{dlna,cast}_play_plex_track for Plex tracks over DLNA/Chromecast
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
2026-04-20 18:33:46 -06:00
vicrodh 76158c81d3 feat(cast): v2_cast_play_local_track for local-library tracks over Chromecast
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
2026-04-20 18:26:58 -06:00
vicrodh 75146d66a9 feat(cast): v2_dlna_play_local_track for local-library tracks over DLNA
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
2026-04-20 18:23:18 -06:00
vicrodh a7e2a8e911 feat(ui): animated padlock on track row while offline CMAF decrypt runs
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
2026-04-17 14:11:44 -06:00
vicrodh f95199d32b fix(library): pass library row id (not Qobuz id) to play_data
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
2026-04-17 12:11:16 -06:00
vicrodh 49d132b6c2 fix(offline-cache): warm L1 memory cache after v2 CMAF decrypt
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
2026-04-17 10:26:13 -06:00
vicrodh 70deae6722 fix(offline-cache): track-id mapping + spawn_blocking decrypt
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
2026-04-17 10:07:49 -06:00
vicrodh 8238ce4de4 fix(offline-cache): route v2 bundles through CMAF in library + gapless
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
2026-04-17 09:49:54 -06:00
vicrodh aa111e15b3 feat(offline-cache): route library playback through CMAF + full purge
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
2026-04-17 08:53:13 -06:00
vicrodh edededcf8c chore(cast): remove AirPlay subsystem (dead code)
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
2026-04-16 19:04:56 -06:00
vicrodh 808b9db0db fix(offline): real offline mode — snapshot streaming, block network, diag logs (#279)
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
2026-04-13 22:13:16 -06:00
vicrodh d8dbcf7bd7 refactor: extract commands_v2/library.rs
cc
2026-04-08 13:15:43 -06:00