Add cardGridLayout helpers, useCardGridMetrics, useRemeasureGridVirtualizer, and VirtualCardGrid (TanStack row virtualization, always remeasure on layout changes).
Apply to Artists (grid + list unchanged policy), Albums, Composers grid, playlists, radio stations, offline library, and album-heavy browse/detail pages. Respect disableMainstageVirtualLists for a non-virtual grid with the same column rules.
Includes vitest coverage for column cap.
Findings 5-8 of the dedup audit:
- F5 byte formatters: appUpdaterHelpers.fmtBytes + ZipDownloadOverlay
.formatMB route through the existing formatBytes; a new formatMb
(always-MB) backs playlistDetailHelpers.formatSize, AlbumHeader and
the 4 inline DeviceSyncPreSyncModal expressions. SongInfoModal.format
Size is intentionally left — it uses decimal (1e6) divisors, not 1024.
- F6 sanitizeHtml: extracted to utils/sanitizeHtml.ts; AlbumHeader,
ComposerDetail and the (now-empty, deleted) artistDetailHelpers use it
directly. nowPlayingHelpers keeps its own export but now delegates to
the shared sanitiser and only adds its trailing-link strip on top.
- F7 album duration: BecauseYouLikeRail's formatAlbumDuration drops in
favour of the shared formatHumanHoursMinutes. Behaviour note: total
minutes now floor instead of round (<=1 min display difference,
matches every other caller).
- F8 clock time: extracted to utils/format/formatClockTime.ts;
PlaybackDelayModal + QueueHeader use it (toLocaleTimeString and
Intl.DateTimeFormat produced identical output).
Behaviour preserved except the two explicitly noted divergences (F7
round->floor; F5 appUpdater/Zip now show GB above 1 GB instead of a
large MB number).
111 of 122 top-level src/utils/ files move into 16 topic folders (audio,
cache, cover, share, server, playback, playlist, deviceSync, waveform,
mix, format, export, changelog, ui, perf, componentHelpers). True
singletons with no cluster stay at the utils/ root.
Pure file-move: a path-aware codemod rewrote 539 relative-import
specifiers across 275 files; no logic touched. The hot-path coverage
gate list (.github/frontend-hot-path-files.txt) is updated to the new
paths for the 11 gated utils files — a mechanical consequence of the
move, not a CI change. tsc is green.
Seven domain-eng splits peel ~200 LOC of read endpoints out of
`api/subsonic.ts`:
- `subsonicStreamUrl.ts` — `buildStreamUrl`, `coverArtCacheKey`,
`buildCoverArtUrl`, `buildDownloadUrl` (token-signed URL builders
for the four /rest endpoints we hand to the browser).
- `subsonicStarRating.ts` — `getStarred`, `star`, `unstar`,
`setRating`, `probeEntityRatingSupport`. `setRating` still triggers
the lazy `navidromeBrowse` cache invalidation; the same-folder
lazy import path is preserved.
- `subsonicSearch.ts` — `search`, `searchSongsPaged`.
- `subsonicScrobble.ts` — `scrobbleSong`, `reportNowPlaying`,
`getNowPlaying`.
- `subsonicAlbumInfo.ts` — `getAlbumInfo2`.
- `subsonicLyrics.ts` — `getLyricsBySongId`.
- `subsonicGenres.ts` — `getGenres`, `getAlbumsByGenre`.
63 external call sites migrated to direct imports. Four `vi.mock`
targets in the store-level tests pointed at `../api/subsonic` and
were updated to the new module paths.
Pure code-move. subsonic.ts: 762 → 561 LOC (−201).
Three domain-eng modules peel ~316 LOC of fetch/mapping out of
`api/subsonic.ts`:
- `subsonicLibrary.ts` — browse + random + per-song fetch
(`getMusicDirectory`, `getMusicIndexes`, `getMusicFolders`,
`getRandomAlbums`, `getAlbumList`, `getRandomSongs`,
`getRandomSongsFiltered`, `getSong`, `getAlbum`,
`filterSongsToActiveLibrary`, `similarSongsRequestCount`, plus
the private `albumIdsInActiveLibraryScope` cache).
- `subsonicArtists.ts` — artist endpoints (`getArtists`, `getArtist`,
`getArtistInfo`, `getTopSongs`, `getSimilarSongs2`,
`getSimilarSongs`). Uses Library's `filterSongsToActiveLibrary` and
`similarSongsRequestCount` for the per-library scoping fallback.
- `subsonicRatings.ts` — `parseSubsonicEntityStarRating` parser plus
`prefetchArtistUserRatings` and `prefetchAlbumUserRatings` workers
with the shared 7-min cache. Calls back into Library/Artists for
the per-id fetch.
51 external call sites migrated to direct imports. No re-export
shims in `subsonic.ts`. Statistics endpoints still in `subsonic.ts`
keep their `RATING_CACHE_TTL` constant locally (same 7-min window).
subsonic.ts: 1078 → 762 LOC (−316).
First Phase F slice. Splits the 1333-LOC `api/subsonic.ts` along its
two most obvious axes:
- `subsonicTypes.ts` — all ~24 exported interfaces + type aliases
(album/song/artist/playlist/directory/genre/now-playing/radio,
random-songs filters, three statistics shapes, search + starred
results, AlbumInfo, structured-lyrics types, etc.) plus the
`RADIO_PAGE_SIZE` constant.
- `subsonicClient.ts` — token-auth + `getClient` + `api<T>()` +
`libraryFilterParams` + `secureRandomSalt` / `getAuthParams` /
`SUBSONIC_CLIENT`. The credential-bearing API helpers
(`pingWithCredentials`, `apiWithCredentials`, `restBaseFromUrl`,
`probeInstantMixWithCredentials`) stay in `subsonic.ts` for now —
they could move into the client module in a follow-up.
66 external call sites migrated to direct imports from the new
modules (no re-export shims in `subsonic.ts`). Pure code-move;
contract test stays green.
subsonic.ts: 1333 → 1078 LOC (−255).
* feat(composer): Browse by Composer page (issue #465)
New library section listing every artist credited as composer on at
least one track, with a detail page showing all works they're credited
on in that role. Targeted at classical-music libraries where the
"recording artist" tag carries the orchestra and the "composer" tag
carries Bach / Mozart / Chopin.
Hits Navidrome's native /api/artist?_filters={"role":"composer"} for
the listing and /api/album?_filters={"role_composer_id":"…"} for the
works grid — Subsonic getArtist only follows AlbumArtist relations and
returns 0 albums for composer-only credits, so the native API is the
only path that works. Requires Navidrome 0.55+ (uses
library_artist.stats role aggregation); on older / pure-Subsonic
servers the page shows a one-line capability banner.
- Two new Tauri commands: nd_list_artists_by_role +
nd_list_albums_by_artist_role, generic over participant role so
conductor / lyricist / arranger pages are trivial to add later.
- Composers grid: text-only compact tiles (name + participation count
pulled from stats[role].albumCount). No avatars — composer libraries
carry no useful imagery and the listing endpoint exposes no image
URLs anyway.
- ComposerDetail: hero with Last.fm bio (via getArtistInfo2) plus the
full work grid, with a graceful fallback when the artist has no
external info synced.
- Sidebar entry default off (Feather icon) — opt-in for the niche
classical use case.
- nd_retry backoffs widened from [500] to [300, 800, 1800] — helps
every nd_* call survive intermittent TLS-handshake-EOF errors that
some reverse-proxy setups produce when keep-alive pools churn.
- Distinguishes "server can't do this" (HTTP 400/404/422/501) from
transient errors so the capability banner only fires when the server
actually rejects the request shape; everything else gets a retry
button.
- i18n in all 8 supported locales.
* fix(composer): address review feedback on detail page + role queries
- Re-fetch ComposerDetail when music-library scope changes; previously
the album grid stayed stale until navigation while the list refreshed.
- Thread library_id through nd_list_artists_by_role and
nd_list_albums_by_artist_role so role queries respect the active
Navidrome library, matching the Subsonic musicFolderId already piped
through libraryFilterParams().
- Fix CachedImage cache-key mismatch on ComposerDetail: a Last.fm header
image was stored under the Subsonic cover-art key, aliasing cache
entries and risking cross-source pollution.
- Consolidate the two contradictory composer-imagery comments in
Composers.tsx into a single accurate one (the older one referenced an
Images toggle that was never implemented).
- Align openLink toast duration with ArtistDetail (1500ms -> 2500ms).
* fix(composer): keep bio across scope changes, add share, degrade gracefully
Three remaining items from the latest review pass on the composer flow.
1. Bio survives a music-library scope change.
The previous fix added musicLibraryFilterVersion to the load effect,
but that effect also did setInfo(null) while the getArtistInfo effect
still depended on [id] alone — so a scope bump on the open page
wiped the bio without re-fetching it. Move the info reset into the
bio effect (keyed on id) and out of the load effect: the album grid
still refreshes on scope change; the Last.fm header image and
biography survive untouched, since both are library-independent.
2. Composers join the share pipeline as a first-class entity kind.
Extend EntityShareKind with 'composer' (and isEntityKind), branch
applySharePastePayload to validate via getArtist (same id pool) and
navigate to /composer/:id, and wire a Share button into
ComposerDetail. A pasted composer link now opens the composer view
instead of the artist view, matching what was copied. i18n added in
all 8 locales (sharePaste.composerUnavailable, openedComposer;
composerDetail.shareComposer, unknownComposer).
3. Partial server failure no longer hides the works.
If getArtist rejects but ndListAlbumsByArtistRole succeeds, the page
used to show full "not found" despite having data to display. Switch
the not-found gate to require both empty (`!artist && !albums`) and
render a degraded header (placeholder name, no Wikipedia / favourite
/ share / Last.fm image) when only metadata is missing.
* fix(composer): right-click share copies a composer link, not an artist link
The context menu opened from a composer card / row uses type='artist'
because every composer-action (radio, favourite, rating, add-to-playlist)
is identical to the artist counterpart — they share an id space and a
backend representation. Sharing was the one exception: the "Share Link"
entry produced a 'psysonic2-' string with k='artist', so a paste opened
/artist/:id even though the user came from /composers.
Add an optional shareKindOverride to openContextMenu (default: undefined,
preserves existing behaviour) and have the artist-typed branch consult
it when calling copyShareLink. Composers.tsx now passes 'composer' on
both right-click sites; nothing else changes downstream because the
override only affects the share kind.
* polish(composer): show Last.fm avatar even without server metadata
Two minor follow-ups from the latest review.
- ComposerDetail: drop the `&& artist` guard on the header-avatar render
path. info?.largeImageUrl can resolve through getArtistInfo(id) without
ever needing the SubsonicArtist record, so the previous gate hid a
perfectly good Last.fm portrait whenever getArtist failed but the
bio fetch succeeded. Replace artist.name with displayName so the
alt / aria-label degrade to the localised "Composer" placeholder
instead of empty strings.
- copyEntityShareLink: doc comment now mentions composer alongside
track / album / artist.
* fix(composer): derive Last.fm cache key from route id, not from artist record
Follow-up to the previous polish: the avatar render path no longer
requires `artist` to be populated, but the cache-key gate still did. So
when getArtist failed but getArtistInfo returned a Last.fm portrait, the
key fell through to coverKey — which is empty without an artist record,
re-creating the very aliasing bug the earlier Subsonic-vs-Last.fm fix
was meant to close.
Switch the Last.fm branch to the route id (same id namespace as the
SubsonicArtist record), so the key stays stable whenever Last.fm art is
shown, independent of getArtist succeeding.
* docs: CHANGELOG + Contributors entry for composer browsing (PR #487)