mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 A–Z 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('#');
|
||||
|
||||
@@ -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, A–Z, 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 (0–9)
|
||||
* - `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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user