diff --git a/CHANGELOG.md b/CHANGELOG.md index 173920c8..cc718730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Queue rows that were far from the currently playing track (e.g. after starting a large playlist from the middle, or scrolling the queue) no longer stay stuck on a "…" placeholder — the queue now loads track details for whatever you scroll to, in the desktop queue panel, the mobile queue drawer, and the fullscreen "up next" overlay. +### Artists browse — case-insensitive search for Cyrillic names + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1237](https://github.com/Psychotoxical/psysonic/pull/1237)** + +* Artist name search on the Artists page no longer depends on query letter case for Cyrillic (and other non-ASCII) names when the local library index is enabled — matching uses the indexed `name_sort` key with Unicode case folding instead of SQLite `LIKE` on the display name alone. + ## [1.49.0] - 2026-06-29 diff --git a/src-tauri/crates/psysonic-library/src/advanced_search.rs b/src-tauri/crates/psysonic-library/src/advanced_search.rs index c050addb..35c99915 100644 --- a/src-tauri/crates/psysonic-library/src/advanced_search.rs +++ b/src-tauri/crates/psysonic-library/src/advanced_search.rs @@ -24,7 +24,8 @@ use crate::repos; use crate::search::{ aliased_track_columns, aliased_track_columns_resolved_bpm, bpm_resolved_expr, fts_album_prefix_match_query, fts_album_title_prefix_match_query, fts_column_prefix_query, fts_query_meets_min_len, - fts_track_prefix_match_query, library_scope_equals_sql, like_contains, PAGE_LIMIT_MAX, + fts_track_prefix_match_query, library_scope_equals_sql, like_contains, like_contains_folded, + PAGE_LIMIT_MAX, }; use crate::store::LibraryStore; @@ -561,7 +562,12 @@ fn build_artist_from_table( push_artist_letter_bucket(&mut w, bucket, applied); } if let Some(t) = text { - w.push_param("ar.name LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t))); + // Match `name_sort` (Unicode lowercase from sync) so Cyrillic and other + // non-ASCII names are case-insensitive; COALESCE covers pre-014 rows. + w.push_param( + "COALESCE(ar.name_sort, ar.name) LIKE ? ESCAPE '\\'", + SqlValue::Text(like_contains_folded(t)), + ); applied.insert("text".to_string()); } for c in scalar { @@ -610,7 +616,10 @@ fn build_artist_from_tracks( w.push_param(&clause, SqlValue::Text(scope)); } if let Some(t) = text { - w.push_param("t.artist LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t))); + w.push_param( + "t.artist LIKE ? ESCAPE '\\'", + SqlValue::Text(like_contains_folded(t)), + ); applied.insert("text".to_string()); } for c in scalar { @@ -1606,6 +1615,36 @@ mod tests { assert_eq!(resp.artists[0].id, "ar1"); } + #[test] + fn artist_text_query_is_case_insensitive_for_cyrillic_name_sort() { + let store = LibraryStore::open_in_memory(); + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO artist (server_id, id, name, name_sort, album_count, synced_at, raw_json) \ + VALUES (?1, ?2, ?3, ?4, ?5, 1, '{}')", + rusqlite::params!["s1", "ar_kino", "Кино", "кино", 3_i64], + ) + }) + .unwrap(); + let mut r = req("s1", &[EntityKind::Artist]); + r.query = Some("КИН".into()); + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.artists.len(), 1); + assert_eq!(resp.artists[0].id, "ar_kino"); + } + + #[test] + fn artist_text_query_is_case_insensitive_for_latin_display_name() { + let store = LibraryStore::open_in_memory(); + insert_artist(&store, "s1", "ar1", "Metallica"); + let mut r = req("s1", &[EntityKind::Artist]); + r.query = Some("METAL".into()); + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.artists.len(), 1); + assert_eq!(resp.artists[0].id, "ar1"); + } + #[test] fn text_query_derives_album_and_artist_from_tracks_when_tables_empty() { let store = LibraryStore::open_in_memory(); diff --git a/src-tauri/crates/psysonic-library/src/search.rs b/src-tauri/crates/psysonic-library/src/search.rs index 4dc7f903..64d8314f 100644 --- a/src-tauri/crates/psysonic-library/src/search.rs +++ b/src-tauri/crates/psysonic-library/src/search.rs @@ -320,6 +320,13 @@ pub(crate) fn like_contains(raw: &str) -> String { format!("%{escaped}%") } +/// `like_contains` with Unicode case folding on the needle. Use when matching +/// against `name_sort` (already lowercase) or when SQLite `LIKE` would treat +/// non-ASCII letters as case-sensitive. +pub(crate) fn like_contains_folded(raw: &str) -> String { + like_contains(&raw.to_lowercase()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 53b916cb..7472d0d6 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -183,6 +183,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Connection — ignore spurious WebKitGTK navigator.onLine offline hint on desktop; confirm via server probe; flush pending favorite/rating sync on real reachability (report: mikmik on Psysonic Discord, PR #1234)', 'Playlists — batch playlist writes past the GET URL limit (add >341 tracks), in-memory membership cache for fast dedup, offline↔playlist layering detangle (PR #1235)', 'Queue — resolve thin-state rows off the visible range so off-window items stop rendering as "…" placeholders (desktop panel, mobile drawer, fullscreen up-next) (PR #1236)', + 'Artists browse — case-insensitive Cyrillic/non-ASCII name search when local index is enabled (PR #1237)', ], }, { diff --git a/src/lib/library/browseTextSearch.test.ts b/src/lib/library/browseTextSearch.test.ts index 26cdbe52..08759782 100644 --- a/src/lib/library/browseTextSearch.test.ts +++ b/src/lib/library/browseTextSearch.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { browseRaceCountsArtists, raceBrowseWithLocalFallback } from './browseTextSearch'; +import { browseRaceCountsArtists, filterBrowseArtistsByNameQuery, raceBrowseWithLocalFallback } from './browseTextSearch'; + +describe('filterBrowseArtistsByNameQuery', () => { + it('matches Cyrillic names regardless of query case', () => { + const artists = [{ id: '1', name: 'Кино' }]; + expect(filterBrowseArtistsByNameQuery(artists, 'кин')).toHaveLength(1); + expect(filterBrowseArtistsByNameQuery(artists, 'КИН')).toHaveLength(1); + }); +}); describe('raceBrowseWithLocalFallback', () => { it('returns local when network throws and local has data', async () => { diff --git a/src/lib/library/browseTextSearch.ts b/src/lib/library/browseTextSearch.ts index 5c68b366..1bd0978a 100644 --- a/src/lib/library/browseTextSearch.ts +++ b/src/lib/library/browseTextSearch.ts @@ -179,6 +179,13 @@ export function browseRaceCountsFullSearch(result: unknown): LibrarySearchDebugE const ARTIST_BROWSE_LIMIT = 500; const ALBUM_BROWSE_LIMIT = 500; +/** Case-insensitive substring match on artist display names (Unicode). */ +export function filterBrowseArtistsByNameQuery(artists: SubsonicArtist[], query: string): SubsonicArtist[] { + const needle = query.trim().toLowerCase(); + if (!needle) return artists; + return artists.filter(a => (a.name ?? '').toLowerCase().includes(needle)); +} + const emptyBrowseOpts = (query: string, artistCreditMode?: ArtistCreditMode): LocalSearchOpts => ({ query, genre: '', @@ -242,7 +249,7 @@ export async function runLocalBrowseArtists( true, ); if (!page) return null; - return page.artists; + return filterBrowseArtistsByNameQuery(page.artists, query); } /** Network search3 artist slice for browse pages. */ @@ -255,9 +262,9 @@ export async function runNetworkBrowseArtists( if (!q) return null; try { const r = await search(q, { artistCount: limit, albumCount: 0, songCount: 0 }); - if (creditMode === 'track') return r.artists; const indexIds = new Set((await getArtists()).map(a => a.id)); - return r.artists.filter(a => indexIds.has(a.id)); + const raw = creditMode === 'track' ? r.artists : r.artists.filter(a => indexIds.has(a.id)); + return filterBrowseArtistsByNameQuery(raw, q); } catch { return null; }