From e8962c21ab5576b1aa1b2949545e8052d3929df3 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:56:54 +0300 Subject: [PATCH] fix(settings): improve in-page search matching and coverage (#968) * fix(settings): improve in-page search matching and coverage Index AudioMuse and individual shortcut rows, tighten fuzzy matching so junk queries return no hits, and scroll to the parent subsection when a shortcut result is selected. * docs: note PR #968 in changelog and settings credits --- CHANGELOG.md | 9 ++++ src/components/settings/ServersTab.tsx | 1 + .../settings/settingsSearch.test.ts | 47 ++++++++++++++++ src/components/settings/settingsSearch.ts | 54 +++++++++++++++++++ src/components/settings/settingsTabs.ts | 16 ++++-- src/config/settingsCredits.ts | 1 + src/pages/Settings.tsx | 29 +++------- 7 files changed, 131 insertions(+), 26 deletions(-) create mode 100644 src/components/settings/settingsSearch.test.ts create mode 100644 src/components/settings/settingsSearch.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ad935963..ae901880 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -482,6 +482,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Fractional custom minutes (e.g. `0.1`, `0.01`) now share the same delay math between the modal preview, armed timer, and play-button countdown so the displayed remaining time matches when playback starts or pauses. +### Settings — in-page search coverage and junk-query filtering + +**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#968](https://github.com/Psychotoxical/psysonic/pull/968)** + +* AudioMuse-AI (Navidrome) is indexed and selectable from settings search; choosing it opens Servers and scrolls to the plugin toggle when shown. +* In-app and global shortcut labels (e.g. Volume up / Volume down) appear as search hits and focus the parent shortcuts subsection. +* Nonsense queries no longer return unrelated fuzzy matches (e.g. long repeated letters). + + ### In-page browse — virtual scroll and cover-art priority **By [@cucadmuh](https://github.com/cucadmuh), PR [#783](https://github.com/Psychotoxical/psysonic/pull/783)** diff --git a/src/components/settings/ServersTab.tsx b/src/components/settings/ServersTab.tsx index 042f0943..425b1288 100644 --- a/src/components/settings/ServersTab.tsx +++ b/src/components/settings/ServersTab.tsx @@ -481,6 +481,7 @@ export function ServersTab({ ) && (
diff --git a/src/components/settings/settingsSearch.test.ts b/src/components/settings/settingsSearch.test.ts new file mode 100644 index 00000000..905a88d9 --- /dev/null +++ b/src/components/settings/settingsSearch.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; +import { matchScore } from './settingsTabs'; +import { searchSettings } from './settingsSearch'; + +const t = vi.fn((key: string) => { + const labels: Record = { + 'settings.audiomuseTitle': 'AudioMuse-AI (Navidrome)', + 'settings.servers': 'Servers', + 'settings.inputKeybindingsTitle': 'In-app shortcuts', + 'settings.globalShortcutsTitle': 'Global shortcuts', + 'settings.shortcutVolumeUp': 'Volume up', + 'settings.shortcutVolumeDown': 'Volume down', + 'settings.playbackTitle': 'Playback', + }; + return labels[key] ?? key; +}); + +describe('matchScore', () => { + it('prefers earlier substring matches', () => { + expect(matchScore('volume up global shortcuts', 'volume')).toBeGreaterThan(0); + }); + + it('rejects repeated-character junk queries via fuzzy matching', () => { + expect(matchScore('database backup analytics strategy', 'aaaaaaa')).toBe(0); + }); + + it('still fuzzy-matches compact typos', () => { + expect(matchScore('equalizer eq bass treble', 'equl')).toBeGreaterThan(0); + }); +}); + +describe('searchSettings', () => { + it('finds AudioMuse by product name', () => { + const hits = searchSettings('AudioMuse', 'library', t as any); + expect(hits.some(h => h.title.includes('AudioMuse'))).toBe(true); + }); + + it('finds global volume shortcuts', () => { + const hits = searchSettings('Volume', 'library', t as any); + expect(hits.some(h => h.title === 'Volume up')).toBe(true); + expect(hits.some(h => h.title === 'Volume down')).toBe(true); + }); + + it('returns nothing for nonsense queries', () => { + expect(searchSettings('aaaaaaa', 'library', t as any)).toEqual([]); + }); +}); diff --git a/src/components/settings/settingsSearch.ts b/src/components/settings/settingsSearch.ts new file mode 100644 index 00000000..274c115b --- /dev/null +++ b/src/components/settings/settingsSearch.ts @@ -0,0 +1,54 @@ +import type { TFunction } from 'i18next'; +import { GLOBAL_SHORTCUT_ACTIONS, IN_APP_SHORTCUT_ACTIONS } from '../../config/shortcutActions'; +import { SETTINGS_INDEX, matchScore, type Tab } from './settingsTabs'; + +export type SettingsSearchHit = { + tab: Tab; + title: string; + focusTitle: string; + score: number; + key: string; +}; + +export function searchSettings(query: string, activeTab: Tab, t: TFunction): SettingsSearchHit[] { + const q = query.trim(); + if (!q) return []; + + const hits: SettingsSearchHit[] = []; + + for (const entry of SETTINGS_INDEX) { + const title = t(entry.titleKey as any); + const hay = entry.keywords ? `${title} ${entry.keywords}` : title; + const score = matchScore(hay, q); + if (score <= 0) continue; + const focusTitle = entry.focusTitleKey ? t(entry.focusTitleKey as any) : title; + hits.push({ tab: entry.tab, title, focusTitle, score, key: entry.titleKey }); + } + + const inAppSection = t('settings.inputKeybindingsTitle'); + for (const { id, getLabel } of IN_APP_SHORTCUT_ACTIONS) { + const title = getLabel(t); + const hay = `${title} ${inAppSection} shortcut hotkey keyboard in-app ${id.replace(/-/g, ' ')}`; + const score = matchScore(hay, q); + if (score <= 0) continue; + hits.push({ tab: 'input', title, focusTitle: inAppSection, score, key: `in-app:${id}` }); + } + + const globalSection = t('settings.globalShortcutsTitle'); + for (const { id, getLabel } of GLOBAL_SHORTCUT_ACTIONS) { + const title = getLabel(t); + const hay = `${title} ${globalSection} shortcut hotkey global media ${id.replace(/-/g, ' ')}`; + const score = matchScore(hay, q); + if (score <= 0) continue; + hits.push({ tab: 'input', title, focusTitle: globalSection, score, key: `global:${id}` }); + } + + hits.sort((a, b) => { + const aCurrent = a.tab === activeTab ? 1 : 0; + const bCurrent = b.tab === activeTab ? 1 : 0; + if (aCurrent !== bCurrent) return bCurrent - aCurrent; + return b.score - a.score; + }); + + return hits; +} diff --git a/src/components/settings/settingsTabs.ts b/src/components/settings/settingsTabs.ts index ddcf8298..92ec14eb 100644 --- a/src/components/settings/settingsTabs.ts +++ b/src/components/settings/settingsTabs.ts @@ -29,7 +29,7 @@ export function resolveTab(input: string | undefined | null): Tab { // Statischer Suchindex ueber alle Sub-Sections aller Tabs. Mitpflegen, wenn eine // neue SettingsSubSection hinzukommt — sonst taucht sie nicht in der Suche auf. -export type SearchIndexEntry = { tab: Tab; titleKey: string; keywords?: string }; +export type SearchIndexEntry = { tab: Tab; titleKey: string; keywords?: string; focusTitleKey?: string }; export const SETTINGS_INDEX: SearchIndexEntry[] = [ { tab: 'audio', titleKey: 'settings.audioOutputDevice', keywords: 'output device speakers headphones alsa wasapi coreaudio' }, @@ -51,6 +51,7 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [ { tab: 'personalisation',titleKey: 'settings.playerBarTitle', keywords: 'player bar playback favorites stars rating lastfm love equalizer mini player controls hide show' }, { tab: 'appearance', titleKey: 'settings.libraryGridMaxColumnsTitle', keywords: 'grid columns album artist playlist cards layout appearance performance scroll paint' }, { tab: 'servers', titleKey: 'settings.servers', keywords: 'local library index sync resync verify integrity offline delta background sqlite search' }, + { tab: 'servers', titleKey: 'settings.audiomuseTitle', keywords: 'audiomuse audio muse navidrome plugin instant mix similar songs lucky mix' }, { tab: 'library', titleKey: 'settings.analyticsStrategyTitle', keywords: 'analytics strategy analysis bpm enrichment waveform lazy advanced library backfill' }, { tab: 'library', titleKey: 'settings.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' }, { tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' }, @@ -76,19 +77,26 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [ { tab: 'system', titleKey: 'licenses.title', keywords: 'licenses license open source attribution copyright third party dependencies oss' }, ]; -// Substring-first, Fuzzy-Fallback (alle Query-Zeichen in Reihenfolge im -// Haystack). Rueckgabe 0 = kein Match. Hoeher = besser. +// Substring-first, compact fuzzy fallback (query chars in order within a +// short span). Returns 0 = no match. Higher = better. export function matchScore(haystack: string, needle: string): number { if (!needle) return 0; const h = haystack.toLowerCase(); const n = needle.toLowerCase(); const idx = h.indexOf(n); if (idx >= 0) return 1000 - Math.min(999, idx); + // Repeated single-char queries ("aaaaaaa") must not match via sparse fuzzy hits. + if (n.length >= 4 && /^(.)\1+$/.test(n)) return 0; let hi = 0; + let start = -1; for (const ch of n) { const j = h.indexOf(ch, hi); if (j < 0) return 0; + if (start < 0) start = j; hi = j + 1; } - return Math.max(1, 100 - Math.min(99, hi - n.length)); + const span = hi - start; + if (span > n.length * 2) return 0; + if (n.length >= 4 && span > n.length + 3) return 0; + return Math.max(1, 100 - Math.min(99, span - n.length)); } diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 5d9249c1..ea97939a 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -153,6 +153,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Cover backfill: follow the smart local/public endpoint switch so off-LAN clients stop fetching covers from the unreachable local address (PR #952)', 'Player: persist volume/repeat/queue visibility/Last.fm cache outside quota-bound queue blob (report: norp on Psysonic Discord) (PR #958)', 'Player transport: custom delay input capped to browser timer limit; preview/countdown aligned for fractional minutes (report: zunoz on Psysonic Discord) (PR #967)', + 'Settings: in-page search indexes AudioMuse + shortcut rows; rejects junk fuzzy matches (report: zunoz on Psysonic Discord) (PR #968)', ], }, { diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 1982a59c..ceff30d5 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -16,7 +16,8 @@ import { PersonalisationTab } from '../components/settings/PersonalisationTab'; import { ServersTab } from '../components/settings/ServersTab'; import { StorageTab } from '../components/settings/StorageTab'; import { SystemTab } from '../components/settings/SystemTab'; -import { SETTINGS_INDEX, type Tab, matchScore, resolveTab } from '../components/settings/settingsTabs'; +import { searchSettings, type SettingsSearchHit } from '../components/settings/settingsSearch'; +import { type Tab, resolveTab } from '../components/settings/settingsTabs'; import { UserManagementSection } from '../components/settings/UserManagementSection'; import { ndLogin } from '../api/navidromeAdmin'; import { type ServerMagicPayload } from '../utils/server/serverMagicString'; @@ -32,7 +33,7 @@ export default function Settings() { const [activeTab, setActiveTab] = useState(resolveTab((routeState as { tab?: string } | null)?.tab)); const [searchQuery, setSearchQuery] = useState(''); const [searchOpen, setSearchOpen] = useState(false); - const [searchResults, setSearchResults] = useState>([]); + const [searchResults, setSearchResults] = useState([]); const [selectedResultIdx, setSelectedResultIdx] = useState(0); const [pendingFocusTitle, setPendingFocusTitle] = useState(null); const [pendingServerInvite, setPendingServerInvite] = useState(null); @@ -61,23 +62,7 @@ export default function Settings() { // eine Query aktiv ist, wird der Tab-Content gerendert-nicht und stattdessen // die Ergebnisliste angezeigt. useEffect(() => { - const q = searchQuery.trim(); - if (!q) { - setSearchResults([]); - return; - } - const scored = SETTINGS_INDEX.map(entry => { - const title = t(entry.titleKey as any); - const hay = entry.keywords ? `${title} ${entry.keywords}` : title; - return { ...entry, title, score: matchScore(hay, q) }; - }).filter(e => e.score > 0); - scored.sort((a, b) => { - const aCurrent = a.tab === activeTab ? 1 : 0; - const bCurrent = b.tab === activeTab ? 1 : 0; - if (aCurrent !== bCurrent) return bCurrent - aCurrent; - return b.score - a.score; - }); - setSearchResults(scored); + setSearchResults(searchSettings(searchQuery, activeTab, t)); setSelectedResultIdx(0); }, [searchQuery, activeTab, t]); @@ -213,7 +198,7 @@ export default function Settings() { if (!hit) return; setSearchQuery(''); setSearchOpen(false); - setPendingFocusTitle(hit.title); + setPendingFocusTitle(hit.focusTitle); setActiveTab(hit.tab); } }} @@ -275,7 +260,7 @@ export default function Settings() { const tabLabelKey = TAB_LABEL_KEY[hit.tab]; const selected = idx === selectedResultIdx; return ( -
  • +