Add additional information to album: record label, release type, version (#1242)

* Add additional information to album

* add mbz release types and normalization

* update Pill styling

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
Kendall Garner 2025-11-03 08:34:42 +00:00 committed by GitHub
parent 3fe6ccf300
commit e26ffaac53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 223 additions and 40 deletions

View file

@ -97,6 +97,8 @@
"quit": "quit", "quit": "quit",
"random": "random", "random": "random",
"rating": "rating", "rating": "rating",
"recordLabel": "record label",
"releaseType": "release type",
"refresh": "refresh", "refresh": "refresh",
"reload": "reload", "reload": "reload",
"reset": "reset", "reset": "reset",
@ -496,6 +498,29 @@
"pause": "pause", "pause": "pause",
"viewQueue": "view queue" "viewQueue": "view queue"
}, },
"releaseType": {
"primary": {
"album": "$t(entity.album_one)",
"broadcast": "broadcast",
"ep": "ep",
"other": "other",
"single": "single"
},
"secondary": {
"audiobook": "audiobook",
"audioDrama": "audio drama",
"compilation": "compilation",
"djMix": "dj mix",
"demo": "demo",
"fieldRecording": "field recording",
"interview": "interview",
"live": "live",
"mixtape": "mixtape",
"remix": "remix",
"soundtrack": "soundtrack",
"spokenWord": "spoken word"
}
},
"setting": { "setting": {
"accentColor_description": "sets the accent color for the application", "accentColor_description": "sets the accent color for the application",
"accentColor": "accent color", "accentColor": "accent color",

View file

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { forwardRef, Fragment, Ref, useCallback, useMemo } from 'react'; import { forwardRef, Ref, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { generatePath, useParams } from 'react-router'; import { generatePath, useParams } from 'react-router';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@ -13,8 +13,10 @@ import { useSongChange } from '/@/renderer/hooks/use-song-change';
import { queryClient } from '/@/renderer/lib/react-query'; import { queryClient } from '/@/renderer/lib/react-query';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store'; import { useCurrentServer } from '/@/renderer/store';
import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils'; import { formatDateAbsoluteUTC, formatDurationString, titleCase } from '/@/renderer/utils';
import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Pill } from '/@/shared/components/pill/pill';
import { Rating } from '/@/shared/components/rating/rating'; import { Rating } from '/@/shared/components/rating/rating';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
@ -38,7 +40,9 @@ export const AlbumDetailHeader = forwardRef(
const cq = useContainerQuery(); const cq = useContainerQuery();
const { t } = useTranslation(); const { t } = useTranslation();
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME; const showRating =
detailQuery?.data?.serverType === ServerType.NAVIDROME ||
detailQuery?.data?.serverType === ServerType.SUBSONIC;
const originalDifferentFromRelease = const originalDifferentFromRelease =
detailQuery.data?.originalDate && detailQuery.data?.originalDate &&
@ -78,7 +82,16 @@ export const AlbumDetailHeader = forwardRef(
} }
}, detailQuery.data !== undefined); }, detailQuery.data !== undefined);
const metadataItems = [ const releaseTypes = useMemo(
() =>
normalizeReleaseTypes(detailQuery.data?.releaseTypes ?? [], t).map((type) => ({
id: type,
value: titleCase(type),
})) || [],
[detailQuery.data?.releaseTypes, t],
);
const metadataItems = releaseTypes.concat([
{ {
id: 'releaseDate', id: 'releaseDate',
value: value:
@ -98,11 +111,17 @@ export const AlbumDetailHeader = forwardRef(
}, },
{ {
id: 'playCount', id: 'playCount',
value: t('entity.play', { value:
count: detailQuery?.data?.playCount as number, typeof detailQuery?.data?.playCount === 'number' &&
}), t('entity.play', {
count: detailQuery?.data?.playCount,
}),
}, },
]; {
id: 'version',
value: detailQuery.data?.version,
},
]);
if (originalDifferentFromRelease) { if (originalDifferentFromRelease) {
const formatted = `${formatDateAbsoluteUTC(detailQuery!.data!.originalDate)}`; const formatted = `${formatDateAbsoluteUTC(detailQuery!.data!.originalDate)}`;
@ -135,28 +154,22 @@ export const AlbumDetailHeader = forwardRef(
title={detailQuery?.data?.name || ''} title={detailQuery?.data?.name || ''}
{...background} {...background}
> >
<Stack gap="sm"> <Stack gap="lg">
<Group gap="sm"> <Pill.Group>
{metadataItems.map((item, index) => ( {metadataItems.map(
<Fragment key={`item-${item.id}-${index}`}> (item, index) =>
{index > 0 && <Text isNoSelect></Text>} item.value && (
<Text>{item.value}</Text> <Pill key={`item-${item.id}-${index}`}>{item.value}</Pill>
</Fragment> ),
))}
{showRating && (
<>
<Text isNoSelect></Text>
<Rating
onChange={handleUpdateRating}
readOnly={
detailQuery?.isFetching ||
updateRatingMutation.isPending
}
value={detailQuery?.data?.userRating || 0}
/>
</>
)} )}
</Group> </Pill.Group>
{showRating && (
<Rating
onChange={handleUpdateRating}
readOnly={detailQuery?.isFetching || updateRatingMutation.isPending}
value={detailQuery?.data?.userRating || 0}
/>
)}
<Group <Group
gap="md" gap="md"
mah="4rem" mah="4rem"

View file

@ -9,6 +9,7 @@ import { AppRoute } from '/@/renderer/router/routes';
import { formatDurationString, formatSizeString } from '/@/renderer/utils'; import { formatDurationString, formatSizeString } from '/@/renderer/utils';
import { formatDateRelative, formatRating } from '/@/renderer/utils/format'; import { formatDateRelative, formatRating } from '/@/renderer/utils/format';
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify'; import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types';
import { sanitize } from '/@/renderer/utils/sanitize'; import { sanitize } from '/@/renderer/utils/sanitize';
import { SEPARATOR_STRING } from '/@/shared/api/utils'; import { SEPARATOR_STRING } from '/@/shared/api/utils';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
@ -122,6 +123,10 @@ const BoolField = (key: boolean) =>
const AlbumPropertyMapping: ItemDetailRow<Album>[] = [ const AlbumPropertyMapping: ItemDetailRow<Album>[] = [
{ key: 'name', label: 'common.title' }, { key: 'name', label: 'common.title' },
{ label: 'entity.albumArtist_one', render: (item) => formatArtists(item.albumArtists) }, { label: 'entity.albumArtist_one', render: (item) => formatArtists(item.albumArtists) },
{
label: 'common.releaseType',
render: (item, t) => normalizeReleaseTypes(item.releaseTypes, t).join(SEPARATOR_STRING),
},
{ label: 'entity.genre_other', render: FormatGenre }, { label: 'entity.genre_other', render: FormatGenre },
{ {
label: 'common.duration', label: 'common.duration',
@ -174,6 +179,8 @@ const AlbumPropertyMapping: ItemDetailRow<Album>[] = [
) : null, ) : null,
}, },
{ key: 'id', label: 'filter.id' }, { key: 'id', label: 'filter.id' },
{ key: 'version', label: 'common.version' },
{ label: 'common.recordLabel', render: (item) => item.recordLabels.join(SEPARATOR_STRING) },
]; ];
const AlbumArtistPropertyMapping: ItemDetailRow<AlbumArtist>[] = [ const AlbumArtistPropertyMapping: ItemDetailRow<AlbumArtist>[] = [

View file

@ -0,0 +1,57 @@
import { TFunction } from 'react-i18next';
import { titleCase } from '/@/renderer/utils/title-case';
// Release types derived from https://musicbrainz.org/doc/Release_Group/Type
const PRIMARY_MAPPING = {
album: 'album',
broadcast: 'broadcast',
ep: 'ep',
other: 'other',
single: 'single',
} as const;
const SECONDARY_MAPPING = {
audiobook: 'audiobook',
'audio drama': 'audioDrama',
compilation: 'compilation',
demo: 'demo',
'dj-mix': 'djMix',
'field recording': 'fieldRecording',
interview: 'interview',
live: 'live',
'mixtape/street': 'mixtape',
remix: 'remix',
soundtrack: 'soundtrack',
spokenword: 'spokenWord',
} as const;
export const normalizeReleaseTypes = (types: string[], t: TFunction) => {
const primary: string[] = [];
const secondary: string[] = [];
const unknown: string[] = [];
for (const type of types) {
const lower = type.toLocaleLowerCase();
if (lower in PRIMARY_MAPPING) {
primary.push(
t(`releaseType.primary.${PRIMARY_MAPPING[lower]}`, { postProcess: 'sentenceCase' }),
);
} else if (lower in SECONDARY_MAPPING) {
secondary.push(
t(`releaseType.secondary.${SECONDARY_MAPPING[lower]}`, {
postProcess: 'sentenceCase',
}),
);
} else {
unknown.push(titleCase(type));
}
}
primary.sort();
secondary.sort();
unknown.sort();
return primary.concat(secondary, unknown);
};

View file

@ -340,7 +340,9 @@ const normalizeAlbum = (
originalDate: null, originalDate: null,
participants: getPeople(item), participants: getPeople(item),
playCount: item.UserData?.PlayCount || 0, playCount: item.UserData?.PlayCount || 0,
recordLabels: [],
releaseDate: item.PremiereDate?.split('T')[0] || null, releaseDate: item.PremiereDate?.split('T')[0] || null,
releaseTypes: [],
releaseYear: item.ProductionYear || null, releaseYear: item.ProductionYear || null,
serverId: server?.id || '', serverId: server?.id || '',
serverType: ServerType.JELLYFIN, serverType: ServerType.JELLYFIN,
@ -352,6 +354,7 @@ const normalizeAlbum = (
updatedAt: item?.DateLastMediaAdded || item.DateCreated, updatedAt: item?.DateLastMediaAdded || item.DateCreated,
userFavorite: item.UserData?.IsFavorite || false, userFavorite: item.UserData?.IsFavorite || false,
userRating: null, userRating: null,
version: null,
}; };
}; };

View file

@ -218,6 +218,47 @@ const normalizeSong = (
}; };
}; };
const parseAlbumTags = (
item: z.infer<typeof ndType._response.album>,
): Pick<Album, 'recordLabels' | 'releaseTypes' | 'tags' | 'version'> => {
if (!item.tags) {
return {
recordLabels: [],
releaseTypes: [],
tags: null,
version: null,
};
}
// We get the genre from elsewhere. We don't need genre twice
delete item.tags['genre'];
let recordLabels: string[] = [];
if (item.tags['recordlabel']) {
recordLabels = item.tags['recordlabel'];
delete item.tags['recordlabel'];
}
let releaseTypes: string[] = [];
if (item.tags['releasetype']) {
releaseTypes = item.tags['releasetype'];
delete item.tags['releasetype'];
}
let version: null | string = null;
if (item.tags['albumversion']) {
version = item.tags['albumversion'].join(' · ');
delete item.tags['albumversion'];
}
return {
recordLabels,
releaseTypes,
tags: item.tags,
version,
};
};
const normalizeAlbum = ( const normalizeAlbum = (
item: z.infer<typeof ndType._response.album> & { item: z.infer<typeof ndType._response.album> & {
songs?: z.infer<typeof ndType._response.songList>; songs?: z.infer<typeof ndType._response.songList>;
@ -238,8 +279,9 @@ const normalizeAlbum = (
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null; const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
return { return {
albumArtist: item.albumArtist, ...parseAlbumTags(item),
...getArtists(item), ...getArtists(item),
albumArtist: item.albumArtist,
backdropImageUrl: imageBackdropUrl, backdropImageUrl: imageBackdropUrl,
comment: item.comment || null, comment: item.comment || null,
createdAt: item.createdAt.split('T')[0], createdAt: item.createdAt.split('T')[0],
@ -281,7 +323,6 @@ const normalizeAlbum = (
size: item.size, size: item.size,
songCount: item.songCount, songCount: item.songCount,
songs: item.songs ? item.songs.map((song) => normalizeSong(song, server)) : undefined, songs: item.songs ? item.songs.map((song) => normalizeSong(song, server)) : undefined,
tags: item.tags || null,
uniqueId: nanoid(), uniqueId: nanoid(),
updatedAt: item.updatedAt, updatedAt: item.updatedAt,
userFavorite: item.starred, userFavorite: item.starred,

View file

@ -272,7 +272,9 @@ const normalizeAlbum = (
originalDate: null, originalDate: null,
participants: getParticipants(item), participants: getParticipants(item),
playCount: null, playCount: null,
recordLabels: item.recordLabels?.map((item) => item.name) || [],
releaseDate: item.year ? new Date(Date.UTC(item.year, 0, 1)).toISOString() : null, releaseDate: item.year ? new Date(Date.UTC(item.year, 0, 1)).toISOString() : null,
releaseTypes: item.releaseTypes || [],
releaseYear: item.year ? Number(item.year) : null, releaseYear: item.year ? Number(item.year) : null,
serverId: server?.id || 'unknown', serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC, serverType: ServerType.SUBSONIC,
@ -287,6 +289,7 @@ const normalizeAlbum = (
updatedAt: item.created, updatedAt: item.created,
userFavorite: item.starred || false, userFavorite: item.starred || false,
userRating: item.userRating || null, userRating: item.userRating || null,
version: item.version || null,
}; };
}; };

View file

@ -117,6 +117,10 @@ const song = z.object({
year: z.number().optional(), year: z.number().optional(),
}); });
const recordLabel = z.object({
name: z.string(),
});
const album = z.object({ const album = z.object({
album: z.string(), album: z.string(),
artist: z.string(), artist: z.string(),
@ -135,11 +139,14 @@ const album = z.object({
isVideo: z.boolean(), isVideo: z.boolean(),
name: z.string(), name: z.string(),
parent: z.string(), parent: z.string(),
recordLabels: z.array(recordLabel).optional(),
releaseTypes: z.array(z.string()).optional(),
song: z.array(song), song: z.array(song),
songCount: z.number(), songCount: z.number(),
starred: z.boolean().optional(), starred: z.boolean().optional(),
title: z.string(), title: z.string(),
userRating: z.number().optional(), userRating: z.number().optional(),
version: z.string().optional(),
year: z.number().optional(), year: z.number().optional(),
}); });

View file

@ -1,28 +1,39 @@
.root {
box-sizing: border-box;
user-select: auto;
background: alpha(var(--theme-colors-background), 0.5);
border: 1px solid var(--theme-colors-border);
&[data-variant='outline'] {
background: transparent;
border: 1px solid var(--theme-colors-border);
}
}
.label { .label {
font-family: var(--theme-content-font-family); font-family: var(--theme-content-font-family);
} }
.label.sm { .label.sm {
font-size: var(--theme-font-size-sm); font-size: var(--theme-font-size-sm);
} }
.label.md { .label.md {
font-size: var(--theme-font-size-md); font-size: var(--theme-font-size-md);
} }
.label.lg { .label.lg {
font-size: var(--theme-font-size-lg); font-size: var(--theme-font-size-lg);
} }
.label.xl { .label.xl {
font-size: var(--theme-font-size-xl); font-size: var(--theme-font-size-xl);
} }
.label.xs { .label.xs {
font-size: var(--theme-font-size-xs); font-size: var(--theme-font-size-xs);
} }
.remove { .remove {
transition: color 0.1s ease-in-out; transition: color 0.1s ease-in-out;

View file

@ -3,7 +3,9 @@ import clsx from 'clsx';
import styles from './pill.module.css'; import styles from './pill.module.css';
export const Pill = ({ children, size = 'md', ...props }: MantinePillProps) => { interface PillProps extends MantinePillProps {}
export const Pill = ({ children, classNames, radius = 'md', size = 'md', ...props }: PillProps) => {
return ( return (
<MantinePill <MantinePill
classNames={{ classNames={{
@ -17,8 +19,10 @@ export const Pill = ({ children, size = 'md', ...props }: MantinePillProps) => {
}), }),
remove: styles.remove, remove: styles.remove,
root: styles.root, root: styles.root,
...classNames,
}} }}
size="md" radius={radius}
size={size}
{...props} {...props}
> >
{children} {children}

View file

@ -182,7 +182,9 @@ export type Album = {
originalDate: null | string; originalDate: null | string;
participants: null | Record<string, RelatedArtist[]>; participants: null | Record<string, RelatedArtist[]>;
playCount: null | number; playCount: null | number;
recordLabels: string[];
releaseDate: null | string; releaseDate: null | string;
releaseTypes: string[];
releaseYear: null | number; releaseYear: null | number;
serverId: string; serverId: string;
serverType: ServerType; serverType: ServerType;
@ -194,6 +196,7 @@ export type Album = {
updatedAt: string; updatedAt: string;
userFavorite: boolean; userFavorite: boolean;
userRating: null | number; userRating: null | number;
version: null | string;
} & { songs?: Song[] }; } & { songs?: Song[] };
export type AlbumArtist = { export type AlbumArtist = {

9
src/types/mantine.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
import { PillVariant } from '@mantine/core';
type ExtendedPillVariant = 'outline' | PillVariant;
declare module '@mantine/core' {
export interface PillProps {
variant?: ExtendedPillVariant;
}
}