mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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>
This commit is contained in:
@@ -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.
|
* **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.
|
* **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
|
## Changed
|
||||||
|
|
||||||
### Dependencies — npm / Cargo refresh and rodio 0.22
|
### Dependencies — npm / Cargo refresh and rodio 0.22
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export interface SubsonicAlbum {
|
|||||||
userRating?: number;
|
userRating?: number;
|
||||||
/** OpenSubsonic: true when the album is tagged as a compilation. */
|
/** OpenSubsonic: true when the album is tagged as a compilation. */
|
||||||
isCompilation?: boolean;
|
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`). */
|
/** OpenSubsonic `artists` / `albumArtists` entries on a child song (may include `userRating`). */
|
||||||
|
|||||||
@@ -264,6 +264,16 @@ export const deTranslation = {
|
|||||||
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
|
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
|
||||||
uploadImage: 'Künstlerbild hochladen',
|
uploadImage: 'Künstlerbild hochladen',
|
||||||
uploadImageError: 'Bild konnte nicht hochgeladen werden',
|
uploadImageError: 'Bild konnte nicht hochgeladen werden',
|
||||||
|
releaseTypes: {
|
||||||
|
album: 'Album',
|
||||||
|
ep: 'EP',
|
||||||
|
single: 'Single',
|
||||||
|
compilation: 'Kompilation',
|
||||||
|
live: 'Live',
|
||||||
|
soundtrack: 'Soundtrack',
|
||||||
|
remix: 'Remix',
|
||||||
|
other: 'Sonstige',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
favorites: {
|
favorites: {
|
||||||
title: 'Favoriten',
|
title: 'Favoriten',
|
||||||
|
|||||||
@@ -266,6 +266,16 @@ export const enTranslation = {
|
|||||||
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
|
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
|
||||||
uploadImage: 'Upload artist image',
|
uploadImage: 'Upload artist image',
|
||||||
uploadImageError: 'Failed to upload image',
|
uploadImageError: 'Failed to upload image',
|
||||||
|
releaseTypes: {
|
||||||
|
album: 'Album',
|
||||||
|
ep: 'EP',
|
||||||
|
single: 'Single',
|
||||||
|
compilation: 'Compilation',
|
||||||
|
live: 'Live',
|
||||||
|
soundtrack: 'Soundtrack',
|
||||||
|
remix: 'Remix',
|
||||||
|
other: 'Other',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
favorites: {
|
favorites: {
|
||||||
title: 'Favorites',
|
title: 'Favorites',
|
||||||
|
|||||||
@@ -265,6 +265,16 @@ export const esTranslation = {
|
|||||||
offlineDownloading: 'Descargando… ({{done}}/{{total}} álbumes)',
|
offlineDownloading: 'Descargando… ({{done}}/{{total}} álbumes)',
|
||||||
uploadImage: 'Subir imagen del artista',
|
uploadImage: 'Subir imagen del artista',
|
||||||
uploadImageError: 'Error al subir imagen',
|
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: {
|
favorites: {
|
||||||
title: 'Favoritos',
|
title: 'Favoritos',
|
||||||
|
|||||||
@@ -264,6 +264,16 @@ export const frTranslation = {
|
|||||||
offlineDownloading: 'En cache… ({{done}}/{{total}} albums)',
|
offlineDownloading: 'En cache… ({{done}}/{{total}} albums)',
|
||||||
uploadImage: "Téléverser l'image de l'artiste",
|
uploadImage: "Téléverser l'image de l'artiste",
|
||||||
uploadImageError: "Échec du téléversement de l'image",
|
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: {
|
favorites: {
|
||||||
title: 'Favoris',
|
title: 'Favoris',
|
||||||
|
|||||||
@@ -264,6 +264,16 @@ export const nbTranslation = {
|
|||||||
offlineDownloading: 'Bufrer… ({{done}}/{{total}} album)',
|
offlineDownloading: 'Bufrer… ({{done}}/{{total}} album)',
|
||||||
uploadImage: 'Last opp artistbilde',
|
uploadImage: 'Last opp artistbilde',
|
||||||
uploadImageError: 'Kunne ikke laste opp bildet',
|
uploadImageError: 'Kunne ikke laste opp bildet',
|
||||||
|
releaseTypes: {
|
||||||
|
album: 'Album',
|
||||||
|
ep: 'EP',
|
||||||
|
single: 'Single',
|
||||||
|
compilation: 'Samling',
|
||||||
|
live: 'Live',
|
||||||
|
soundtrack: 'Filmmusikk',
|
||||||
|
remix: 'Remiks',
|
||||||
|
other: 'Annet',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
favorites: {
|
favorites: {
|
||||||
title: 'Favoritter',
|
title: 'Favoritter',
|
||||||
|
|||||||
@@ -263,6 +263,16 @@ export const nlTranslation = {
|
|||||||
offlineDownloading: 'Cachen… ({{done}}/{{total}} albums)',
|
offlineDownloading: 'Cachen… ({{done}}/{{total}} albums)',
|
||||||
uploadImage: 'Artiestafbeelding uploaden',
|
uploadImage: 'Artiestafbeelding uploaden',
|
||||||
uploadImageError: 'Uploaden van afbeelding mislukt',
|
uploadImageError: 'Uploaden van afbeelding mislukt',
|
||||||
|
releaseTypes: {
|
||||||
|
album: 'Album',
|
||||||
|
ep: 'EP',
|
||||||
|
single: 'Single',
|
||||||
|
compilation: 'Compilatie',
|
||||||
|
live: 'Live',
|
||||||
|
soundtrack: 'Soundtrack',
|
||||||
|
remix: 'Remix',
|
||||||
|
other: 'Overig',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
favorites: {
|
favorites: {
|
||||||
title: 'Favorieten',
|
title: 'Favorieten',
|
||||||
|
|||||||
@@ -277,6 +277,16 @@ export const ruTranslation = {
|
|||||||
offlineDownloading: 'Кэширование… ({{done}} из {{total}} альбомов)',
|
offlineDownloading: 'Кэширование… ({{done}} из {{total}} альбомов)',
|
||||||
uploadImage: 'Загрузить фото исполнителя',
|
uploadImage: 'Загрузить фото исполнителя',
|
||||||
uploadImageError: 'Не удалось загрузить изображение',
|
uploadImageError: 'Не удалось загрузить изображение',
|
||||||
|
releaseTypes: {
|
||||||
|
album: 'Альбом',
|
||||||
|
ep: 'EP',
|
||||||
|
single: 'Сингл',
|
||||||
|
compilation: 'Сборник',
|
||||||
|
live: 'Концерт',
|
||||||
|
soundtrack: 'Саундтрек',
|
||||||
|
remix: 'Ремикс',
|
||||||
|
other: 'Другое',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
favorites: {
|
favorites: {
|
||||||
title: 'Избранное',
|
title: 'Избранное',
|
||||||
|
|||||||
@@ -262,6 +262,16 @@ export const zhTranslation = {
|
|||||||
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
|
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
|
||||||
uploadImage: '上传艺术家图片',
|
uploadImage: '上传艺术家图片',
|
||||||
uploadImageError: '图片上传失败',
|
uploadImageError: '图片上传失败',
|
||||||
|
releaseTypes: {
|
||||||
|
album: '专辑',
|
||||||
|
ep: 'EP',
|
||||||
|
single: '单曲',
|
||||||
|
compilation: '合辑',
|
||||||
|
live: '现场',
|
||||||
|
soundtrack: '原声带',
|
||||||
|
remix: '混音',
|
||||||
|
other: '其他',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
favorites: {
|
favorites: {
|
||||||
title: '收藏夹',
|
title: '收藏夹',
|
||||||
|
|||||||
@@ -459,6 +459,37 @@ export default function ArtistDetail() {
|
|||||||
[coverId],
|
[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<string, SubsonicAlbum[]>();
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
setHeaderCoverFailed(false);
|
setHeaderCoverFailed(false);
|
||||||
}, [coverId, coverRevision, id]);
|
}, [coverId, coverRevision, id]);
|
||||||
@@ -858,9 +889,21 @@ export default function ArtistDetail() {
|
|||||||
{t('artistDetail.albumsBy', { name: artist.name })}
|
{t('artistDetail.albumsBy', { name: artist.name })}
|
||||||
</h2>
|
</h2>
|
||||||
{albums.length > 0 ? (
|
{albums.length > 0 ? (
|
||||||
<div className="album-grid-wrap album-grid-wrap--artist">
|
groupedAlbums.length === 1 ? (
|
||||||
{albums.map((a, i) => <AlbumCard key={`${a.id}-${i}`} album={a} />)}
|
<div className="album-grid-wrap album-grid-wrap--artist">
|
||||||
</div>
|
{albums.map((a, i) => <AlbumCard key={`${a.id}-${i}`} album={a} />)}
|
||||||
|
</div>
|
||||||
|
) : groupedAlbums.map(([label, group]) => (
|
||||||
|
<div key={label} className="artist-release-group">
|
||||||
|
<div className="artist-release-group__header">
|
||||||
|
<h3>{label}</h3>
|
||||||
|
<span className="artist-release-group__count">{group.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="album-grid-wrap album-grid-wrap--artist">
|
||||||
|
{group.map((a, i) => <AlbumCard key={`${a.id}-${i}`} album={a} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
) : (
|
) : (
|
||||||
<p style={{ color: 'var(--text-muted)' }}>{t('artistDetail.noAlbums')}</p>
|
<p style={{ color: 'var(--text-muted)' }}>{t('artistDetail.noAlbums')}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -276,6 +276,7 @@ const CONTRIBUTORS = [
|
|||||||
since: '1.46.0',
|
since: '1.46.0',
|
||||||
contributions: [
|
contributions: [
|
||||||
'Discord Rich Presence: cover art from your own server (Subsonic getAlbumInfo2) with three-way picker — none / server / Apple Music (PR #462)',
|
'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)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8598,6 +8598,31 @@ html.no-compositing .fs-seekbar-played {
|
|||||||
justify-content: center;
|
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 {
|
.app-shell[data-mobile] .album-grid-wrap--artist {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user