From dc35f536742cfbe37aad341c3ede1f638d8ae5f5 Mon Sep 17 00:00:00 2001 From: Sayykii Date: Wed, 6 May 2026 15:47:05 +0300 Subject: [PATCH] feat(artist): group albums by release type on artist page (#471) * feat(artist): group albums by release type on artist page Uses the releaseType field to group albums/releases into sections like Albums, Compilation, Live, etc. If there's no release type it falls back to normal view * feat(artist): i18n release-type group labels * fix(artist): deterministic release-type group order * refactor(artist): replace inline styles with CSS classes * i18n(artist): translate release-type labels in remaining 7 locales Sayykii's `releaseTypes` namespace was added to en.ts only. Fills in de, fr, nl, zh, nb, ru, es with the same 8 keys (album, ep, single, compilation, live, soundtrack, remix, other) so users on non-English UIs see translated section headers on the artist page instead of the raw title-cased fallback. * docs: changelog + contributors for PR #471 Adds the v1.46.0 "Added" entry and bumps Sayykii's contributors line for the artist-page release-type grouping. --------- Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> --- CHANGELOG.md | 8 +++++++ src/api/subsonic.ts | 2 ++ src/locales/de.ts | 10 ++++++++ src/locales/en.ts | 10 ++++++++ src/locales/es.ts | 10 ++++++++ src/locales/fr.ts | 10 ++++++++ src/locales/nb.ts | 10 ++++++++ src/locales/nl.ts | 10 ++++++++ src/locales/ru.ts | 10 ++++++++ src/locales/zh.ts | 10 ++++++++ src/pages/ArtistDetail.tsx | 49 +++++++++++++++++++++++++++++++++++--- src/pages/Settings.tsx | 1 + src/styles/components.css | 25 +++++++++++++++++++ 13 files changed, 162 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efc100b5..42d7309f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **Live search** and the **mobile search overlay** now load **artist images** for rows in the **Artists** section via the same **`getCoverArt` / image-cache path** as album art (**`coverArt`** when present, otherwise the **artist id** where the server supports it), with a **fallback icon** when art is missing or fails. * **Mobile** artist hits use a **round** thumbnail next to **square** album art so the two result types read clearly at a glance. +### Artist page — group albums by release type + +**By [@Sayykii](https://github.com/Sayykii), PR [#471](https://github.com/Psychotoxical/psysonic/pull/471)** + +* Albums on the artist page can now be **grouped into sections** by their OpenSubsonic `releaseTypes` (Album, EP, Single, Compilation, Live, Soundtrack, Remix). Section order is deterministic across languages, with unknown types appended at the end. +* Falls back to the previous flat list when the server doesn't return `releaseTypes` or all albums share the default Album type — no behaviour change for non-OpenSubsonic servers. +* Section headers are localised in all 8 supported languages. + ## Changed ### Dependencies — npm / Cargo refresh and rodio 0.22 diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index ae5d1b58..0701cff0 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -75,6 +75,8 @@ export interface SubsonicAlbum { userRating?: number; /** OpenSubsonic: true when the album is tagged as a compilation. */ isCompilation?: boolean; + /** OpenSubsonic: release types from MusicBrainz tags (e.g. "Album", "EP", "Single", "Compilation", "Live"). */ + releaseTypes?: string[]; } /** OpenSubsonic `artists` / `albumArtists` entries on a child song (may include `userRating`). */ diff --git a/src/locales/de.ts b/src/locales/de.ts index 0accdedb..80a1b401 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -264,6 +264,16 @@ export const deTranslation = { offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)', uploadImage: 'Künstlerbild hochladen', uploadImageError: 'Bild konnte nicht hochgeladen werden', + releaseTypes: { + album: 'Album', + ep: 'EP', + single: 'Single', + compilation: 'Kompilation', + live: 'Live', + soundtrack: 'Soundtrack', + remix: 'Remix', + other: 'Sonstige', + }, }, favorites: { title: 'Favoriten', diff --git a/src/locales/en.ts b/src/locales/en.ts index 92d80297..ac40bb0a 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -266,6 +266,16 @@ export const enTranslation = { offlineDownloading: 'Caching… ({{done}}/{{total}} albums)', uploadImage: 'Upload artist image', uploadImageError: 'Failed to upload image', + releaseTypes: { + album: 'Album', + ep: 'EP', + single: 'Single', + compilation: 'Compilation', + live: 'Live', + soundtrack: 'Soundtrack', + remix: 'Remix', + other: 'Other', + }, }, favorites: { title: 'Favorites', diff --git a/src/locales/es.ts b/src/locales/es.ts index c6d8b2c8..717f54f7 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -265,6 +265,16 @@ export const esTranslation = { offlineDownloading: 'Descargando… ({{done}}/{{total}} álbumes)', uploadImage: 'Subir imagen del artista', uploadImageError: 'Error al subir imagen', + releaseTypes: { + album: 'Álbum', + ep: 'EP', + single: 'Single', + compilation: 'Recopilación', + live: 'En vivo', + soundtrack: 'Banda sonora', + remix: 'Remix', + other: 'Otro', + }, }, favorites: { title: 'Favoritos', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index b5d954d9..2e5d741b 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -264,6 +264,16 @@ export const frTranslation = { offlineDownloading: 'En cache… ({{done}}/{{total}} albums)', uploadImage: "Téléverser l'image de l'artiste", uploadImageError: "Échec du téléversement de l'image", + releaseTypes: { + album: 'Album', + ep: 'EP', + single: 'Single', + compilation: 'Compilation', + live: 'Live', + soundtrack: 'Bande originale', + remix: 'Remix', + other: 'Autre', + }, }, favorites: { title: 'Favoris', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index a29f184f..23af747c 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -264,6 +264,16 @@ export const nbTranslation = { offlineDownloading: 'Bufrer… ({{done}}/{{total}} album)', uploadImage: 'Last opp artistbilde', uploadImageError: 'Kunne ikke laste opp bildet', + releaseTypes: { + album: 'Album', + ep: 'EP', + single: 'Single', + compilation: 'Samling', + live: 'Live', + soundtrack: 'Filmmusikk', + remix: 'Remiks', + other: 'Annet', + }, }, favorites: { title: 'Favoritter', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 386b6989..abd80988 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -263,6 +263,16 @@ export const nlTranslation = { offlineDownloading: 'Cachen… ({{done}}/{{total}} albums)', uploadImage: 'Artiestafbeelding uploaden', uploadImageError: 'Uploaden van afbeelding mislukt', + releaseTypes: { + album: 'Album', + ep: 'EP', + single: 'Single', + compilation: 'Compilatie', + live: 'Live', + soundtrack: 'Soundtrack', + remix: 'Remix', + other: 'Overig', + }, }, favorites: { title: 'Favorieten', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index b1eeffe9..b71e8b26 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -277,6 +277,16 @@ export const ruTranslation = { offlineDownloading: 'Кэширование… ({{done}} из {{total}} альбомов)', uploadImage: 'Загрузить фото исполнителя', uploadImageError: 'Не удалось загрузить изображение', + releaseTypes: { + album: 'Альбом', + ep: 'EP', + single: 'Сингл', + compilation: 'Сборник', + live: 'Концерт', + soundtrack: 'Саундтрек', + remix: 'Ремикс', + other: 'Другое', + }, }, favorites: { title: 'Избранное', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index fe4dd704..601987a2 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -262,6 +262,16 @@ export const zhTranslation = { offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)', uploadImage: '上传艺术家图片', uploadImageError: '图片上传失败', + releaseTypes: { + album: '专辑', + ep: 'EP', + single: '单曲', + compilation: '合辑', + live: '现场', + soundtrack: '原声带', + remix: '混音', + other: '其他', + }, }, favorites: { title: '收藏夹', diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index de8ba97a..828848a9 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -459,6 +459,37 @@ export default function ArtistDetail() { [coverId], ); + const groupedAlbums = useMemo(() => { + if (albums.length === 0) return []; + const RELEASE_TYPE_ORDER = ['album', 'ep', 'single', 'compilation', 'live', 'soundtrack', 'remix', 'other']; + const defaultKey = 'album'; + const titleCase = (s: string) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(); + const translateType = (tag: string) => + t(`artistDetail.releaseTypes.${tag}`, { defaultValue: titleCase(tag) }); + + const groups = new Map(); + for (const album of albums) { + const key = album.releaseTypes?.length + ? album.releaseTypes.map(r => r.toLowerCase()).join(' · ') + : defaultKey; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(album); + } + + if (groups.size === 1 && groups.has(defaultKey)) { + return [[translateType(defaultKey), albums] as const]; + } + + const sortKey = (key: string) => { + const idx = RELEASE_TYPE_ORDER.indexOf(key.split(' · ')[0]); + return idx >= 0 ? idx : RELEASE_TYPE_ORDER.length; + }; + + return [...groups.entries()] + .sort((a, b) => sortKey(a[0]) - sortKey(b[0]) || a[0].localeCompare(b[0])) + .map(([key, group]) => [key.split(' · ').map(translateType).join(' · '), group] as const); + }, [albums, t]); + useEffect(() => { setHeaderCoverFailed(false); }, [coverId, coverRevision, id]); @@ -858,9 +889,21 @@ export default function ArtistDetail() { {t('artistDetail.albumsBy', { name: artist.name })} {albums.length > 0 ? ( -
- {albums.map((a, i) => )} -
+ groupedAlbums.length === 1 ? ( +
+ {albums.map((a, i) => )} +
+ ) : groupedAlbums.map(([label, group]) => ( +
+
+

{label}

+ {group.length} +
+
+ {group.map((a, i) => )} +
+
+ )) ) : (

{t('artistDetail.noAlbums')}

)} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 49c7cd08..52a273a7 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -276,6 +276,7 @@ const CONTRIBUTORS = [ since: '1.46.0', contributions: [ 'Discord Rich Presence: cover art from your own server (Subsonic getAlbumInfo2) with three-way picker — none / server / Apple Music (PR #462)', + 'Artist page: group albums by OpenSubsonic releaseTypes (Album / EP / Single / Compilation / Live / Soundtrack / Remix) with deterministic order and i18n section headers in all 8 locales (PR #471)', ], }, { diff --git a/src/styles/components.css b/src/styles/components.css index d342f398..e9993512 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -8598,6 +8598,31 @@ html.no-compositing .fs-seekbar-played { justify-content: center; } +.artist-release-group { + margin-bottom: 1.5rem; +} + +.artist-release-group__header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 0.75rem; +} + +.artist-release-group__header h3 { + margin: 0; + font-size: 15px; + font-weight: 600; +} + +.artist-release-group__count { + font-size: 12px; + color: var(--text-muted); + background: var(--bg-card); + border-radius: 10px; + padding: 1px 8px; +} + .app-shell[data-mobile] .album-grid-wrap--artist { grid-template-columns: repeat(2, 1fr); }