fix(library): Navidrome artist letter buckets + safer index open/swap (#1144) (#1145)

This commit is contained in:
cucadmuh
2026-06-21 20:55:35 +03:00
committed by GitHub
parent 986550c854
commit d9969ed76f
26 changed files with 1088 additions and 115 deletions
+2
View File
@@ -80,6 +80,7 @@ export interface SyncStateDto {
serverLastScanIso?: string | null;
indexesLastModifiedMs?: number | null;
artistsLastModifiedMs?: number | null;
ignoredArticles?: string | null;
localTrackCount?: number | null;
serverTrackCount?: number | null;
lastError?: string | null;
@@ -237,6 +238,7 @@ export interface LibraryArtistDto {
serverId: string;
id: string;
name: string;
nameSort?: string | null;
albumCount?: number | null;
syncedAt: number;
rawJson: unknown;
+2
View File
@@ -161,6 +161,8 @@ export interface SubsonicNowPlaying extends SubsonicSong {
export interface SubsonicArtist {
id: string;
name: string;
/** Article-stripped lowercase sort key (local index / OpenSubsonic). */
nameSort?: string;
albumCount?: number;
coverArt?: string;
starred?: string;
+1
View File
@@ -170,6 +170,7 @@ const CONTRIBUTOR_ENTRIES = [
'AutoDJ — smooth skip and interrupt blend: manual/out-of-queue crossfade, loud→loud ~2s advance, cold-target prep duck + deferred player-bar handoff (PR #1128)',
'Play queue sync — manual pull via connection indicator, idle auto-pull, multi-server push filter, flush-on-server-switch (PR #1131)',
'Niri compositor tiling WM detection (PR #1127)',
'Local library index: Navidrome ignored-articles artist/composer letter buckets (name_sort + server ignoredArticles), idempotent migration with safe open/swap and poisoned-lock recovery (PR #1145)',
],
},
{
+8 -5
View File
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import type { SubsonicArtist } from '../api/subsonicTypes';
import { usePlayerStore } from '../store/playerStore';
import { ALL_SENTINEL, artistBucketKey, compareBuckets, type ArtistListFlatRow } from '../utils/componentHelpers/artistsHelpers';
import { ALL_SENTINEL, artistLetterBucket, compareBuckets, type ArtistListFlatRow } from '../utils/componentHelpers/artistsHelpers';
interface UseArtistsFilteringArgs {
artists: SubsonicArtist[];
@@ -10,6 +10,8 @@ interface UseArtistsFilteringArgs {
starredOnly: boolean;
visibleCount: number;
viewMode: 'grid' | 'list';
/** Server `ignoredArticles` when known (local index); omit for Navidrome default. */
ignoredArticles?: string | null;
}
interface UseArtistsFilteringResult {
@@ -42,13 +44,14 @@ export function useArtistsFiltering({
starredOnly,
visibleCount,
viewMode,
ignoredArticles,
}: UseArtistsFilteringArgs): UseArtistsFilteringResult {
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const filtered = useMemo(() => {
let out = artists;
if (letterFilter !== ALL_SENTINEL) {
out = out.filter(a => artistBucketKey(a.name) === letterFilter);
out = out.filter(a => artistLetterBucket(a, ignoredArticles) === letterFilter);
}
if (filter) {
const needle = filter.toLowerCase();
@@ -58,7 +61,7 @@ export function useArtistsFiltering({
out = out.filter(a => a.id in starredOverrides ? starredOverrides[a.id] : !!a.starred);
}
return out;
}, [artists, letterFilter, filter, starredOnly, starredOverrides]);
}, [artists, letterFilter, filter, starredOnly, starredOverrides, ignoredArticles]);
const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]);
const hasMore = visibleCount < filtered.length;
@@ -67,12 +70,12 @@ export function useArtistsFiltering({
if (viewMode !== 'list') return { groups: {} as Record<string, SubsonicArtist[]>, letters: [] as string[] };
const g: Record<string, SubsonicArtist[]> = {};
for (const a of visible) {
const key = artistBucketKey(a.name);
const key = artistLetterBucket(a, ignoredArticles);
if (!g[key]) g[key] = [];
g[key].push(a);
}
return { groups: g, letters: Object.keys(g).sort(compareBuckets) };
}, [visible, viewMode]);
}, [visible, viewMode, ignoredArticles]);
const artistListFlatRows = useMemo((): ArtistListFlatRow[] => {
if (viewMode !== 'list') return [];
+38
View File
@@ -0,0 +1,38 @@
import { useEffect, useState } from 'react';
import { libraryGetStatus } from '../api/library';
/**
* Server `ignoredArticles` (Navidrome `getArtists` watermark) for the active
* server's local index, used to bucket artist/composer letters the same way the
* index sorts `name_sort`. Falls back to `null` (Navidrome default list) when
* the index is disabled, the server omits the field, or the lookup fails.
*
* One lightweight read per server change — `ignoredArticles` rarely changes, so
* we deliberately avoid the polling that `useLibraryIndexSync` does.
*/
export function useLibraryIgnoredArticles(
serverId: string | null | undefined,
enabled = true,
): string | null {
const [ignoredArticles, setIgnoredArticles] = useState<string | null>(null);
useEffect(() => {
if (!enabled || !serverId) {
setIgnoredArticles(null);
return;
}
let cancelled = false;
void libraryGetStatus(serverId)
.then(status => {
if (!cancelled) setIgnoredArticles(status.ignoredArticles ?? null);
})
.catch(() => {
if (!cancelled) setIgnoredArticles(null);
});
return () => {
cancelled = true;
};
}, [serverId, enabled]);
return ignoredArticles;
}
+4 -1
View File
@@ -20,6 +20,7 @@ import {
ARTIST_LIST_ROW_EST,
} from '../utils/componentHelpers/artistsHelpers';
import { useArtistsFiltering } from '../hooks/useArtistsFiltering';
import { useLibraryIgnoredArticles } from '../hooks/useLibraryIgnoredArticles';
import { useArtistsBrowseCatalog } from '../hooks/useArtistsBrowseCatalog';
import { useBrowseArtistTextSearch } from '../hooks/useBrowseArtistTextSearch';
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
@@ -136,9 +137,11 @@ export default function Artists() {
const selectedArtists = artists.filter(a => selectedIds.has(a.id));
const ignoredArticles = useLibraryIgnoredArticles(serverId, indexEnabled);
const {
filtered, visible, hasMore, groups, letters, artistListFlatRows,
} = useArtistsFiltering({ artists, filter: effectiveFilter, letterFilter, starredOnly, visibleCount, viewMode });
} = useArtistsFiltering({ artists, filter: effectiveFilter, letterFilter, starredOnly, visibleCount, viewMode, ignoredArticles });
const pendingLetterMatch =
browseMode === 'slice'
+7 -11
View File
@@ -20,6 +20,8 @@ import { peekComposerBrowseScrollRestore } from '../store/composerBrowseSessionS
import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore';
import { readComposerBrowseRestore } from '../utils/navigation/albumDetailNavigation';
import { filterArtistsWithRoleAlbumCredits } from '../utils/library/composerBrowse';
import { ALL_SENTINEL, artistLetterBucket } from '../utils/componentHelpers/artistsHelpers';
import { useLibraryIgnoredArticles } from '../hooks/useLibraryIgnoredArticles';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
import OverlayScrollArea from '../components/OverlayScrollArea';
@@ -28,7 +30,6 @@ import { useClientSliceInfiniteScroll } from '../hooks/useClientSliceInfiniteScr
import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
import InpageScrollSentinel from '../components/InpageScrollSentinel';
const ALL_SENTINEL = 'ALL';
const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
const COMPOSER_LIST_LETTER_ROW_EST = 48;
@@ -172,15 +173,11 @@ export default function Composers() {
}, [musicLibraryFilterVersion, reloadTick]);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const ignoredArticles = useLibraryIgnoredArticles(serverId);
const filtered = useMemo(() => {
let out = composerSource;
if (letterFilter !== ALL_SENTINEL) {
out = out.filter(a => {
const first = a.name[0]?.toUpperCase() ?? '#';
const isAlpha = /^[A-Z]$/.test(first);
if (letterFilter === '#') return !isAlpha;
return first === letterFilter;
});
out = out.filter(a => artistLetterBucket(a, ignoredArticles) === letterFilter);
}
if (effectiveFilter) {
const needle = effectiveFilter.toLowerCase();
@@ -190,7 +187,7 @@ export default function Composers() {
out = out.filter(a => a.id in starredOverrides ? starredOverrides[a.id] : !!a.starred);
}
return out;
}, [composerSource, letterFilter, effectiveFilter, starredOnly, starredOverrides]);
}, [composerSource, letterFilter, effectiveFilter, starredOnly, starredOverrides, ignoredArticles]);
const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]);
const hasMore = visibleCount < filtered.length;
@@ -219,13 +216,12 @@ export default function Composers() {
if (viewMode !== 'list') return { groups: {} as Record<string, SubsonicArtist[]>, letters: [] as string[] };
const g: Record<string, SubsonicArtist[]> = {};
for (const a of visible) {
const letter = a.name[0]?.toUpperCase() ?? '#';
const key = /^[A-Z]$/.test(letter) ? letter : '#';
const key = artistLetterBucket(a, ignoredArticles);
if (!g[key]) g[key] = [];
g[key].push(a);
}
return { groups: g, letters: Object.keys(g).sort() };
}, [visible, viewMode]);
}, [visible, viewMode, ignoredArticles]);
const composerListFlatRows = useMemo((): ComposerListFlatRow[] => {
if (viewMode !== 'list') return [];
@@ -1,5 +1,69 @@
import { describe, expect, it } from 'vitest';
import { artistBucketKey, compareBuckets, OTHER_BUCKET, ALPHABET } from './artistsHelpers';
import {
artistBucketKey,
artistLetterBucket,
compareBuckets,
DEFAULT_IGNORED_ARTICLES,
OTHER_BUCKET,
ALPHABET,
sortKeyFromDisplayName,
stripLeadingArticles,
} from './artistsHelpers';
describe('stripLeadingArticles', () => {
it('strips The from Beatles', () => {
expect(stripLeadingArticles('The Beatles', DEFAULT_IGNORED_ARTICLES)).toBe('Beatles');
});
it('strips The from Kinks', () => {
expect(stripLeadingArticles('The Kinks', DEFAULT_IGNORED_ARTICLES)).toBe('Kinks');
});
it('honours a custom ignoredArticles list', () => {
expect(stripLeadingArticles('Los Lobos', 'Los')).toBe('Lobos');
});
});
describe('sortKeyFromDisplayName', () => {
it('strips articles and lowercases', () => {
expect(sortKeyFromDisplayName('The Beatles')).toBe('beatles');
});
});
describe('artistLetterBucket', () => {
it('buckets a browse row by its display name, ignoring stale nameSort', () => {
const artist = { id: '1', name: 'The Chemical Brothers', nameSort: 'the chemical brothers' };
expect(artistLetterBucket(artist)).toBe('C');
});
it('uses a server ignoredArticles override when supplied', () => {
const artist = { id: '2', name: 'Los Lobos' };
expect(artistLetterBucket(artist, 'Los')).toBe('L');
});
});
describe('screenshot T-filter artists (Navidrome article rules)', () => {
it('keeps Theme/Tossers/Temper/Tiger under T after stripping The', () => {
expect(artistBucketKey('The Theme Guys')).toBe('T');
expect(artistBucketKey('The Tossers')).toBe('T');
expect(artistBucketKey('The Temper Trap')).toBe('T');
expect(artistBucketKey('The Tiger Lillies')).toBe('T');
expect(artistBucketKey('TV Themes')).toBe('T');
expect(artistBucketKey('Tribute To The N...')).toBe('T');
});
it('moves Beatles/Chemical/Cure/Doors out of T', () => {
expect(artistBucketKey('The Beatles')).toBe('B');
expect(artistBucketKey('The Chemical Brothers')).toBe('C');
expect(artistBucketKey('The Cure')).toBe('C');
expect(artistBucketKey('The Doors')).toBe('D');
expect(artistBucketKey('The Fat Rat')).toBe('F');
});
it('keeps glued TheFatRat under T (no space after article — Navidrome parity)', () => {
expect(artistBucketKey('TheFatRat')).toBe('T');
});
});
describe('artistBucketKey', () => {
it('buckets AZ names by their uppercased first letter', () => {
@@ -8,6 +72,11 @@ describe('artistBucketKey', () => {
expect(artistBucketKey('mGla')).toBe('M');
});
it('puts The Beatles under B', () => {
expect(artistBucketKey('The Beatles')).toBe('B');
expect(artistBucketKey('The Kinks')).toBe('K');
});
it('puts digit-leading names in #', () => {
expect(artistBucketKey('2Pac')).toBe('#');
expect(artistBucketKey('50 Cent')).toBe('#');
+49 -4
View File
@@ -6,23 +6,68 @@ export const ALL_SENTINEL = 'ALL';
export const OTHER_BUCKET = 'OTHER';
export const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''), OTHER_BUCKET];
/** Navidrome default (`IgnoredArticles` when the server omits the field). */
export const DEFAULT_IGNORED_ARTICLES = 'The El La Los Las Le Les Os As O A';
/** Stable ordering index for a bucket key — '#' first, AZ, then 'Other' last. */
const BUCKET_ORDER = new Map(ALPHABET.map((l, i) => [l, i]));
/** Strip leading articles for sort/bucket keys (Navidrome `RemoveArticle` parity). */
export function stripLeadingArticles(
name: string,
ignoredArticles = DEFAULT_IGNORED_ARTICLES,
): string {
const trimmed = name.trim();
for (const article of ignoredArticles.split(' ').filter(Boolean)) {
const prefix = `${article} `;
if (
trimmed.length >= prefix.length
&& trimmed.slice(0, prefix.length).toLowerCase() === prefix.toLowerCase()
) {
return trimmed.slice(prefix.length).trimStart();
}
}
return trimmed;
}
/** Sort key from display name — article strip + lowercase (Navidrome parity). */
export function sortKeyFromDisplayName(
displayName: string,
ignoredArticles?: string | null,
): string {
const articles = ignoredArticles?.trim() || DEFAULT_IGNORED_ARTICLES;
return stripLeadingArticles(displayName, articles).toLowerCase();
}
/**
* Bucket an artist name into the alphabet index:
* Bucket an artist name into the alphabet index (after article stripping):
* - `#` → starts with a digit (09)
* - `A``Z` → starts with an ASCII letter
* - `A``Z` → starts with an ASCII letter on the sort key
* - `OTHER` → anything else (accents, CJK, Cyrillic, symbols, empty)
*
* Buckets always derive from the display `name` + `ignoredArticles`, never the
* persisted `nameSort` (which can lag a renamed artist until the next reconcile).
*/
export function artistBucketKey(name: string): string {
const first = name?.trim()?.[0];
export function artistBucketKey(
name: string,
ignoredArticles?: string | null,
): string {
const sortKey = sortKeyFromDisplayName(name, ignoredArticles);
const first = sortKey?.[0];
if (!first) return OTHER_BUCKET;
if (/^[0-9]$/.test(first)) return '#';
const up = first.toUpperCase();
return /^[A-Z]$/.test(up) ? up : OTHER_BUCKET;
}
/** Letter bucket for a browse row — uses the server's `ignoredArticles` when known. */
export function artistLetterBucket(
artist: SubsonicArtist,
ignoredArticles?: string | null,
): string {
return artistBucketKey(artist.name, ignoredArticles);
}
/** Sort comparator for bucket keys following ALPHABET order (unknown keys last). */
export function compareBuckets(a: string, b: string): number {
return (BUCKET_ORDER.get(a) ?? 999) - (BUCKET_ORDER.get(b) ?? 999);
+8 -1
View File
@@ -242,10 +242,17 @@ export function artistToArtist(ar: LibraryArtistDto): SubsonicArtist {
const base: SubsonicArtist = {
id: ar.id,
name: ar.name,
nameSort: ar.nameSort ?? undefined,
albumCount: ar.albumCount ?? undefined,
coverArt: ar.id,
};
return { ...base, ...(raw as Partial<SubsonicArtist>) };
const merged = mergeArtistRawJson(base, raw as Partial<SubsonicArtist>);
return merged;
}
/** Hot columns from SQLite win over sparse `raw_json` (ADR-7). */
function mergeArtistRawJson(base: SubsonicArtist, raw: Partial<SubsonicArtist>): SubsonicArtist {
return { ...raw, ...base };
}
/**