Commit Graph
54 Commits
Author SHA1 Message Date
vicrodh ee37d65f40 fix(library): read album/album-artist/date across all tags at scan (#447, #507)
Port of 0c3b38f8 (bugfix/internal/local-library-tag-fallback) onto current
pre-release. The scanner read metadata from a SINGLE lofty tag
(primary_tag().or(first_tag())) with no per-key fallback to the file's other
tags. Old, repeatedly-retagged collections routinely carry the album /
Album Artist / date only in a secondary tag (ID3v1/APE/Vorbis) while the
primary tag lacks them:

- #447: the missed album tag let the folder-derived backfill land in the
  album column and masquerade as metadata (folder-name albums, folder-level
  grouping, MIN(year) across mixed folders).
- #507: the missed Album Artist left album_artist NULL, degenerating every
  COALESCE(album_artist, artist) to the per-track artist (Various Artists).

extract() now falls back per key across tagged_file.tags() (primary first,
deterministic conflict policy) for title/artist/album/album_artist/genre/
catalog/track/disc/date. Folder backfills stay as-is for genuinely untagged
files. Tests adapted to the AlbumGroupMode signature; 75/75 pass. A full
library rescan picks the fix up (no migration).

km
2026-07-22 16:49:10 -06:00
vicrodh 77e41caa2f feat(library): selectable album-identity mode query layer + root-artist clamp
Spec 2026-07-19-local-album-grouping-mode (A1 + C + D).

AlbumGroupMode { Metadata, Folder }: the Albums view can now group by the
album|artist metadata pair (today's #411 behavior) OR by the album-root
folder — a compilation with 10 track artists renders as ONE card, and
multi-disc boxes merge even when discs are tagged separately. Read-time
switch: no schema change, no rescan. Threaded through
get_albums_metadata_page / count / get_albums_metadata_grouped; the
cover-art lookup matches both key shapes. Title/artist derivation is
mode-agnostic (album tag preferred, album_group_title fallback, per-row
COALESCE(album_artist, artist) + COUNT(DISTINCT track_artist) > 1 ->
'Various Artists').

Untagged-root clamp (C): infer_artist_album no longer returns the library
ROOT's name as the artist when the album dir hangs directly off a root
(a tagless DSD set surfaced with artist 'Music'). extract_with_roots
threads the scan targets; plain extract keeps the legacy inference for
ephemeral/single-file paths.

Tests (66/66): folder mode compilation = 1 album + VA + all_artists,
album_artist respected, orphan bucket preserved, metadata split unchanged;
root-clamp cases incl. disc subfolders, 'Artist - Album' split, real
artist folder named like the root, and the no-roots legacy path.

km
2026-07-21 12:56:20 -06:00
Afonso Jorge Ramos 7a600a60f4 ci: add workspace cargo test job (exclude Slint UI graph)
Run unit/doc tests for the shipping crates workspace on PRs without
compiling qbz-ui. Fix three green-blockers: local playlist schema for
tests, Track.work fixture, and a stale ListenBrainz doctest.
2026-07-10 00:52:05 +02:00
vicrodh e379aa65f6 feat(library): sort control for the Tracks tab (SQL-side)
The Tracks tab is server-paginated (200 rows/page), so the new 8-option
sort dropdown (Default / Artist / Title / Year / Date added, mirroring
the Albums toolbar control) rides the SQL query through a validated
ORDER BY allowlist in search_with_filter_page (same pattern as the
albums page query), re-queries page 1 on change (gen-bumped, stale
in-flight pages drop), and persists in locallibrary_prefs. Every sort
key ends in id so LIMIT/OFFSET pagination is deterministic across
ties. The once-only Plex page-1 merge re-sorts client-side with a
comparator matching the SQL semantics. Group modes keep their
client-side visual reorder on top, unchanged. Zero new msgids — all
labels already exist.

cc
2026-07-06 20:56:02 -06:00
vicrodh a0154b9e63 feat(library): index DSF/DFF files and sort ephemeral folders musically
Scanner accepts .dsf/.dff; new AudioFormat::Dsd; metadata extraction
via qbz-dsd (embedded ID3, artwork, bit_depth=1 with the DSD bit rate
as sample_rate so badges derive DSD64/128/256). Ephemeral folder open
now sorts tracks by album/disc/track/title and re-keys ids so the
displayed order matches the play-queue order (was readdir order).

cc
2026-07-03 17:56:38 -06:00
vicrodh 9521c7e03e feat(playlist): hide Copy button for playlists already copied to library
Persist the source playlist id when a Qobuz playlist is copied (new
copied_playlists table in library.db, mirroring Tauri's user-scoped
qbz_copied_playlists set) and seed is-copied on detail-view load, so the
Copy button stays hidden on later opens, surviving an app restart.

cc
2026-06-28 12:58:04 -06:00
vicrodh ca46e3c478 feat(slint): offline playlists in folders + sidebar cover collage
Local playlists were excluded from both sidebar folders and the cover
collage. Folders: new local_playlists.folder_id column (+ migration) on
the shared playlist_folders, move_to_folder/clear_folder helpers, the
move-to-folder context menu enabled for local rows, optimistic cache
move, and grouping under folders in the rebuild + collapsed-folder
flyout. Collage: resolve up to four covers from a local playlist's own
tracks with no network (local file artwork, Plex thumbs, cached-Qobuz
covers), populated in the async load and routed through the local/Plex
art loader (file/Plex paths would miss the HTTP loader).

cc
2026-06-22 21:35:45 -06:00
vicrodh 58b464ea71 feat(slint): mixed playlists - sidecar interleave in the Qobuz detail (carrete)
The online Qobuz playlist detail now reads the local/plex sidecar tables
and renders the merged list with Tauri's exact absolute-slot interleave
(sidecar rows claim stored positions, Qobuz fills in server order, gaps
collapse, leftovers append, contiguous display numbers), via a shared
reader extracted from the D11 offline branch - online and offline render
identical row identities. Fix-forward over Tauri: same-slot collisions
emit all claimants local-first instead of silently dropping rows, and the
real plex glyph renders (T4).

Source-aware play/shuffle route mixed lists through the merged snapshot
with a Qobuz-only playback context; the QConnect all-or-nothing admission
path was verified untouched. Sidecar adds now position at the merged end
via MAX(position)+1 across both tables (fixes the live 0-based collision
writes from the picker/drag) with on-load collision healing (stable claim
order, only colliding rows renumber, every move logged; 9 new tests).
Multi-select removal splits by namespace - local and plex rows hit their
sidecar removers, Qobuz rows resolve catalog ids to playlist_track_ids
before the API call (fixing the live bulk-remove bug that shipped track
ids in the ptid parameter). Custom order upgrades to (track_id, is_local)
keying with Tauri's plex-rewrite wart replicated and documented. Rows
gain per-source favorite/download gating with a grid-preserving
placeholder column.

cc
2026-06-10 11:53:22 -06:00
vicrodh 81587f4d17 feat(slint): playlist snapshot (names+membership) and offline favorites rail
Qobuz playlist snapshot, captured opportunistically with zero new API
traffic: every online sidebar/manager list load upserts id+name+owner+
track_count for the user's playlists; opening a playlist detail online
full-replaces its membership track ids (refusing playlists never listed,
so viewed public playlists don't land). Offline consumers: synthesized
sidebar/manager entries resolve the persisted name (killing the cold-start
'Playlist (N local)' fallback when a snapshot exists), and the mixed-
playlist offline detail now renders the snapshot tracks that are playable
(snapshot intersect cached intersect grace) from the offline-cache index -
snapshot block first in snapshot order, sidecar local/plex block after.
D11 availability widens: a playlist with zero sidecar rows but cached
snapshot tracks now shows offline. 4 repo tests.

Offline favorites rail: the Favorites placeholder gains an 'Available
offline (N)' block - disk-cached favorite ids intersected with the
offline-cache READY index and LocalLibrary qobuz_download copies, compact
rows with the offline cover chain, click plays the rail as a queue through
the cache tier. Shared OfflinePlaceholder gains an @children slot; only
the Favorites mount injects the rail.

Single commit: the two features interleave in main.rs/state wiring and
ship together to keep every commit compiling.

cc
2026-06-10 02:27:54 -06:00
vicrodh 23129cb98e feat(slint): local playlist reorder and favorite/hide organization
Reorder: repo gains reorder(from, to) with remove-then-insert semantics
and contiguity preservation (5 tests); the local detail wires the existing
per-row chevron path (shown under Default sort - the repo order IS the
editable order; the Custom sort entry hides for locals since the custom-
order sidecar is u64-keyed) with optimistic model swap and off-thread
persistence.

Organization: additive favorite/hidden columns on local_playlists
(pragma-guarded migration + 2 tests incl. pre-migration shape), manager
cards render real heart/eye state and locals participate in the
visible/hidden toolbar filter, hidden locals drop from the sidebar while
staying reachable through the manager's hidden filter. Folder membership
for locals stays out of scope (folder tables are Qobuz-keyed).

main.rs wiring for these actions rides the next commit (shared file with
the in-flight favorites-rail batch).

cc
2026-06-10 02:10:33 -06:00
vicrodh 77dfd73b6d fix(slint): network-folder visibility keyed on connectivity, playback lock on missing files
Root cause of the empty Albums tab: transposed positional booleans from
the Plex-port rewrite (a45840d1) left exclude_network_folders=true and
include_qobuz_downloads=false unconditionally on every browse query since
2026-06-07. The owner-approved model replaces it: network-folder content
hides ONLY under hard offline (link confirmed down - no link means no LAN
mounts either); a logged-out session or induced offline with the link up
shows everything (the Qobuz verdict says nothing about NFS/SMB). The
show-network-folders toggle is retired from Settings (DB column stays,
Tauri-compatible).

Playback lock instead of hiding: local tracks get an existence check at
the play seam and in the advance walk (FileMissing verdict, 'is the drive
mounted?' toasts, never on the audio thread - D-state caveat documented).

Scan-cleanup guard: tracks under a network folder whose mount is
unreachable are never treated as missing (a refresh pressed before
remounting used to hard-delete the folder's whole index). mount_info: nfs4
and smb3-style version suffixes now classify as network (its red test is
green) and the longest-mount match respects path boundaries.

cc
2026-06-10 01:32:29 -06:00
vicrodh da6e9599fb feat(slint): first-class local playlists with offline-only mode (D7/D8)
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
2026-06-09 22:17:13 -06:00
vicrodh 41c5261613 feat(slint): local album homologation, Recently Played fixes, local artist nav
Album view (local/Plex):
- Rebuild LocalAlbumView as a near copy of the Qobuz AlbumPageView (whole-page
  scroll, 224px cover, action row, toolbar with quality badge + track search,
  column header, source-aware rows) minus the label/awards sidebar and Qobuz
  context menus. Add a per-album client-side track search.

Recently Played (Home) — D:
- Carry the track source (qobuz|plex|local) through the recently-played store so
  the cards resolve source-aware artwork (PlexThumb / local file), the album-card
  play routes to local/Plex playback instead of the empty Qobuz path, and the
  card click opens the right album view.
- Fix a regression from the Plex version split: local_queue_track now derives the
  content-hash plex_album_key for album_id (navigation) while album_group_key keeps
  the per-edition split key, so "go to album" and recent Plex albums open correctly.

Local artist navigation — B:
- Implement open_artist (was a TODO): latch the requested artist and select it once
  the Artists tab loads. The shared open-artist is now source-aware (numeric id ->
  Qobuz artist; name -> LocalLibrary Artists tab). Wire the LocalAlbum header artist
  link and the now-playing "Go to artist" for Plex/local.

Folder delete -> Recently Played cascade:
- Removing a Local Library folder now also prunes its albums/tracks from the
  recently-played store (album_keys_in_folder + recently::prune_albums).

Covers — C:
- Robust on-disk folder cover lookup (cover/folder/front/art/<album>.jpg, any image
  as a last resort), shared by the Folders subcards and now-playing/album rows.

cc
2026-06-09 01:44:39 -06:00
vicrodh ee9a3c1c89 refactor(qbz-library): extract ephemeral-folder module to the shared crate
Lift the in-memory ephemeral-folder library (synthetic-id store + folder
scan / metadata / CUE handling) out of the Tauri binary into a
frontend-agnostic qbz-library module (ADR-006), so both the Tauri and Slint
frontends consume identical logic. src-tauri/ephemeral_library now re-exports
it as a thin shim. Adds tracks_snapshot() / current_folder_path() for the
Slint consumer.

cc
2026-06-07 10:12:44 -06:00
vicrodh 682719cab0 feat(slint): library/sidebar UX overhaul + theme switching + fixes
Shell & navigation:
- Three-state sidebar (open/mini/closed); collapse cycles via header button.
- Section nav as right-flyouts (sidebar) + compact header nav (closed) +
  full header tabs (nav-in-sidebar off), via a shared HeaderMenuOverlay.
- Collapsed-folder right-flyout popup (max 4 + scrollbar), Rust-populated.
- Thin auto-hiding sidebar scrollbar pinned to the edge (Flickable, drag-safe).
- Slack-style bezel: rounded inset content panel over surface-card chrome.
- Header restructure: nav buttons, window-centered search, app menu.

Themes & components:
- Live theme switching: Dark / OLED Black / Tokyo Night / System (std-widgets).
- SegmentedTabBar (Discovery-style) with count badges for Library + Local Library.
- MP3 quality-badge tier (format-first classification) across badges.
- QbzSelect (Group/Sort) trimmed 20px.

Behavior fixes:
- Artist + Local Library Folder cover art now reach the rendered (visible/
  grouped) models, not just the source set.
- Favorites Playlists/Labels random buttons.
- Playlist track click plays from the clicked track in visible order.
- Dropdown idle-close pauses while the cursor rests on the menu.

cc
2026-05-30 15:40:35 -06:00
vicrodh 7e0d970fd5 feat(locallibrary): tag-editor core — lofty writer + kv + artist-match (qbz-library)
Frontend-agnostic foundation for the LocalLibrary tag editor (consumed by the
Slint modal + controller in a follow-up slice; the Tauri direct-write command
can later fix-forward onto this):

- tag_writer.rs: write_album_tags_to_files() ports the Tauri lofty loop 1:1
  (dedup by file_path first-wins, per-file on_progress closure instead of a
  Tauri event, destructive remove-on-None for album-artist/year/genre/catalog,
  honest partial-failure error wording) + compute_track_artist_match().
- database.rs: a small library_kv (key,value) table + get_kv/set_kv (upsert) to
  persist the direct-write one-time acknowledgement (replaces Tauri localStorage).

cc
2026-05-29 13:36:09 -06:00
vicrodh 7bef91b3d7 feat(locallibrary): Artists rail portraits + capped Qobuz photo fetch
Render artist portraits on the two-column Artists rail and fill missing ones
from Qobuz in the background.

- qbz-library: new get_all_artist_image_urls() bulk reader (custom path else
  cached Qobuz url, keyed by artist_name) so previously-fetched portraits
  reappear on revisit without re-hitting Qobuz. (The Tauri batch command was
  never registered; this is the corrected one-pass reader.)
- state.slint: LocalArtistItem gains a decoded `image` field; LocalLibraryState
  gains the fetch toggle + progress props. merge_artists now returns a Send-safe
  ArtistRow (no slint::Image) across spawn_blocking; apply_artists builds the
  LocalArtistItem with the (default-empty) image on the UI thread.
- ensure_artists_loaded: seeds the rail from get_all_artist_image_urls, spawns
  decode jobs by scheme (local path -> spawn_local_loads, http -> spawn_loads),
  and kicks the capped fetch.
- fetch_missing_artist_images: sequential worker, max 50/session, 1s apart,
  generation-guarded, exact-normalized match only (no wrong-artist persist),
  paints each portrait immediately, persists via cache_artist_image_with_canonical.
- artwork.rs: Copy-safe LocalArtistRowImage { index, gen } target + apply arm
  that resolves index -> name and routes through the new name-keyed dual-setter
  set_artist_row_image (flat master + every grouped section), so portraits
  survive a derive_artists re-run.
- LocalArtistRow renders the decoded image (placeholder until it lands).

cc
2026-05-29 13:31:58 -06:00
vicrodh aa7c320b94 feat(locallibrary): library scan engine + progress (Slice B)
Lift the Tauri scan-orchestration loop into the core crate and drive it from
Settings > Local Library with live progress + cancel.

Core (qbz-library/src/scan.rs): scan_with_progress(db, folder_ids, artwork_cache,
cancel, on_event) replicates v2_library_scan (full) and v2_library_scan_folder
(single) byte-for-byte: CUE files first, per-album sidecar override, embedded ->
folder artwork with a per-folder cache, insert, and missing-file cleanup (whole
DB for full, folder-prefix for single). Progress is pushed through a ScanEvent
callback (no IPC poll) and a cancel AtomicBool is checked at every file boundary
(no cleanup/scan-time on cancel, Tauri parity). Two verified improvements: the
full scan now stamps each folder's last_scan, and network status is re-detected
for every non-overridden scanned folder.

Slint: LibraryScanState global (single poll target; progress computed Rust-side);
a determinate 2-rect progress bar + counts + current-file + Stop button in the
panel; scan-all disabled while running. Controller run_scan() on spawn_blocking
with a ~100ms-coalesced sink (terminal/phase events bypass throttling), an owned
SCAN_CANCEL token, an outcome toast, and a post-scan reload (folder list +
reset browse models so the tabs re-fetch). scan-all/scan-folder/stop-scan wired.

cc
2026-05-29 13:16:21 -06:00
vicrodh 3826298f77 feat(locallibrary): Settings > Local Library — folder management (Slice A)
Port the Tauri folder-management surface to a new Settings > Local Library
sub-section (index 4); the browse-view gear routes here and pre-selects it.

UI:
- LocalLibrarySettings.slint: folder list (icon, display name, disabled/
  unavailable badges, multi-select checkboxes) + toolbar (scan-all/add/edit/
  remove icon buttons) + filter + Maintenance (cleanup missing) + two-step
  Danger Zone clear. ADR-008: plain bordered icon buttons + square badges.
- LibFolderEditModal.slint: alias, enabled, network override + fs-type,
  change-path, accessibility status, last-scanned, scan-this-folder. Mounted
  at the AppShell root (overlays the window, ADR-009 by mount order).
- SettingsView gains the section-4 sub-nav + panel; app.slint re-exports the
  new globals so the Rust layer can populate them.

Controller (local_library_settings.rs): load/add/remove/edit/save/change-path,
cleanup-missing (stats outside the DB lock), two-step clear, filter, select.
All DB access via qbz-library through library_db::with_db on spawn_blocking;
native dir picker + confirms via rfd. Selection preserved across reloads;
network folders get an async 6s-timeout accessibility check (exists() fallback
on timeout, mirroring Tauri).

Core (qbz-library): network_fs_label() for fs-type parity; separator-safe
delete_tracks_in_folder_prefixed() + remove_folder_with_tracks() (fixes the
prefix-collision bug without changing the legacy fn). Scan engine lands in
Slice B.

cc
2026-05-29 13:07:19 -06:00
vicrodh 56b3ca790b feat(locallibrary): Tracks tab — server-paginated flat list
Adds the Tracks browse tab. Each page is a search_with_filter_page query
(new paged variant of search_with_filter) appended on scroll-near-bottom, so
the list never materializes the whole table — the documented ~16K-row Tracks
freeze. Reuses the shared TrackRow in a virtualized ListView + ListScrollbar
(the Favorites Tracks pattern) and the Albums controller's generation-guard
(TRACKS_GEN) so a search/retry can't be clobbered by a stale in-flight page.

- qbz-library: search_with_filter_page(query, offset, limit, include_qobuz,
  exclude_network) — same ordering as search_with_filter, LIMIT/OFFSET.
- local_library.rs: map_local_track (LocalTrack is Send -> crosses
  spawn_blocking; TrackItem built on the UI thread), fetch_tracks_page,
  apply/append, reload/ensure/load_more (TRACKS_GEN-guarded).
- LocalLibraryView.slint: Tracks body (ListView + TrackRow, show-artwork off
  per Tauri's perf default, show-favorite/show-download off) + a search
  toolbar + loading/error/empty states.
- state.slint + main.rs: tracks state/actions + lazy load on first visit +
  search/load-more/retry wiring.

Group-by, multi-select/bulk, and per-row playback land with the source-aware
playback slice (track-action is a stub, like Albums). Built green
(cargo check -p qbz-slint); runtime smoke-test pending.

cc
2026-05-29 00:17:51 -06:00
vicrodh b412293f3c fix(locallibrary): make the album folder-cover fallback robust
Generalize resolve_album_cover_fallback beyond a single track + cover.jpg:
scan several of the album's tracks (CMAF keeps each track in its own folder,
only some of which may carry a cover), check both the track folder and its
parent (multi-disc layouts keep art one level up), and accept the common
local cover filenames (cover/folder/front, jpg/png). Resolves more on-disk
covers without touching the paged query. Albums whose offline bundle never
saved a cover (segments only) still need an offline-cache cover re-fetch.

cc
2026-05-29 00:03:48 -06:00
vicrodh 3fc724af84 fix(locallibrary): show offline/folder album covers + add source badge
Two fixes from the first Albums-grid test pass.

Offline/folder covers (the reported "cover exists in the folder but isn't
shown"): some albums — offline-cached CMAF bundles and folder downloads —
have no artwork_path in the index even though a cover.jpg sits in the track
folder (older offline-cache downloads didn't backfill it). Add a frontend-
agnostic LibraryDatabase::resolve_album_cover_fallback(group_key) that finds
a representative track, derives its folder (the path itself for a CMAF bundle
dir, else the parent), and returns <folder>/cover.jpg when it exists. The
Slint Albums mapper calls it whenever the album has no artwork. Verified
against the live index: it resolves the folder covers that exist on disk
(FLAC-layout + CMAF-with-cover). Older CMAF bundles that never downloaded a
cover (segments only) still show the placeholder — those need a cover
re-fetch (offline-cache maintenance), tracked separately.

Source badge: the Local Library album card now shows the always-visible
source badge inside the artwork (bottom-right) — a hard-drive glyph for local
files, the Qobuz logo for offline copies, the Plex logo for Plex. Added
AlbumCard.show-source-badge (default off; threaded through AlbumCollectionView
/AlbumGrid) + AlbumCard.source; album_map now carries the real source and
map_local_album maps user->local / qobuz_download / plex. Small rounded
square, not a pill (ADR-008). Logos copied into the Slint icon assets.

Built green (cargo check -p qbz-slint); runtime smoke-test pending.

cc
2026-05-28 23:40:58 -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 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
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
vicrodh 93a242e0d0 feat(qbz-library): add list_folder_tracks_recursive primitive
Returns full LocalTrack rows for every descendant under a folder path,
filtered by COALESCE(source, 'user') = 'user' so qobuz_download rows
never leak. Ordering is file_path ASC for stable on-disk reading
order across heterogeneous subtrees. Reuses escape_like_pattern so
literal '_' / '%' in path segments don't behave as LIKE wildcards.

Used by the tree-mode multi-select to populate selectedTrackIds when
the user ticks a folder-row checkbox; returns the full record (not
just IDs) so the frontend can build queue items without a second
round-trip.

Adds three unit tests covering the recursive walk, file_path
ordering, and special-character escape handling.

cc
2026-05-08 16:17:21 -06:00
vicrodh 1bb353429c chore(qbz-library): address Task 1 review nits
- Replace the false "SQLite ORDER BY fragility" rationale on the
  Rust-side sort in list_folder_children with the actual reason
  (rows already in memory; Rust case-insensitive compare clearer
  than COLLATE NOCASE on a CASE-derived column).
- Drop the redundant ESCAPE '\\' clause on the literal '%/%' pattern
  in list_folder_tracks. The pattern contains no escape character;
  the clause was a copy-paste artifact from the user-input LIKE
  patterns where ESCAPE is required.

No behaviour change.

cc
2026-05-08 14:05:49 -06:00
vicrodh 8481f7e8b3 feat(qbz-library): add list_folder_children + list_folder_tracks queries
Two new SQL primitives that walk local_tracks.file_path to reconstruct
filesystem hierarchy without schema changes. Both filter
COALESCE(source, 'user') = 'user' so Plex and Qobuz downloads are
excluded from the eventual tree-mode view in the Folders tab. Both hit
idx_tracks_file_path for the LIKE prefix% portion; per-row level
extraction is in-memory over the prefix-narrowed candidate set. New
FolderTreeEntry enum carries the child rows. Unit tests cover folder/
track interleaving, source filter, special-character paths, and
direct-children vs recursive scoping.

cc
2026-05-08 13:48:26 -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 54074a5580 feat(library): add metadata-grouped album queries
New album_grouping module exports the SQL CASE-WHEN expression for the
metadata group key shared by both new queries. The expression has three
priorities: (1) usable album tag => album|artist key, (2) fallback to
existing folder-based album_group_key, (3) orphan bucket
'__unknown_album__'.

get_albums_metadata_grouped runs the grouped query and returns
LocalAlbum rows with directory_path empty and source_folders populated
with the comma-separated list of contributing folders. Pinned 'Unknown
Album' to the bottom via ORDER BY.

get_album_tracks_metadata fetches all tracks belonging to a metadata
group, ordered by disc/track number.

Adds idx_local_tracks_album_lookup index on (album, album_artist,
artist) to keep the GROUP BY index-backed at 16k+ track libraries.

LocalAlbum gains an optional source_folders field (None for folder-
grouped rows; Some(comma-separated) for metadata-grouped rows). The
existing folder-grouped query continues to populate it as None so no
downstream consumer breaks.

Hidden filter (album_settings.hidden) does not apply to metadata-
grouped rows in this iteration; revisiting requires extending
album_settings to support metadata keys.

cc
2026-05-06 17:10:51 -06:00
vicrodh 7eba2749f5 fix(library): preserve per-track artwork during scan, cache folder lookups
The scan loop called update_album_group_artwork after every successful
track insert, which propagated one track's artwork to every track in
the same group. Tracks with unique embedded covers ended up sharing
whichever cover was processed last. Drop the call; per-track artwork
is resolved correctly by extract_artwork (embedded) + find_folder_artwork
(cover.jpg fallback) before insert_track.

Replace the propagation's caching effect with an in-memory per-folder
HashMap that memoizes find_folder_artwork for the duration of the scan
run, avoiding repeated folder walks for tracks without embedded art.

Mark update_album_group_artwork #[deprecated] so no future caller
reintroduces the bug. Keep it compilable.

Existing libraries already have collapsed artwork paths in the DB and
need a re-scan to recover per-track covers; called out in release notes.

cc
2026-05-06 16:32:27 -06:00
vicrodh 1e13447921 feat(library): detect network-mounted tracks at index time
Adds an is_network_mount flag to local_tracks. Populated by reading
/proc/mounts (falling back to /run/host/proc/mounts for Flatpak
sandboxes) and matching the file path's longest-prefix mount against
a list of network filesystems — nfs/nfs4, cifs/smb/smb3, fuse.sshfs,
fuse.rclone, fuse.gvfs, davfs/webdav, 9p, ceph, glusterfs, afs, afp.

Motivation: the earlier offline-awareness commit used a frontend
heuristic that matched /mnt, /media, /run/media and Windows UNC. That
catches the classic cases but misses the common sandbox case — a user
on Flathub or Snap mounts their NAS via GVFS at /run/user/1000/gvfs/…
or bind-mounts it into /home/user/music, and neither of those
triggers the frontend check. The backend sees the mount type
directly, so flagging the row at index time gives the UI ground truth.

Changes:
  * new crates/qbz-library/src/mount_info.rs — is_network_path(&Path)
    helper with /proc/mounts parsing, longest-prefix mount matching,
    and a NETWORK_FS_PREFIXES list. Guarded with cfg(target_os =
    "linux"); stubs return false on macOS / Windows (frontend keeps
    its string-match heuristic there).
  * LocalTrack gains is_network_mount: bool (serialized camelCase).
  * local_tracks table: new is_network_mount INTEGER NOT NULL DEFAULT
    0 column, both in the initial CREATE TABLE and a migration for
    existing DBs.
  * insert_track calls mount_info::is_network_path(&track.file_path)
    and writes the flag. Qobuz cached-download inserts rely on the
    column default (always 0) since those land inside the app's own
    data dir.
  * TRACK_COLUMNS + row_to_track pick up the new column. The two
    inline LocalTrack builders in get_playlist_local_tracks and
    get_playlist_local_tracks_with_position are updated the same way,
    plus the playlist_position index shifts by one.

cc
2026-04-21 13:27:31 -06:00
vicrodh 13ecec3b17 feat(library): add playlist_plex_tracks table and CRUD for Plex-in-playlist
First half of the Plex-tracks-in-playlists fix. Adds a dedicated
playlist_plex_tracks table alongside the existing playlist_local_tracks
so Plex content (TEXT rating key, owned by a remote server) doesn't
collide with the i64 filesystem id used by local_tracks. No foreign
key to plex_cache_tracks — the cache can be purged independently and
the user's intent should survive.

Database methods added:
  * add_plex_track_to_playlist(playlist_id, rating_key, position)
  * remove_plex_track_from_playlist(playlist_id, rating_key)
  * get_playlist_plex_tracks_with_position(playlist_id) ->
    Vec<(rating_key, position)>
  * get_playlist_plex_track_count(playlist_id) -> u32

get_all_playlist_local_track_counts now merges plex counts into the
same HashMap so the sidebar's hasLocalContent signal stays a single
number and covers Plex-populated playlists without a second round
trip from the frontend.

cc
2026-04-21 11:27:51 -06:00
vicrodh b539f67b7c feat(mixtape): Tauri command layer for CRUD
Twelve v2_* commands wrap mixtape::repo with the standard app state
pattern used by commands_v2/playlists.rs. Enum values travel as
strings across the Tauri boundary; camelCase for frontend, snake_case
for Rust.

Also adds LibraryDatabase::with_connection_mut for the reorder command,
which needs a transaction (&mut Connection).

cc
2026-04-18 18:04:08 -06:00
vicrodh 357650300d feat(mixtape): schema migrations for collections and items
Two new tables (mixtape_collections, mixtape_collection_items) with
CASCADE on delete and indexes for the two common query patterns. One
additive column on session_queue_state for queue-source tracking.

Idempotent (CREATE TABLE IF NOT EXISTS, ALTER only when missing),
safe on every app start. The ALTER is skipped gracefully if
session_queue_state hasn't been created yet.

Adds LibraryDatabase::with_connection() helper (qbz-library) so
src-tauri can run external DDL on the shared Connection without
exposing it as a field. Migration is wired in session_lifecycle
immediately after library.init_at() in both the online and offline
activation paths.

cc
2026-04-18 17:46:13 -06:00
vicrodh 2c0f42b885 chore(deps): bump dirs 5 -> 6 across all crates
Zero API changes, only bumps internal deps (windows-sys, redox_users).
Eliminates duplicate dirs versions in Cargo.lock.

Closes #257

cc
2026-04-06 15:16:34 -06:00
victorrhgap a99d558e37 fix: update lofty 0.18 API calls to lofty 0.23
- Imports reorganized: use lofty::prelude::*, lofty::tag::*, lofty::picture::*
- Picture::new_unchecked -> Picture::unchecked().pic_type().mime_type().build()
- set_year/remove_year -> set_date/remove_date with Timestamp
- ItemKey::Unknown("ISRC") -> ItemKey::Isrc
- save_to_path(path) -> save_to_path(path, WriteOptions::default())
- get_string/remove_key now take ItemKey by value
- Bump qbz-library crate lofty dep to 0.23

cc
2026-03-31 09:32:02 -06:00
vicrodh de29f1e2e0 chore(qconnect): checkpoint current poc state
cx
2026-03-13 17:28:05 -06:00
vicrodh c94dad9f33 fix(library): album names with Disc/CD suffix misidentified as disc folders (#147)
is_disc_folder() was matching any folder name containing "Disc N" or
"CDN" anywhere in the name. This caused albums like "100 Popular
Classics, Disc 1" or "Now 75 - CD1" under Various Artists to be treated
as disc subfolders, collapsing all tracks into a single album group.

Now is_disc_folder() only returns true when the folder name consists
exclusively of disc-related tokens (disc/cd/disk, digits, and modifiers
like "bonus"). Album names with extra words are correctly left as-is.

Users must re-scan their library for the fix to take effect.
2026-03-12 10:30:58 -06:00
vicrodh c7ab0ba129 fix: track number display for compilations and box sets (#147)
- Add filename-based track number inference as fallback when tags lack track_number
- Detect degenerate track numbering (all tracks same number) and use index fallback
- Flatten single-track-per-disc box sets into sequential numbering
- Replace fragile SELECT * with explicit column list in track queries
2026-03-10 15:08:10 -06:00
vicrodh 786ce883ac fix(library): extract track/disc number from file tags during backfill import
insert_qobuz_cached_track_direct was hardcoding track_number to 0,
causing all imported tracks to show wrong numbers. Now accepts
track_number and disc_number params, and both callers (backfill
downloads and offline cache sync) extract metadata from the audio
file via MetadataExtractor before inserting.

Also fix LocalLibraryView to use || instead of ?? for track_number
fallback, so 0 correctly falls back to index-based numbering.

Closes #147
2026-03-06 11:08:32 -06:00
vicrodh 87321a0ea6 fix(library): detect qobuz_purchase source on scan and clean stale downloads
insert_track() now checks downloaded_purchases table to set source.
get_downloaded_purchase_track_ids() verifies file existence and
auto-removes stale entries. Closes #115
2026-03-05 11:13:59 -06:00
vicrodh 683f97a5d5 fix: deduplicate albums with quality subfolders in local library
The library scanner's album_root_dir() only knew to skip disc folders
(Disc 1, CD2, etc.) when resolving the album directory. Files stored
in quality subfolders like artist/album/FLAC 24-bit - 96 kHz/track.flac
caused each encoding variant to appear as a separate album card.

Add is_encoding_folder() to detect common audio format directory names
(FLAC, MP3, AAC, WAV, etc.) and make album_root_dir() skip past both
encoding and disc subdirectories, handling chained nesting too.

Fixes the workaround from #116.
2026-03-01 15:40:22 -06:00
vicrodh 4073b9fd35 feat: track download format in SQLite for per-quality status
Migrate downloaded_purchases to compound PK (track_id, format_id) so
the same track downloaded in MP3 and FLAC are tracked separately.
Album detail endpoint now returns downloaded_format_ids per track.
2026-02-24 15:07:58 -06:00