mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
fix(artists): case-insensitive browse search for Cyrillic names (#1237)
This commit is contained in:
@@ -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::*;
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user