fix(artists): case-insensitive browse search for Cyrillic names (#1237)

This commit is contained in:
cucadmuh
2026-07-05 14:19:44 +03:00
committed by GitHub
parent db645ca72f
commit f48089e222
6 changed files with 75 additions and 7 deletions
+6
View File
@@ -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
@@ -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();
@@ -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::*;
+1
View File
@@ -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)',
],
},
{
+9 -1
View File
@@ -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 () => {
+10 -3
View File
@@ -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;
}