Commit Graph
4584 Commits
Author SHA1 Message Date
Afonso Jorge Ramos e54c5de08b feat(views): drag regions on top-bars across remaining views 2026-05-13 20:40:43 +01:00
Afonso Jorge Ramos 47f3a9f335 feat(views): drag regions on home, library, and favorites top-bars 2026-05-13 20:40:39 +01:00
Afonso Jorge Ramos 897f6ac862 feat(detail-views): drag regions and selectable metadata blocks 2026-05-13 20:40:34 +01:00
Afonso Jorge Ramos eb57c70ef9 feat(modals): mark info modals and legal notice selectable 2026-05-13 20:40:29 +01:00
Afonso Jorge Ramos 418bf371e8 refactor(styles): drop now-redundant local user-select: none 2026-05-13 20:40:23 +01:00
Afonso Jorge Ramos a34e418530 feat(sidebar): drag window from aside top padding band on macos 2026-05-13 20:39:58 +01:00
Afonso Jorge Ramos 69d7ee0212 refactor(layout): remove macos overlay drag and main-content padding 2026-05-13 20:39:57 +01:00
Afonso Jorge Ramos 5610861ed8 refactor(titlebar): drag on spacer divs not full bar 2026-05-13 20:39:56 +01:00
Afonso Jorge Ramos c1c151ee25 refactor(css): user-select none on body with .selectable opt-in 2026-05-13 20:39:50 +01:00
vicrodh f12b4e2997 Merge branch 'feature/queue-persistence-315' into pre-release 2026-05-13 13:12:21 -06:00
vicrodh d4745f84a3 feat(session): restore local on startup, let QConnect overwrite when it lands (#315)
Today the startup orchestration skips the local session restore whenever
qconnectRuntimeActive — defensive against the visual flicker of seeing
the last local queue and then watching QConnect overwrite it. That left
QConnect users staring at "No track playing" while the WebSocket
bootstrapped, and the queue was effectively unrecoverable if QConnect
failed to push state.

Inverts the policy per #315: always restore the local snapshot. When
QConnect is bootstrapping at launch, the restore is held back behind a
1s flicker guard timer. The QueueUpdated event listener cancels the
timer if the remote queue snapshot arrives first — the user sees only
QConnect state, no flicker. If 1s elapses without a remote snapshot
(slow network, server hiccup, controller offline), the timer fires and
local restore proceeds so the user isn't stuck.

Two related wires:
- restoreLastView runs unconditionally now (view restore is unrelated
  to playback source, shouldn't be gated on QConnect).
- The QConnect listener also calls debouncedFullSessionSave after a
  successful QueueUpdated sync, covering the case where QConnect
  reorders/replaces upcoming tracks while the current_track stays the
  same — the track-change effect wouldn't fire, and without this hook
  the local snapshot would drift behind remote.

Acceptance from issue #315: queue persistence on every mutation
(prior commit), QConnect state read at startup (already wired via
bootstrap_remote_presence → AskForQueueState → QueueStateChanged),
QConnect wins on divergence (cancel-guard makes this the typical
path), local-only path unchanged when QConnect is off (else branch).

The auto-advance / delete-timing guard logic in corebridge.rs and
commands_v2/playback.rs is untouched — the change lives in the route
component's restore orchestration, not in the QConnect runtime.

cc
2026-05-13 12:54:36 -06:00
vicrodh 267440f541 feat(session): round-trip streamable, parental_warning, source_item_id_hint (#315)
The PersistedQueueTrack struct already had streamable and parental_warning
fields but the load path hardcoded streamable=true and parental_warning
=false because the columns didn't exist in queue_tracks. source_item_id
_hint was missing entirely.

Adds the three columns via the same idempotent ALTER-TABLE pattern the
existing migrations use, persists them in save_session, and reads them in
load_session. PersistedQueueTrack gains source_item_id_hint as an
Option<String> with serde default so saves from older builds deserialize
cleanly.

User-visible effect: a restored queue now keeps explicit-content badges,
unstreamable visual state, and Mixtape/Collection source-item ids
matching the live queue instead of resetting to defaults at every
launch. Backwards-compatible: columns get safe defaults for older rows
(streamable=1, parental_warning=0, hint=NULL) so users upgrading from
1.2.12 don't see a regression.

cc
2026-05-13 12:41:58 -06:00
vicrodh 7284568c40 feat(session): persist queue on every mutation, not just track-change (#315)
The local session restore today saves the full queue only on track-change
(debounced 2s), pause, clear, the 15s playback tick, and app-close. Pure
queue mutations — add/addNext/setQueue/clearQueue/moveQueueTrack/
removeAfter/stop-after-consume — left the persisted queue stale until the
next track boundary, which meant a crash mid-album lost any tracks
queued after the playing one.

queueStore exposes registerQueueMutationObserver so the route component
can plug debouncedFullSessionSave into the mutation path without the
store knowing about session-save internals. Each mutator that funnels
through syncQueueState now also calls notifyMutation, the observer
coalesces via the existing 2s debounce, and the SQLite write happens on
the same path the track-change save already uses (single source of
truth, idempotent on rapid mutations).

The cursor-only mutators (next/previous/playQueueIndex) intentionally
do NOT notify — they are already covered by the track-change effect at
+page.svelte:5055 which saves on current_index change.

cc
2026-05-13 12:32:41 -06:00
vicrodh 6f8f6f43e9 Merge branch 'bugfix/qconnect-batch-pagination-419' into pre-release 2026-05-13 11:37:37 -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
Victor RH 93f51d26bd Merge pull request #422 from afonsojramos/document-stream-error-clear
docs(player): clarify set_stream_error(false) drops pending message
2026-05-13 11:17:50 -06:00
Victor RH 8445f84530 Merge pull request #421 from afonsojramos/close-other-filters-on-open
fix(views): close other dropdown filters when opening one
2026-05-13 11:11:21 -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 9231ea2bd4 fix(mixtapes): replace click-swallowing backdrop with document click-outside 2026-05-13 17:54:48 +01:00
Afonso Jorge Ramos b3b0fce535 fix(purchases): replace click-swallowing backdrop with document click-outside 2026-05-13 17:54:48 +01:00
Afonso Jorge Ramos 3382a36ede fix(mixtape-collection): replace click-swallowing backdrop with document click-outside 2026-05-13 17:54:48 +01:00
Afonso Jorge Ramos 29598747fb fix(collections): replace click-swallowing backdrop with document click-outside 2026-05-13 17:54:48 +01:00
Afonso Jorge Ramos ff11dea869 fix(label): drop tracks context menu backdrop in favor of click-outside listener 2026-05-13 17:54:48 +01:00
Afonso Jorge Ramos ddf0ed4175 fix(artist-detail): consolidate section sort menus and drop tracks context backdrop 2026-05-13 17:54:48 +01:00
Afonso Jorge Ramos 126dff1297 fix(library): consolidate toolbar dropdowns into single openMenu state 2026-05-13 17:54:47 +01:00
Afonso Jorge Ramos 7fccf9bd01 fix(favorites): consolidate toolbar and section dropdowns into single openMenu state 2026-05-13 17:54:47 +01:00
Afonso Jorge Ramos fc56fb4b2f feat(genre-filter): make isOpen bindable for parent dropdown coordination 2026-05-13 17:54:08 +01:00
vicrodh 653201346a perf(lyrics): drop karaoke gradient in non-immersive CPU mode
Follow-up to #418 (karaoke playback overhaul). In CPU mode the
background-clip: text repaint plus the per-frame --line-progress
interpolation that drives the gradient stop are both costly under
software compositing. Immersive already swaps the gradient for solid
white (kept), but sidebar/static/compact surfaces still ran the
expensive path.

Adds a global override under html.no-hwaccel that zeroes the gradient,
restores a solid accent color, and kills the --line-progress transition.
Active-line emphasis still reads via the font-size/weight/scale from
.lyrics-line.active, which is unchanged. The immersive solid-white rule
keeps winning on specificity (0,0,5,0 vs 0,0,4,1), so this only takes
effect on the non-immersive surfaces where the gradient was the cost.

cc
2026-05-13 10:53:07 -06:00
vicrodh 427e1b5bec Merge branch 'feature/multiselect-completion-381' into pre-release 2026-05-13 10:48:52 -06:00
vicrodh 58a80b57d0 feat(favorites): select-all checkbox for track multiselect
Adds a tri-state Select-All checkbox in the Tracks tab toolbar of
FavoritesView, mirroring the local-library tracks tab. State is computed
over the currently filtered tracks so genre/search filters narrow the
selectable set, matching user expectation for the visible-set semantics
established in #381.

cc
2026-05-13 10:48:48 -06:00
vicrodh 2e2c7549fc feat(local-library): finalize #381 multiselect with cross-context apagador
Completes the multiselect rollout in LocalLibraryView and the folder/album
view:

- Select-All checkbox in the folder-album track listing, mirroring the
  tracks-tab toolbar pattern.
- Shift-click anchor and ctrl-a effect for track rows in the folder-album
  view.
- Cross-context mutual exclusion ("apagador") between tree-select,
  album-select, track-select and the folder-album view's selection mode:
  entering any context clears the others' selections and turns off their
  selection mode, so two contexts cannot be active simultaneously.

The mutex is plumbed via parentSelectionActive prop + onSelectionEntered
callback between LocalLibraryView (orchestrator) and the
LocalLibraryFolderAlbumView child, matching the simpler single-switch
mental model the user requested.

cc
2026-05-13 10:48:42 -06:00
Victor RH 000f7c10d2 Merge pull request #418 from afonsojramos/fix-macos-lyrics-text-color
fix(lyrics): active-line color and karaoke playback overhaul
2026-05-13 10:44:26 -06:00
Afonso Jorge Ramos 84dcd7ebbe perf(+page): guard lyrics state writes against subscriber churn
subscribeLyrics fires per rAF tick (~60Hz with extrapolation). The
previous version wrote all six $state slots unconditionally, which
re-triggered the lyricsSidebarLines $derived (a .map allocating N
objects per call) every tick even though lyricsLines itself only
changes per-track. Adding reference/value guards short-circuits the
fan-out for unchanged fields — the per-tick path now only writes
lyricsActiveProgress (and sometimes lyricsActiveIndex).
2026-05-13 14:31:19 +01:00
Afonso Jorge Ramos 640dead393 fix(lyricsStore): claim rAF handle pre-tick and notify on every change
Two related fixes to the active-line rAF loop:

- The previous guard reassigned updateRafHandle *after* updateActiveLine
  returned. Any listener that synchronously re-entered
  startActiveLineUpdates during notifyListeners() saw a null handle and
  scheduled a second parallel rAF chain. Now the handle is claimed
  before the work runs, so re-entry is a no-op.

- Drops the inter-tick progress threshold. With playerStore extrapolating
  audio time between backend events, every rAF tick has a freshly-changed
  progress value. Threshold-throttling would skip the edge samples where
  the snap-to-1 and indexChanged transitions need to land — re-introducing
  the "starts midway"/"ends before 100%" bugs. Downstream consumers
  equality-guard their $state writes, so fan-out only fires on real
  changes.
2026-05-13 14:31:11 +01:00
Afonso Jorge Ramos 6166584ee8 perf(playerStore): extrapolate audio time between backend position events
The Rust backend emits playback:state at 250ms cadence. Frontend
getCurrentTime() was returning that staircase value — for the karaoke
rAF loop running at 60Hz, every 15 frames got the same answer, then a
250ms jump. This caused two visible artifacts:

- Active line appears to "start midway" when detection of the new index
  lags by up to 250ms past the line's timeMs (audio is already that far
  into the line by the time we see the boundary).
- Active line gets demoted before its highlight reaches the end, because
  the last in-line notification reports progress ~0.92 (one tick before
  bound) and the next backend tick has already crossed into the next
  line — the snap-to-1 never gets a chance to fire.

Audio position is wall-clock-based on the backend (see the 250ms emit
loop comment in lib.rs); we now do the same math in JS: anchor
(positionSecs, performance.now()) on every event-driven update, then
return anchor + elapsed while playing. Cap extrapolation at +1s so a
stalled backend doesn't run away. Existing 33 playerStore tests pass
unchanged — they read currentTime directly, not getCurrentTime().
2026-05-13 14:31:03 +01:00
Afonso Jorge Ramos 64511c4fa2 feat(lyrics): segment active-line gradient by visual line via pretext
Pre-pretext, the karaoke gradient applied to a single span shared the same
horizontal cut across every wrapped visual line — line 2 appeared partially
sung when only line 1's worth of vocals had played. Pretext lays out the
active lyric into visual lines; each renders as its own block-level span
with its own 0->100% gradient, so progress fills sequentially.

Also adds race/perf safeguards layered on top:
- Cancellation token on tick().then(layoutActiveLine) so rapid activeIndex
  changes (seek-scrub, fast songs) don't queue stale microtasks against
  the current DOM.
- ResizeObserver coalesces via rAF and ignores sub-pixel width deltas, so
  window-drag and the active-line scale animation don't trigger Pretext
  re-measurement every callback.
- segmentBounds prefix array memoizes cumulative widths, dropping per-
  segment progress lookup from O(i) to O(1) inside the each-block.
- EMA writes equality-guard measuredBlockMs/Delta and lookaheadEnabled so
  Svelte signals don't fan out no-op updates 60 times per second.
- Lookahead clamped at 1/30 and disabled on backward dp so a coarse audio
  jump or in-line seek can't poison the visual lead.
- measuredBlockMs seed lowered (>=20ms, dur * 0.01) since rAF-rate data
  converges the EMA within 2-3 ticks; the prior 80ms floor visibly lagged
  the gradient on the first frame of every new line.
- LyricsLine type unified from store (single source of truth).
2026-05-13 14:30:54 +01:00
Afonso Jorge Ramos 57f8307934 feat(ui): toast on audio:init-failed event
Listen for the backend audio:init-failed event and show a user-facing
error toast (8s) with the backend-provided cause. Mirrors the
audio:device-missing listener pattern alongside it. Surfaces the
silence when macOS CoreAudio init fails, instead of leaving the user
guessing why playback didn't start.
2026-05-13 13:09:35 +01:00
Afonso Jorge Ramos a251849094 i18n(toast): add audioInitFailed translation key
Add localized strings for the new backend audio init failure toast
(en, de, es, fr, pt). Reason placeholder receives the backend's
already user-friendly message (e.g. "macOS audio device changed its
sample rate during stream open...").
2026-05-13 13:09:31 +01:00
Afonso Jorge Ramos 6d91ec0e02 feat(audio): emit audio:init-failed Tauri event on backend init failure
The playback-event polling loop now drains
SharedState::take_stream_error_message and emits a Tauri
audio:init-failed event with the user-readable cause. Drain semantics
guarantee the toast fires exactly once per error instead of every
tick of the polling loop.
2026-05-13 13:09:26 +01: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 83fb109b26 Merge pull request #416 from afonsojramos/debug-local-update-issue
fix(ci): suffix macOS .app.tar.gz bundles with arch to fix auto-updater
2026-05-12 23:55:57 -06: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 f5e00e0a24 Merge branch 'feature/cpu-mode-lite-panels' into pre-release
cc
2026-05-12 21:22:06 -06:00
vicrodh 4b29030820 feat(immersive): CPU-mode lite path + per-panel toggles + warning modal + badge unification
Comprehensive set of changes that make immersive mode usable on
machines without hardware-accelerated GPU compositing (Wayland NVIDIA
with the broken driver path, software-rendered llvmpipe, VMs, etc.).

Detection lives in `graphicsState.ts` — when `bootstrapGraphicsState()`
resolves and `hardware_accel_enabled` is false, we add an
`html.no-hwaccel` root class. Every CPU-mode rule below gates itself
on that class so GPU users see zero change.

Chrome-level work (ImmersivePlayer wraps every panel):

  - Blanket CSS rule on `.immersive-player` and descendants kills
    every `transition`, `animation`, `box-shadow`, and `text-shadow`.
    Box-shadows with 32-60px blur radii were the dominant per-frame
    cost — confirmed game-changer when stripped (user-tested on
    Visualizer with `0 8px 32px + 0 20px 60px` halo around the
    artwork).
  - `.player-bar`, `.window-menu`, `.tabs`, `.viz-dropdown`,
    `.viz-submenu`, and the quality-badge backdrop become opaque
    `#000` and drop `backdrop-filter` (40px / 16px / 12px blurs).
  - Whitelisted animation exceptions: the 4-bar equalizer in
    Coverflow's now-playing indicator (4 x 3px scaleY, basically
    free) and `fadeInUpLite` for LyricsFocus line changes
    (opacity + transform only, no `filter: blur`).

Per-panel LOW_PROFILE flag (captured from `isHardwareAccelEnabled()`):

  - Oscilloscope: skip `shadowBlur: 8` glow, fake with fat-underlay
    + thin-opaque stroke pair; force dpr=1.
  - Lissajous: skip shadowBlur: 6 with same trick, trail 8 -> 3
    frames, drop the 85-fillRect-per-frame dot cloud, hide the
    blurred watermark, dpr=1.
  - EnergyBands: drop per-band energy-driven shadowBlur (up to
    ~20px x 5 rings); halo-arc + ring trick; dpr=1.
  - Visualizer: taller cubes (14px vs 6px, half the count per bar);
    drop the per-cubelet gradient entirely so the whole frame uses
    a single `fillStyle`; dpr=1.
  - Coverflow: snap side-cover transitions (only `.center` keeps
    its 500ms transform); neutralize hover transforms on side
    covers (rotateY + scale 1.05 forces re-rasterization under
    software perspective); side covers go to opacity:1 so they
    don't pay alpha compositing.

LyricsLines split mode in immersive: restore line transitions for
opacity, color, and transform (all compositor-only). Skip the
font-size transition because that one forces re-layout per frame.

New per-panel enable/disable store (`immersivePanelsStore.ts`):

  - localStorage-backed `Record<ImmersivePanelId, boolean>`.
  - In CPU mode the five inherently-heavy panels default to
    disabled: AlbumReactive, NeonFlow (Laser), TunnelFlow,
    CometFlow, TransientPulse. These rely on shaders, particle
    systems, or dense per-pixel filter passes that simply cannot
    work in software compositing.
  - `applyCpuModeDefaultsIfNeeded()` re-applies defaults after
    `bootstrapGraphicsState()` resolves, so first-launch CPU users
    don't have to relaunch to see the heavy panels hidden. Wiring
    lives in `bootstrap.ts` to avoid a circular import between
    the store and graphicsState.
  - ImmersiveHeader filters the visualizer dropdown and Neon
    submenu by enabled state; if all three Neon variants are off,
    the submenu item disappears entirely. The `Visualizer` main
    tab falls back to the first enabled viz option instead of
    hardcoded spectral-ribbon.

First-time CPU-mode warning modal (`CpuModeWarningModal.svelte`):

  - Built on ModalLite. Triggered from `+page.svelte` via $effect
    when immersive opens, HW accel is off, and the user has not
    dismissed the warning before. Disarmed for the rest of the
    session after fire.
  - "Don't show again" checkbox writes
    `qbz-cpu-mode-warning-shown-v1` to localStorage when checked.
  - Explains which panels are hidden and points to Settings ->
    Appearance -> Composition and -> Immersive Panels.

Settings UI: new "Immersive Panels" collapsible section above the
existing "Immersive FPS" section in Settings -> Appearance. Per-panel
toggle, with a GPU badge + warning paragraph on the five heavy panels
when running in CPU mode.

QualityBadge unification (out of CPU-saver scope, addressed in same
branch by user request): every immersive callsite that previously
used the reactive `QualityBadge` (compact or full) now renders
`QualityBadgeStatic` — the same component used in Collection /
Mixtape detail lists. Sixteen callsites total: nine canvas panels
(compact mode), five center-content panels (full mode), plus
ImmersivePlayer split mode and ImmersiveControls. `QualityBadgeStatic`
gains `text-align: start` on its `.badge-text` block so it renders
correctly inside parents that center their content (VisualizerPanel,
StaticPanel, etc.).

i18n: new keys for `settings.appearance.immersivePanels.*` (title,
summary, desc, cpuWarning, heavyTooltip, warningModal.{title,body,
hint,dontShowAgain,acknowledge}) across en/es/de/fr/pt.

cc
2026-05-12 21:21:59 -06:00
vicrodh f528129fab Merge branch 'feature/412-track-list-album-covers' into pre-release
cc
2026-05-12 18:22:12 -06:00
vicrodh 145831fec6 feat(local-library): opt-in album artwork in tracks/folders list (#412)
User request: show the album cover thumbnail next to each track in
the Local Library Tracks and Folders list views (same UX the Qobuz
tracks and playlist views already offer).

Default OFF per the 2026-05-12 user call: a library with 16k+ local
tracks would render that many decoded images per scroll session,
and the user shouldn't opt into that without knowing. The setting
lives in `libraryPerformanceStore` (`showTrackArtwork: false` by
default) alongside the other library-perf knobs.

Frontend wiring:

  - `libraryPerformanceStore.ts` — new `showTrackArtwork` field,
    `isTrackArtworkEnabled()` getter, plumbed through `updateSetting`.
  - `LocalLibraryView.svelte` — subscribes to the store, passes
    `showArtwork={showTrackArtwork}` and a
    `getArtworkUrl={(track) => getArtworkUrl(track.artwork_path)}`
    bridge to `VirtualizedTrackList`. `VirtualizedTrackList` and
    `TrackRow` already supported these props (used by Playlist
    detail view).
  - `SettingsView.svelte` — new toggle in Appearance, next to the
    "Show playlist cover collages in sidebar" toggle. Uses the same
    Toggle component and same persistence pattern.

i18n: new `settings.appearance.localLibraryTrackArtwork` +
`settings.appearance.localLibraryTrackArtworkDesc` keys across all
five locales.

cc
2026-05-12 18:20:56 -06:00
vicrodh 9abc82b872 Merge bugfix/411-album-title-prefer-metadata into pre-release 2026-05-12 18:11:08 -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