diff --git a/src-tauri/crates/psysonic-library/src/artist_sort.rs b/src-tauri/crates/psysonic-library/src/artist_sort.rs index a94511d1..d6a7c2ed 100644 --- a/src-tauri/crates/psysonic-library/src/artist_sort.rs +++ b/src-tauri/crates/psysonic-library/src/artist_sort.rs @@ -9,10 +9,16 @@ pub fn strip_leading_articles(name: &str, ignored_articles: &str) -> String { for article in ignored_articles.split(' ').filter(|s| !s.is_empty()) { let prefix = format!("{} ", article); // `prefix` is ASCII; use `get` so we never slice inside a multibyte rune - // (e.g. "Elə…" must not panic when probing the "El " article). - let head = trimmed.get(0..prefix.len()); - if head.is_some_and(|h| h.eq_ignore_ascii_case(&prefix)) { - return trimmed[prefix.len()..].trim_start().to_string(); + // (e.g. probing "The " / "El " on CJK names must not panic). + let Some(head) = trimmed.get(..prefix.len()) else { + continue; + }; + if head.eq_ignore_ascii_case(&prefix) { + return trimmed + .get(prefix.len()..) + .map(str::trim_start) + .unwrap_or("") + .to_string(); } } trimmed.to_string() @@ -69,4 +75,13 @@ mod tests { let key = sort_key_for_display_name("Eləmir", DEFAULT_IGNORED_ARTICLES); assert_eq!(key, "eləmir"); } + + #[test] + fn does_not_panic_on_cjk_multi_artist_credit_string() { + // Discord report (Asra): sync panicked on FromSoftware OST composer list + // when probing the 4-byte "The " article prefix against 北村友香… + let name = "北村友香, 齋藤司, 桜庭統 & 鈴木伸嘉"; + let key = sort_key_for_display_name(name, DEFAULT_IGNORED_ARTICLES); + assert_eq!(key, name.to_lowercase()); + } } diff --git a/src-tauri/crates/psysonic-library/src/repos/artist.rs b/src-tauri/crates/psysonic-library/src/repos/artist.rs index 80794876..487a1f7e 100644 --- a/src-tauri/crates/psysonic-library/src/repos/artist.rs +++ b/src-tauri/crates/psysonic-library/src/repos/artist.rs @@ -195,4 +195,68 @@ mod tests { .unwrap(); assert_eq!(name_sort, "beatles"); } + + #[test] + fn backfill_from_tracks_accepts_cjk_artist_display_name() { + use crate::artist_sort::DEFAULT_IGNORED_ARTICLES; + use crate::repos::{TrackRepository, TrackRow}; + + let store = LibraryStore::open_in_memory(); + let cjk = "北村友香, 齋藤司, 桜庭統 & 鈴木伸嘉"; + let row = TrackRow { + server_id: "s1".into(), + id: "tr_1".into(), + title: "Song".into(), + title_sort: None, + artist: Some(cjk.into()), + artist_id: Some("ar_cjk".into()), + album: "al_1".into(), + album_id: Some("al_1".into()), + album_artist: None, + duration_sec: 200, + track_number: Some(1), + disc_number: Some(1), + year: None, + genre: None, + suffix: None, + bit_rate: None, + size_bytes: None, + cover_art_id: None, + starred_at: None, + user_rating: None, + play_count: None, + played_at: None, + server_path: None, + library_id: None, + isrc: None, + mbid_recording: None, + bpm: None, + replay_gain_track_db: None, + replay_gain_album_db: None, + content_hash: None, + server_updated_at: None, + server_created_at: None, + deleted: false, + synced_at: 1, + raw_json: "{}".into(), + }; + TrackRepository::new(&store).upsert_batch(&[row]).unwrap(); + + let repo = ArtistRepository::new(&store); + let n = repo + .backfill_from_tracks("s1", DEFAULT_IGNORED_ARTICLES, 2) + .unwrap(); + assert_eq!(n, 1); + + let name_sort: String = store + .with_conn("misc", |c| { + c.query_row( + "SELECT name_sort FROM artist WHERE server_id = 's1' AND id = 'ar_cjk'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(name_sort, cjk.to_lowercase()); + } }