From 47e16ebfef07ae3c951ca6c95ea96d8f4201baed Mon Sep 17 00:00:00 2001
From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com>
Date: Thu, 4 Jun 2026 03:13:45 +0200
Subject: [PATCH] fix: RC3 queue link underline, genre album artist split,
Artists "Other" bucket (#977)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix: RC3 queue link underline, genre album artist split, Artists "Other" bucket
Three zunoz reports, one change:
- Queue now-playing card: the artist and album links now underline on hover
(not just recolour), matching clickable names everywhere else. The album
link sits on .queue-current-sub itself; artist links are nested .is-link
spans — both selectors covered.
- Genre album cards split multi-artist credits into individual links like the
rest of the app. Root cause was the local-index genre query hardcoding NULL
for the album raw_json column, so OpenSubsonic artists[] never reached the
card and it fell back to the flat "A • B" single link. Select a.raw_json
instead (parity with the All Albums / advanced-search album queries).
- Artists page alphabet index: # is now digits-only, and a new "Other" bucket
collects accented Latin (Æ/Ø/Å…) and non-Latin scripts (CJK, Cyrillic, …)
that previously fell into the # catch-all. Adds artistBucketKey/compareBuckets
helpers (unit-tested), an OTHER bucket sorted last, and the artists.other
label across 9 locales.
* docs(changelog): queue link hover, genre card artist split, Artists Other bucket (#977)
---
CHANGELOG.md | 8 ++++
.../src/genre_album_browse.rs | 2 +-
src/components/artists/ArtistsListView.tsx | 6 +--
src/hooks/useArtistsFiltering.ts | 14 ++----
src/locales/de/artists.ts | 1 +
src/locales/en/artists.ts | 1 +
src/locales/es/artists.ts | 1 +
src/locales/fr/artists.ts | 1 +
src/locales/nb/artists.ts | 1 +
src/locales/nl/artists.ts | 1 +
src/locales/ro/artists.ts | 1 +
src/locales/ru/artists.ts | 1 +
src/locales/zh/artists.ts | 1 +
src/pages/Artists.tsx | 3 +-
src/styles/layout/queue-panel.css | 10 +++-
.../componentHelpers/artistsHelpers.test.ts | 48 +++++++++++++++++++
src/utils/componentHelpers/artistsHelpers.ts | 27 ++++++++++-
17 files changed, 109 insertions(+), 18 deletions(-)
create mode 100644 src/utils/componentHelpers/artistsHelpers.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f417bcad..68ec52cf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -864,6 +864,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* The track-list column header keeps its background on hover instead of turning transparent and letting rows show through.
* **Track of the moment** and the browse rows split multi-artist tracks into individually clickable artist links, matching the album track list.
+### Queue, Genre cards, and the Artists index
+
+**By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#977](https://github.com/Psychotoxical/psysonic/pull/977)**
+
+* The artist and album in the Queue's now-playing card now underline on hover like every other clickable name, instead of only changing colour.
+* Album cards on a **Genre** page split multi-artist credits into individually clickable artist links, matching the rest of the app.
+* On the **Artists** page the `#` index button now holds only names that start with a number; accented and non-Latin names (Æ Ø Å, Chinese, Japanese, Cyrillic, …) move to a new **Other** section instead of the `#` catch-all.
+
## [1.46.0] - 2026-05-18
> **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages.
diff --git a/src-tauri/crates/psysonic-library/src/genre_album_browse.rs b/src-tauri/crates/psysonic-library/src/genre_album_browse.rs
index a77f20ec..7fc7c3e9 100644
--- a/src-tauri/crates/psysonic-library/src/genre_album_browse.rs
+++ b/src-tauri/crates/psysonic-library/src/genre_album_browse.rs
@@ -137,7 +137,7 @@ pub fn list_albums_by_genre(
COALESCE(a.cover_art_id, la.cover_art_id), \
COALESCE(a.starred_at, la.starred_at), \
COALESCE(a.synced_at, la.synced_at), \
- NULL \
+ a.raw_json \
FROM ( \
SELECT \
t.server_id, \
diff --git a/src/components/artists/ArtistsListView.tsx b/src/components/artists/ArtistsListView.tsx
index 0f4150e2..b80db2e5 100644
--- a/src/components/artists/ArtistsListView.tsx
+++ b/src/components/artists/ArtistsListView.tsx
@@ -3,7 +3,7 @@ import type { Virtualizer } from '@tanstack/react-virtual';
import type { TFunction } from 'i18next';
import type { SubsonicArtist } from '../../api/subsonicTypes';
import type { PlayerState } from '../../store/playerStoreTypes';
-import type { ArtistListFlatRow } from '../../utils/componentHelpers/artistsHelpers';
+import { OTHER_BUCKET, type ArtistListFlatRow } from '../../utils/componentHelpers/artistsHelpers';
import { ArtistRowAvatar } from './ArtistAvatars';
interface RowProps {
@@ -123,7 +123,7 @@ export function ArtistsListView({
<>
{letters.map(letter => (
-
{letter}
+
{letter === OTHER_BUCKET ? t('artists.other') : letter}
{groups[letter].map(artist => (
@@ -159,7 +159,7 @@ export function ArtistsListView({
transform: `translateY(${vi.start - artistListScrollMargin}px)`,
}}
>
-
{row.letter}
+
{row.letter === OTHER_BUCKET ? t('artists.other') : row.letter}
);
}
diff --git a/src/hooks/useArtistsFiltering.ts b/src/hooks/useArtistsFiltering.ts
index ecebce3c..64a5c174 100644
--- a/src/hooks/useArtistsFiltering.ts
+++ b/src/hooks/useArtistsFiltering.ts
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import type { SubsonicArtist } from '../api/subsonicTypes';
import { usePlayerStore } from '../store/playerStore';
-import { ALL_SENTINEL, type ArtistListFlatRow } from '../utils/componentHelpers/artistsHelpers';
+import { ALL_SENTINEL, artistBucketKey, compareBuckets, type ArtistListFlatRow } from '../utils/componentHelpers/artistsHelpers';
interface UseArtistsFilteringArgs {
artists: SubsonicArtist[];
@@ -48,12 +48,7 @@ export function useArtistsFiltering({
const filtered = useMemo(() => {
let out = artists;
if (letterFilter !== ALL_SENTINEL) {
- out = out.filter(a => {
- const first = a.name[0]?.toUpperCase() ?? '#';
- const isAlpha = /^[A-Z]$/.test(first);
- if (letterFilter === '#') return !isAlpha;
- return first === letterFilter;
- });
+ out = out.filter(a => artistBucketKey(a.name) === letterFilter);
}
if (filter) {
const needle = filter.toLowerCase();
@@ -72,12 +67,11 @@ export function useArtistsFiltering({
if (viewMode !== 'list') return { groups: {} as Record
, letters: [] as string[] };
const g: Record = {};
for (const a of visible) {
- const letter = a.name[0]?.toUpperCase() ?? '#';
- const key = /^[A-Z]$/.test(letter) ? letter : '#';
+ const key = artistBucketKey(a.name);
if (!g[key]) g[key] = [];
g[key].push(a);
}
- return { groups: g, letters: Object.keys(g).sort() };
+ return { groups: g, letters: Object.keys(g).sort(compareBuckets) };
}, [visible, viewMode]);
const artistListFlatRows = useMemo((): ArtistListFlatRow[] => {
diff --git a/src/locales/de/artists.ts b/src/locales/de/artists.ts
index d364f406..dfb3605d 100644
--- a/src/locales/de/artists.ts
+++ b/src/locales/de/artists.ts
@@ -2,6 +2,7 @@ export const artists = {
title: 'Künstler',
search: 'Suchen…',
all: 'Alle',
+ other: 'Andere',
gridView: 'Gitteransicht',
listView: 'Listenansicht',
imagesOn: 'Künstlerbilder aktiv — kann Netzwerk- und Systemlast erhöhen',
diff --git a/src/locales/en/artists.ts b/src/locales/en/artists.ts
index e5af1574..089e35a3 100644
--- a/src/locales/en/artists.ts
+++ b/src/locales/en/artists.ts
@@ -2,6 +2,7 @@ export const artists = {
title: 'Artists',
search: 'Search…',
all: 'All',
+ other: 'Other',
gridView: 'Grid view',
listView: 'List view',
imagesOn: 'Artist images on — may increase network and system load',
diff --git a/src/locales/es/artists.ts b/src/locales/es/artists.ts
index a7b78747..27d2d131 100644
--- a/src/locales/es/artists.ts
+++ b/src/locales/es/artists.ts
@@ -2,6 +2,7 @@ export const artists = {
title: 'Artistas',
search: 'Buscar…',
all: 'Todos',
+ other: 'Otros',
gridView: 'Vista de cuadrícula',
listView: 'Vista de lista',
imagesOn: 'Imágenes de artistas activadas — puede aumentar la carga de red y sistema',
diff --git a/src/locales/fr/artists.ts b/src/locales/fr/artists.ts
index 847502af..10c7187d 100644
--- a/src/locales/fr/artists.ts
+++ b/src/locales/fr/artists.ts
@@ -2,6 +2,7 @@ export const artists = {
title: 'Artistes',
search: 'Rechercher…',
all: 'Tous',
+ other: 'Autres',
gridView: 'Vue en grille',
listView: 'Vue en liste',
imagesOn: 'Images d\'artistes activées — peut augmenter la charge réseau et système',
diff --git a/src/locales/nb/artists.ts b/src/locales/nb/artists.ts
index 5706f1d9..112a030c 100644
--- a/src/locales/nb/artists.ts
+++ b/src/locales/nb/artists.ts
@@ -2,6 +2,7 @@ export const artists = {
title: 'Artister',
search: 'Søk…',
all: 'Alle',
+ other: 'Andre',
gridView: 'Rutenettvisning',
listView: 'Listevisning',
imagesOn: 'Artistbilder på - kan øke belastningen på nettverket og applikasjonen',
diff --git a/src/locales/nl/artists.ts b/src/locales/nl/artists.ts
index c380ceae..a6ce006e 100644
--- a/src/locales/nl/artists.ts
+++ b/src/locales/nl/artists.ts
@@ -2,6 +2,7 @@ export const artists = {
title: 'Artiesten',
search: 'Zoeken…',
all: 'Alle',
+ other: 'Overige',
gridView: 'Rasterweergave',
listView: 'Lijstweergave',
imagesOn: 'Artiestafbeeldingen aan — kan netwerk- en systeembelasting verhogen',
diff --git a/src/locales/ro/artists.ts b/src/locales/ro/artists.ts
index d6b3259f..04c02969 100644
--- a/src/locales/ro/artists.ts
+++ b/src/locales/ro/artists.ts
@@ -2,6 +2,7 @@ export const artists = {
title: 'Artiști',
search: 'Caută…',
all: 'Tot',
+ other: 'Altele',
gridView: 'Vizualizare grilă',
listView: 'Vizualizare listă',
imagesOn: 'Imagini artist pornit — poate mări încărcarea rețelei și a sistemului',
diff --git a/src/locales/ru/artists.ts b/src/locales/ru/artists.ts
index a29d1bdb..55de6c98 100644
--- a/src/locales/ru/artists.ts
+++ b/src/locales/ru/artists.ts
@@ -2,6 +2,7 @@ export const artists = {
title: 'Исполнители',
search: 'Поиск…',
all: 'Все',
+ other: 'Другое',
gridView: 'Сетка',
listView: 'Список',
imagesOn: 'Фото исполнителей — больше трафика и нагрузка на систему',
diff --git a/src/locales/zh/artists.ts b/src/locales/zh/artists.ts
index a4f52f26..e223050c 100644
--- a/src/locales/zh/artists.ts
+++ b/src/locales/zh/artists.ts
@@ -2,6 +2,7 @@ export const artists = {
title: '艺术家',
search: '搜索…',
all: '全部',
+ other: '其他',
gridView: '网格视图',
listView: '列表视图',
imagesOn: '艺术家图片已开启 — 可能增加网络和系统负载',
diff --git a/src/pages/Artists.tsx b/src/pages/Artists.tsx
index 1de92979..39e1a26a 100644
--- a/src/pages/Artists.tsx
+++ b/src/pages/Artists.tsx
@@ -14,6 +14,7 @@ import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import {
ALL_SENTINEL,
ALPHABET,
+ OTHER_BUCKET,
ARTIST_LIST_LAST_IN_LETTER_EST,
ARTIST_LIST_LETTER_ROW_EST,
ARTIST_LIST_ROW_EST,
@@ -375,7 +376,7 @@ export default function Artists() {
onClick={() => setLetterFilter(l)}
className={`artists-alpha-btn${letterFilter === l ? ' artists-alpha-btn--active' : ''}`}
>
- {l === ALL_SENTINEL ? t('artists.all') : l}
+ {l === ALL_SENTINEL ? t('artists.all') : l === OTHER_BUCKET ? t('artists.other') : l}
))}
diff --git a/src/styles/layout/queue-panel.css b/src/styles/layout/queue-panel.css
index 1840a8a0..541c0305 100644
--- a/src/styles/layout/queue-panel.css
+++ b/src/styles/layout/queue-panel.css
@@ -224,11 +224,17 @@
transition: color var(--transition-fast);
}
-.queue-current-sub.is-link {
+/* Album link sits on the .queue-current-sub element itself; artist links are
+ .is-link spans nested inside it (OpenArtistRefInline). Both get the same
+ accent + underline affordance on hover as clickable names everywhere else. */
+.queue-current-sub.is-link,
+.queue-current-sub .is-link {
cursor: pointer;
}
-.queue-current-sub.is-link:hover {
+.queue-current-sub.is-link:hover,
+.queue-current-sub .is-link:hover {
color: var(--accent);
+ text-decoration: underline;
}
.queue-current-enrichment {
diff --git a/src/utils/componentHelpers/artistsHelpers.test.ts b/src/utils/componentHelpers/artistsHelpers.test.ts
new file mode 100644
index 00000000..37ca5a03
--- /dev/null
+++ b/src/utils/componentHelpers/artistsHelpers.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest';
+import { artistBucketKey, compareBuckets, OTHER_BUCKET, ALPHABET } from './artistsHelpers';
+
+describe('artistBucketKey', () => {
+ it('buckets A–Z names by their uppercased first letter', () => {
+ expect(artistBucketKey('Adele')).toBe('A');
+ expect(artistBucketKey('zz top')).toBe('Z');
+ expect(artistBucketKey('mGla')).toBe('M');
+ });
+
+ it('puts digit-leading names in #', () => {
+ expect(artistBucketKey('2Pac')).toBe('#');
+ expect(artistBucketKey('50 Cent')).toBe('#');
+ expect(artistBucketKey('999')).toBe('#');
+ });
+
+ it('puts accented Latin and non-Latin scripts in Other (not #)', () => {
+ expect(artistBucketKey('Ärzte')).toBe(OTHER_BUCKET);
+ expect(artistBucketKey('Øde')).toBe(OTHER_BUCKET);
+ expect(artistBucketKey('Å-band')).toBe(OTHER_BUCKET);
+ expect(artistBucketKey('이영지')).toBe(OTHER_BUCKET); // Korean
+ expect(artistBucketKey('くるり')).toBe(OTHER_BUCKET); // Japanese
+ expect(artistBucketKey('Кино')).toBe(OTHER_BUCKET); // Cyrillic
+ expect(artistBucketKey('王菲')).toBe(OTHER_BUCKET); // Chinese
+ });
+
+ it('puts symbol-leading and empty names in Other', () => {
+ expect(artistBucketKey('!!!')).toBe(OTHER_BUCKET);
+ expect(artistBucketKey(' ')).toBe(OTHER_BUCKET);
+ expect(artistBucketKey('')).toBe(OTHER_BUCKET);
+ });
+
+ it('ignores leading whitespace', () => {
+ expect(artistBucketKey(' Beatles')).toBe('B');
+ });
+});
+
+describe('compareBuckets', () => {
+ it('orders # first, then A–Z, then Other last', () => {
+ const shuffled = ['OTHER', 'M', '#', 'A', 'Z'];
+ expect([...shuffled].sort(compareBuckets)).toEqual(['#', 'A', 'M', 'Z', 'OTHER']);
+ });
+
+ it('ALPHABET ends with the Other bucket', () => {
+ expect(ALPHABET[ALPHABET.length - 1]).toBe(OTHER_BUCKET);
+ expect(ALPHABET).toContain('#');
+ });
+});
diff --git a/src/utils/componentHelpers/artistsHelpers.ts b/src/utils/componentHelpers/artistsHelpers.ts
index 8bd0bebe..113f162b 100644
--- a/src/utils/componentHelpers/artistsHelpers.ts
+++ b/src/utils/componentHelpers/artistsHelpers.ts
@@ -1,7 +1,32 @@
import type { SubsonicArtist } from '../../api/subsonicTypes';
export const ALL_SENTINEL = 'ALL';
-export const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
+/** Catch-all bucket for names that start with neither an A–Z letter nor a digit
+ * (accented Latin like Æ/Ø/Å, and non-Latin scripts: CJK, Cyrillic, …). */
+export const OTHER_BUCKET = 'OTHER';
+export const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''), OTHER_BUCKET];
+
+/** Stable ordering index for a bucket key — '#' first, A–Z, then 'Other' last. */
+const BUCKET_ORDER = new Map(ALPHABET.map((l, i) => [l, i]));
+
+/**
+ * Bucket an artist name into the alphabet index:
+ * - `#` → starts with a digit (0–9)
+ * - `A`–`Z` → starts with an ASCII letter
+ * - `OTHER` → anything else (accents, CJK, Cyrillic, symbols, empty)
+ */
+export function artistBucketKey(name: string): string {
+ const first = name?.trim()?.[0];
+ if (!first) return OTHER_BUCKET;
+ if (/^[0-9]$/.test(first)) return '#';
+ const up = first.toUpperCase();
+ return /^[A-Z]$/.test(up) ? up : OTHER_BUCKET;
+}
+
+/** Sort comparator for bucket keys following ALPHABET order (unknown keys last). */
+export function compareBuckets(a: string, b: string): number {
+ return (BUCKET_ORDER.get(a) ?? 999) - (BUCKET_ORDER.get(b) ?? 999);
+}
/** Virtual row height guesses — letter heading vs dense rows vs last row in section (group gap). */
export const ARTIST_LIST_LETTER_ROW_EST = 48;