Commit Graph
354 Commits
Author SHA1 Message Date
vicrodh e4fa90c1b2 feat(prefetch): adaptive throttle based on observed bandwidth + underrun signal
Recovered from 63ecc324 (authored on the native-port branch, never merged to
pre-release). Backend-only, no Slint. Scales the prefetch cap down on slow
links and during audio underruns, and exposes the per-segment download signal
the offline detector reuses as a positive liveness override (#467).

cc
2026-05-22 21:46:07 -06:00
vicrodh 3477edfb76 refactor: share the image cache and credential store via crates
Extracts two self-contained, framework-agnostic modules out of
src-tauri so they can be reused by other clients without duplicating
logic:

- ImageCacheService / ImageCacheStats move into the qbz-cache crate
  (LRU disk cache, MD5-keyed, SQLite index).
- The credential store (keyring + AES-256-GCM encrypted file for Qobuz
  credentials and the OAuth token) moves into a new qbz-credentials
  crate.

src-tauri/image_cache.rs and src-tauri/credentials/mod.rs become thin
re-exports, keeping their Tauri managed-state wrappers. The Tauri app
is behavior-identical; both modules were already self-contained (no
Tauri and no crate:: references).

cc
2026-05-18 14:11:05 -06:00
vicrodh 7ef6300a22 refactor(qbz-app): add minimal session activation to AppRuntime
Task 2 of the Slint POC readiness audit. AppRuntime now performs
framework-agnostic per-user session activation: activate, activate_at,
activate_offline, deactivate, plus is_session_active, active_user_id,
and a with_session_store accessor.

This is the minimal version per the 2026-05-18 audit correction. It
opens only the session store and does the portable scaffolding (user
paths, directories, last_user_id marker, runtime state machine). It
does not touch Tauri's session_lifecycle, does not initialize the
src-tauri-side per-user stores, and does not run the flat-to-user
migration. The shipping app keeps identical behavior; this is additive.

activate_at takes explicit directories and is the testable core;
activate resolves real per-user paths through UserDataPaths.

cc
2026-05-18 09:26:22 -06:00
vicrodh a213b37807 refactor(qbz-app): add AppRuntime shell facade skeleton
Adds a framework-agnostic composition root for non-Tauri UI shells
(Slint, TUI, headless) as Task 1 of the Slint POC readiness audit
(session-20-slint-poc-readiness-audit).

AppRuntime<A> owns an Arc<QbzCore<A>> plus the runtime state machine,
built with the same recipe as the Tauri CoreBridge but with no Tauri
dependency. Scope is composition and accessors only; per-user store
ownership and session activation are deferred to Task 2.

qbz-app now depends on qbz-core, qbz-player, qbz-audio, and qbz-models.
The Tauri app does not consume this module; it is purely additive.

cc
2026-05-18 07:28:19 -06:00
vicrodh f79c550889 refactor(qbz-app): add playback context
cx
2026-05-17 19:39:14 -06:00
vicrodh 4ba96fe59a refactor(qbz-app): add session store foundation
cx
2026-05-17 19:16:13 -06:00
vicrodh bfcd769055 fix(cast): heartbeat the Chromecast every 5s to keep the connection alive
Google Cast closes a control connection that goes roughly 10 seconds without a heartbeat PING. QBZ pinged every 25 seconds, so any cast connection left idle between connect and load — for example while the track downloads, or across a delayed second play attempt — was dropped by the receiver. The next LOAD then failed with EPIPE (broken pipe): the device chimed on connect but never played audio, and the heartbeat thread logged repeating broken-pipe warnings.

Pinging every 5s is the cadence the Cast protocol expects and keeps the connection alive through those idle gaps.

Fixes #439.

cc
2026-05-16 14:38:29 -06:00
vicrodh 29b766ae24 fix(graphics): recommend CPU for NVIDIA-only, allow DMA-BUF on hybrid
NVIDIA-only systems no longer get a hardware-acceleration recommendation that visibly stutters under WebKitGTK. The three NVIDIA-only branches (Wayland+GNOME, Wayland, X11) collapse into one CPU-rendering profile, since XWayland does not rescue the stutter either.

NVIDIA + iGPU hybrid now allows DMA-BUF: WebKit composites on the stable iGPU stack, so the instability that affects NVIDIA-only systems does not apply. The recommendation also tells the user to pick the iGPU explicitly in the rendering GPU selector instead of Auto.

cc
2026-05-16 12:22:41 -06:00
vicrodh 1a5dbe07bb refactor(qbz-app): extract boot watchdog decision
cx
2026-05-16 09:04:34 -06:00
vicrodh 3c963cdaf3 refactor(qbz-app): extract graphics autoconfig
cx
2026-05-16 08:50:06 -06:00
vicrodh 2fc9270c89 refactor(qbz-app): add graphics detection
cx
2026-05-16 00:44:16 -06:00
vicrodh a497d61b75 refactor(qbz-app): add user data paths
cx
2026-05-16 00:20:21 -06:00
vicrodh be500956ff refactor(qbz-app): add developer settings store
cx
2026-05-15 23:25:06 -06:00
vicrodh f85df353ca refactor(qbz-audio): align audio settings store
cx
2026-05-15 19:42:17 -06:00
vicrodh bcdbc69913 refactor(qbz-app): add graphics settings store
cx
2026-05-15 18:52:15 -06:00
vicrodh f8f8bb88d6 refactor(qbz-app): add image cache settings store
cx
2026-05-15 18:18:36 -06:00
vicrodh 26012e98c9 refactor(qbz-app): add download settings store
cx
2026-05-15 17:48:14 -06:00
vicrodh 2e43831800 refactor(qbz-app): add remote control settings store
cx
2026-05-15 14:17:35 -06:00
vicrodh ebe03193c2 Merge refactor/qbz-app-ui-preferences-foundation: qbz-app UI preference stores
cx
2026-05-15 00:41:12 -06:00
vicrodh 3903086f36 fix(release-watch): drop upcoming and non-streamable albums
`/favorite/getNewReleases` includes albums whose stream availability is
still in the future, so they appear in the Release Watch grid even
though `get_album` 404s as soon as the user clicks them. Same shape for
regional restrictions and label takedowns surfaced via `streamable=false`.

Add `release_date_stream` and `streamable` to the Album struct (both
optional with serde defaults so existing call sites stay happy) and
filter the response inside `v2_get_release_watch` after the artist
blacklist pass: skip any album marked `streamable=false` or whose
`release_date_stream` is strictly greater than today (UTC). The total
count gets adjusted alongside so pagination math stays consistent.

cc
2026-05-15 00:35:38 -06:00
vicrodh 04d5b45134 docs(qbz-app): keep app comments frontend agnostic
cx
2026-05-15 00:12:22 -06:00
vicrodh d1082317f5 refactor(qbz-app): add ui preference stores
cx
2026-05-15 00:04:36 -06:00
vicrodh a40b1b6c48 fix(core): make QbzCore::init tolerant to offline startup
Bundle token extraction requires the network. When the host is fully
offline at startup, the failure used to propagate out of CoreBridge::new
and leave the bridge as None, which made every queue/playback V2 command
panic at `bridge.get().expect("CoreBridge not initialized")`.

QbzCore::init() now treats bundle extraction as best-effort: on failure
the core still finishes initialization, the queue manager and player stay
usable for local/offline playback, and API methods return
CoreError::NotInitialized cleanly instead of crashing the runtime. Adds
is_api_initialized and try_init_api so callers can rebuild the API
client lazily once the network returns.

cc
2026-05-14 23:24:33 -06:00
vicrodh 61d5715a22 refactor(qbz-app): add preference stores
cx
2026-05-14 19:48:54 -06:00
vicrodh 800447d33c refactor(qbz-app): add settings stores
cx
2026-05-14 17:08:45 -06:00
vicrodh dc74db6401 test(qbz-app): cover runtime state transitions 2026-05-14 11:54:16 -06:00
vicrodh d16ef003a5 refactor(qbz-app): move runtime core 2026-05-14 11:45:59 -06:00
vicrodh 4f008cd219 refactor(qbz-app): scaffold app crate 2026-05-14 11:40:19 -06:00
vicrodh 921f8e1a49 fix(session): resume Qobuz streams at saved position without losing seek (#315)
Three bugs were stacking up to make session resume drop the saved offset
for streamed Qobuz tracks:

1. Position save was debounced, not throttled. Each playback tick reset
   the 5s timer, so the save never fired while a track played
   continuously — the persisted position froze at whatever value got
   written during the first quiet window after track start (typically
   ~2s). Switch to throttle: fire the first call immediately, then once
   per 5s window, with a tail call to flush the latest value when the
   window ends.

2. togglePlay tried to apply the restore offset via a separate cmdSeek
   right after cmdPlayTrack. For Qobuz streaming the audio thread's
   Seek handler bails when current_audio_data is unset — streaming
   uses current_streaming_source instead — so the seek was silently
   dropped and the track started at 0:00. Pass start_position_secs
   directly to v2_play_track. The streaming backend (legacy + V2
   player) waits for enough buffer to cover the offset plus 8s
   headroom, then eagerly decode-and-discards decoder output up to
   the offset before engaging the audio engine — no race, no
   first-pull underrun. Plex and local tracks keep the old play+seek
   path; they load full audio_data so Seek works as-is.

3. The seekbar showed 0:00 from app launch until the user hit play,
   even when a saved position was queued. setPendingSessionRestore
   now pre-positions currentTime when positionSecs > 0 so the UI
   reflects "paused at X" the moment the app opens.

Other plumbing:
- AudioCommand::PlayStreaming carries start_position_secs and
  content_length (used to estimate bytes-per-second for the resume
  buffer target).
- Player::play_streaming{,_dynamic} grow a start_position_secs
  parameter; QConnect and api_server callers pass 0 (remote-driven
  playback always starts fresh).
- cmdPlayTrack forwards startPositionSecs as a 5th optional arg.

cc
2026-05-14 02:18:59 -06:00
vicrodh b5918e1cbb fix(library): recover Plex album format + bit-depth when codec is missing
The Plex aggregation CTE used MAX(codec)/MAX(bit_depth) directly,
which produced two bugs that surfaced in the Albums grid for many
Plex-only libraries:

  - format = "Unknown". Plex frequently omits the stream-level codec
    when its analyzer hasn't seen the file yet. MAX(NULL) collapsed
    the row's format to NULL, which the frontend parser mapped to
    AudioFormat::Unknown — the Library grid then showed UNKNOWN even
    though the file's container was clearly "flac" / "mp3".
  - bit_depth = NULL. Plex commonly omits bitDepth on older releases
    entirely; the album row inherited the gap and the UI rendered
    "--/44.1" next to an otherwise valid quality string.

Two fallbacks applied to the plex_aggregated CTE only — the local
CTE is untouched because local indexing always writes a non-null
format string and a real bit depth via Symphonia:

  - format: COALESCE(MAX(codec), MAX(container)). The `container`
    column is populated for the same media and typically carries the
    same value ("flac", "mp3"), so it's a safe fallback when codec
    is fully NULL across the album.
  - bit_depth: COALESCE(MAX(bit_depth), CASE WHEN <lossless> AND
    sample_rate <= 48000 THEN 16 ELSE NULL END). Lossless formats
    at CD-range rates are virtually always 16-bit redbook, so we
    default to 16 in that narrow window. Higher rates stay NULL so
    we don't lie about 24 vs 32. The per-track view shows the real
    value when the user clicks in (lazy hydration via
    plex_get_track_metadata).

cc
2026-05-13 22:22:57 -06:00
vicrodh 6f05fd3069 fix(qobuz): chunk get_tracks_batch into 50-ID windows (#419)
The /track/getList endpoint caps at 50 IDs per call. Sending more in a
single POST either errors out or silently truncates, both of which take
the entire call site down.

For QConnect this surfaced as: pushing a >50-track playlist from the
Qobuz Android app to QBZ caused the materialization step (corebridge
get_tracks_batch hydration) to return an error, abort, and leave only
the 1-track placeholder queue from the earlier align-cursor pass. The
visible symptom was "only the first track loads on Connect playlists
above 50 tracks".

The fix is at the helper level rather than in QConnect: split inputs
larger than 50 into 50-ID windows fetched serially, preserve input
order in the returned Vec. Single-window path is unchanged for the
common case. Same return shape, same error type, so the four call
sites (corebridge.materialize_remote_queue, qbzd qconnect, V2
v2_get_tracks_batch, qbzd catalog) all benefit transparently without
edits to QConnect orchestration code (auto-advance, deletion timing,
queue ordering are untouched).

Serial loop instead of the concurrent fan-out used by get_playlist —
lower risk for the first fix; if QConnect playlists of 200+ tracks
start feeling slow we can revisit with try_join_all.

cc
2026-05-13 11:37:33 -06:00
Afonso Jorge Ramos 5865c16624 docs(player): explain set_stream_error(false) message swallow
Clearing the error flag also drops any pending stream_error_message.
A fast record_stream_error -> set_stream_error(false) -> polling-tick
sequence will silently swallow the toast — that is the intent (a
recovered error is not user-actionable), but it surprises future
readers, so document it inline.
2026-05-13 18:07:54 +01:00
Victor RH 0daca399cd Merge pull request #420 from afonsojramos/refactor/internal/macos-shared-mode-followup
refactor(audio): macOS shared-mode polish + review follow-ups from #410
2026-05-13 10:56:38 -06:00
Afonso Jorge Ramos 04e17cc449 feat(player): expose user-readable stream_error_message channel
Pairs the existing stream_error AtomicBool with an Option<String> that
the audio thread can populate when a stream init failure has a
meaningful cause to surface to the user (currently: the macOS
CoreAudio rate-mismatch path).

set_stream_error(false) also clears the message; record_stream_error
sets both atomically; take_stream_error_message drains so the UI
fires its notification exactly once per error.
2026-05-13 13:09:18 +01:00
Afonso Jorge Ramos a7b40bf6bb perf(player): cache CoreAudio nominal rate query for 750ms
evaluate_stream_recreate runs on every track change on the audio
thread, and each call issued two HAL syscalls
(resolve_output_device_id + get_nominal_sample_rate). Cache the result
per-thread for 750ms — long enough to absorb the bulk of repeats
within an album, short enough to notice a system rate change.

Cache invalidates on device-name change, so switching the output
device still triggers a fresh query.
2026-05-13 09:23:39 +01:00
Victor RH da3c1e86ee Merge pull request #410 from Vudgekek/fix/internal/macos-shared-mode
fix(audio): fix macOS Shared Mode playback from changing speed
2026-05-12 23:51:44 -06:00
vicrodh bdf6ca30e5 fix(local-library): prefer album metadata over folder-derived title (#411)
User-reported: Local Library Albums view showed the folder name as
the album title even when the album tag in the track metadata had
a different value — e.g. folder `Alle Songs` shadowed metadata
`ALBUM.`. Cause: the three metadata-grouped album queries (full-load
`get_albums_metadata_grouped`, chunked `get_albums_metadata_page`,
and `count_albums_metadata_for_page`) projected the title via
`COALESCE(album_group_title, album, 'Unknown Album')`.

`album_group_title` is the snapshot the scanner wrote — it
contains the tag's album when the tag was present at scan time,
otherwise the folder name. So whenever a tag was missing or
changed after scan, the folder name would win over the live
`album` column.

Fixed the priority by querying `album` first and falling back to
`album_group_title` only when the live metadata is empty / NULL /
'Unknown Album':

  COALESCE(
      NULLIF(NULLIF(TRIM(album), ''), 'Unknown Album'),
      album_group_title,
      'Unknown Album'
  )

Folder-grouped queries (which use `album_group_key` first and
intentionally prefer folder-derived titles) are unchanged.

Closes #411

cc
2026-05-12 18:10:59 -06:00
vicrodh 447344c8c0 fix(local-library): plex id double-prefix + scrim z-index over buttons
Two bugs from the same session:

  - The plex_aggregated CTE prefixed `group_key` with `'plex:'`, but
    `plex/mod.rs::plex_album_key()` already returns `"plex:<hash>"`.
    Result: album.id was `"plex:plex:<hash>"` from the chunked path,
    which `handleAlbumClick` couldn't resolve, so opening an album
    showed NO TRACKS. Fixed: use `COALESCE(album_key, 'plex:' ||
    rating_key)` so populated rows pass through untouched and only
    the rating_key fallback gets the prefix added. GROUP BY mirrors
    the same expression for consistency.

  - On AlbumCardLibraryLite the scrim (`.cover-wrap::after`) and the
    overlay container (`.overlay`) were both at `z-index: 2`. Pseudo-
    elements paint after all DOM children at the same z-index, so
    the scrim ended up ON TOP of the buttons, hiding them on hover.
    Re-laid the layering so it's monotonic top-to-bottom:
        1  cover img
        2  scrim
        3  overlay (action buttons)
        4  select-checkbox + source-badge-slot

Plus: restored the dark `rgba(0,0,0,0.85)` hover scrim itself,
removed the `::after` accent-border ring + title-colour cue. Per the
2026-05-12 user call ("Hasta quisiera el overlay de regreso") — the
Plex thumbnail downscale (9f417999) made the scrim's opacity
transition cheap again since the underlying paint is now 220x220
not 1000x1000. Buttons go back to opacity 0 → 1 on hover with a
single transition on `.action-buttons`. White-outline circles
(transparent fill) since the scrim provides contrast.

cc
2026-05-12 16:40:45 -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
Afonso Jorge Ramos 1edde8a28a fix(audio): rephrase CoreAudio rate-mismatch errors for end users
The error strings flow up through stream_error and end up in user-
visible logs. Replace the internal-shaped messages ("opened at XHz
while device nominal rate is YHz") with text that names the cause,
the remediation, and the reason the fallback path is refused.
2026-05-11 15:19:30 +02:00
Afonso Jorge Ramos 9e632386b9 fix(audio): retry CoreAudio shared-mode open on rate mismatch
CPAL caches device configs and can briefly return a stale sample rate
right after CoreAudio changes the device nominal rate. Before this
change, opening at the stale rate failed the post-open validation and
returned Err, leaving the user with no audio on the first track after
a rate change.

Drop the bad sink, re-resolve the device, re-query nominal rate and
try once more with a 50ms backoff. Two attempts in total. Turns the
racy window into transparent recovery.
2026-05-11 15:16:46 +02:00
Afonso Jorge Ramos c408ea73cb test(player): cover compute_needs_new_stream decision rule
Extract the boolean decision out of evaluate_stream_recreate into a
pure function so the truth table can be tested without faking a
MixerDeviceSink or AudioSettings mutex. Eight cases cover the matrix:
no stream, unchanged format, format change with and without each
bit-perfect backend, and the CoreAudio shared-mode rate-mismatch path.
2026-05-11 15:15:57 +02:00
Afonso Jorge Ramos ac60113cdd refactor(player): extract StreamRecreateDecision helper
The rate-mismatch detection block was duplicated between the Play and
Stream handlers, and each handler acquired thread_settings.lock() three
times in a row. Extract into one helper that reads settings once and
returns every flag the recreate decision needs.

Also drops a dead else-branch that logged "Creating initial audio
stream" — unreachable inside if stream_opt.is_some() — and the
redundant info-log that re-announced the rate-mismatch reason at
recreate time (the warn-level log inside the helper already says
"Recreating stream to avoid wrong-speed playback").
2026-05-11 15:14:54 +02:00
Afonso Jorge Ramos 3b34a734b4 refactor(player): rename current_sample_rate to current_track_sample_rate
The field tracks the decoded source format, not the output stream's
hardware rate. The names are now explicit so future readers don't
conflate the two — on macOS shared mode these can legitimately differ
since Rodio resamples internally.
2026-05-11 15:13:29 +02:00
Vudgekek 7bad704e21 fix(audio): fix macOS Shared Mode playback from changing speed 2026-05-09 23:43:26 -05:00
vicrodh 2f2dbe626b perf(local-library): push sort into SQL and drop localeCompare from groupTracks
Two cooperating changes that flatten the long sort tail in the Tracks tab:

1. search_with_filter now ORDER BYs (album, COALESCE(album_artist, artist),
   disc_number, track_number, title) COLLATE NOCASE — matching the default
   album-grouped browsing. SQLite handles 100K rows in well under 100ms;
   the JS sort took several seconds for the same volume.

2. groupTracks replaces the chained localeCompare with decorate-sort-
   undecorate using precomputed composite keys (lowercase strings with a
   sentinel separator and zero-padded numeric segments). The Intl collator
   instantiation per comparison was the bulk of the per-pass cost.

Both changes preserve the existing ordering semantics for the album, artist
and name modes; locale-specific accent ordering becomes lowercase ASCII
order, which is acceptable for library browsing.

Tier 3 of three.

cc
2026-05-08 23:04:36 -06:00
vicrodh 87fbc1bc4e feat(qbz-library): add count_folder_tracks_recursive
Lightweight COUNT(*) helper for the tree-mode scan-root rows that
needed a recursive descendant count. Same network-folder filter as
the listing primitives.

cc
2026-05-08 17:23:21 -06:00
vicrodh 1d68f7516e feat(qbz-library): network-folder filter on folder-tree primitives
list_folder_children, list_folder_tracks, list_folder_tracks_recursive
now apply the same network-folder exclusion that flat-mode search
honors, so the tree rail and recursive playback see the same source
of truth. Tests cover the on/off cases.

cc
2026-05-08 16:58:59 -06:00
vicrodh 5e2670f59f test(qbz-library): empty-folder case for list_folder_tracks_recursive
Verify the recursive listing returns an empty Vec (not an error) when
the requested folder path has no descendants — frontend treats empty
as "nothing to play/queue" and skips the toast.

cc
2026-05-08 16:37:08 -06:00