mirror of
https://github.com/antebudimir/feishin.git
synced 2025-12-31 10:03:33 +00:00
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:
parent
3fe6ccf300
commit
e26ffaac53
12 changed files with 223 additions and 40 deletions
|
|
@ -97,6 +97,8 @@
|
|||
"quit": "quit",
|
||||
"random": "random",
|
||||
"rating": "rating",
|
||||
"recordLabel": "record label",
|
||||
"releaseType": "release type",
|
||||
"refresh": "refresh",
|
||||
"reload": "reload",
|
||||
"reset": "reset",
|
||||
|
|
@ -496,6 +498,29 @@
|
|||
"pause": "pause",
|
||||
"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": {
|
||||
"accentColor_description": "sets the accent color for the application",
|
||||
"accentColor": "accent color",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { generatePath, useParams } from 'react-router';
|
||||
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 { AppRoute } from '/@/renderer/router/routes';
|
||||
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 { Pill } from '/@/shared/components/pill/pill';
|
||||
import { Rating } from '/@/shared/components/rating/rating';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
|
|
@ -38,7 +40,9 @@ export const AlbumDetailHeader = forwardRef(
|
|||
const cq = useContainerQuery();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME;
|
||||
const showRating =
|
||||
detailQuery?.data?.serverType === ServerType.NAVIDROME ||
|
||||
detailQuery?.data?.serverType === ServerType.SUBSONIC;
|
||||
|
||||
const originalDifferentFromRelease =
|
||||
detailQuery.data?.originalDate &&
|
||||
|
|
@ -78,7 +82,16 @@ export const AlbumDetailHeader = forwardRef(
|
|||
}
|
||||
}, 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',
|
||||
value:
|
||||
|
|
@ -98,11 +111,17 @@ export const AlbumDetailHeader = forwardRef(
|
|||
},
|
||||
{
|
||||
id: 'playCount',
|
||||
value: t('entity.play', {
|
||||
count: detailQuery?.data?.playCount as number,
|
||||
}),
|
||||
value:
|
||||
typeof detailQuery?.data?.playCount === 'number' &&
|
||||
t('entity.play', {
|
||||
count: detailQuery?.data?.playCount,
|
||||
}),
|
||||
},
|
||||
];
|
||||
{
|
||||
id: 'version',
|
||||
value: detailQuery.data?.version,
|
||||
},
|
||||
]);
|
||||
|
||||
if (originalDifferentFromRelease) {
|
||||
const formatted = `♫ ${formatDateAbsoluteUTC(detailQuery!.data!.originalDate)}`;
|
||||
|
|
@ -135,28 +154,22 @@ export const AlbumDetailHeader = forwardRef(
|
|||
title={detailQuery?.data?.name || ''}
|
||||
{...background}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group gap="sm">
|
||||
{metadataItems.map((item, index) => (
|
||||
<Fragment key={`item-${item.id}-${index}`}>
|
||||
{index > 0 && <Text isNoSelect>•</Text>}
|
||||
<Text>{item.value}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
{showRating && (
|
||||
<>
|
||||
<Text isNoSelect>•</Text>
|
||||
<Rating
|
||||
onChange={handleUpdateRating}
|
||||
readOnly={
|
||||
detailQuery?.isFetching ||
|
||||
updateRatingMutation.isPending
|
||||
}
|
||||
value={detailQuery?.data?.userRating || 0}
|
||||
/>
|
||||
</>
|
||||
<Stack gap="lg">
|
||||
<Pill.Group>
|
||||
{metadataItems.map(
|
||||
(item, index) =>
|
||||
item.value && (
|
||||
<Pill key={`item-${item.id}-${index}`}>{item.value}</Pill>
|
||||
),
|
||||
)}
|
||||
</Group>
|
||||
</Pill.Group>
|
||||
{showRating && (
|
||||
<Rating
|
||||
onChange={handleUpdateRating}
|
||||
readOnly={detailQuery?.isFetching || updateRatingMutation.isPending}
|
||||
value={detailQuery?.data?.userRating || 0}
|
||||
/>
|
||||
)}
|
||||
<Group
|
||||
gap="md"
|
||||
mah="4rem"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { AppRoute } from '/@/renderer/router/routes';
|
|||
import { formatDurationString, formatSizeString } from '/@/renderer/utils';
|
||||
import { formatDateRelative, formatRating } from '/@/renderer/utils/format';
|
||||
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
||||
import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types';
|
||||
import { sanitize } from '/@/renderer/utils/sanitize';
|
||||
import { SEPARATOR_STRING } from '/@/shared/api/utils';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
|
|
@ -122,6 +123,10 @@ const BoolField = (key: boolean) =>
|
|||
const AlbumPropertyMapping: ItemDetailRow<Album>[] = [
|
||||
{ key: 'name', label: 'common.title' },
|
||||
{ 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: 'common.duration',
|
||||
|
|
@ -174,6 +179,8 @@ const AlbumPropertyMapping: ItemDetailRow<Album>[] = [
|
|||
) : null,
|
||||
},
|
||||
{ key: 'id', label: 'filter.id' },
|
||||
{ key: 'version', label: 'common.version' },
|
||||
{ label: 'common.recordLabel', render: (item) => item.recordLabels.join(SEPARATOR_STRING) },
|
||||
];
|
||||
|
||||
const AlbumArtistPropertyMapping: ItemDetailRow<AlbumArtist>[] = [
|
||||
|
|
|
|||
57
src/renderer/utils/normalize-release-types.tsx
Normal file
57
src/renderer/utils/normalize-release-types.tsx
Normal 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);
|
||||
};
|
||||
|
|
@ -340,7 +340,9 @@ const normalizeAlbum = (
|
|||
originalDate: null,
|
||||
participants: getPeople(item),
|
||||
playCount: item.UserData?.PlayCount || 0,
|
||||
recordLabels: [],
|
||||
releaseDate: item.PremiereDate?.split('T')[0] || null,
|
||||
releaseTypes: [],
|
||||
releaseYear: item.ProductionYear || null,
|
||||
serverId: server?.id || '',
|
||||
serverType: ServerType.JELLYFIN,
|
||||
|
|
@ -352,6 +354,7 @@ const normalizeAlbum = (
|
|||
updatedAt: item?.DateLastMediaAdded || item.DateCreated,
|
||||
userFavorite: item.UserData?.IsFavorite || false,
|
||||
userRating: null,
|
||||
version: null,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
item: z.infer<typeof ndType._response.album> & {
|
||||
songs?: z.infer<typeof ndType._response.songList>;
|
||||
|
|
@ -238,8 +279,9 @@ const normalizeAlbum = (
|
|||
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
|
||||
|
||||
return {
|
||||
albumArtist: item.albumArtist,
|
||||
...parseAlbumTags(item),
|
||||
...getArtists(item),
|
||||
albumArtist: item.albumArtist,
|
||||
backdropImageUrl: imageBackdropUrl,
|
||||
comment: item.comment || null,
|
||||
createdAt: item.createdAt.split('T')[0],
|
||||
|
|
@ -281,7 +323,6 @@ const normalizeAlbum = (
|
|||
size: item.size,
|
||||
songCount: item.songCount,
|
||||
songs: item.songs ? item.songs.map((song) => normalizeSong(song, server)) : undefined,
|
||||
tags: item.tags || null,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.updatedAt,
|
||||
userFavorite: item.starred,
|
||||
|
|
|
|||
|
|
@ -272,7 +272,9 @@ const normalizeAlbum = (
|
|||
originalDate: null,
|
||||
participants: getParticipants(item),
|
||||
playCount: null,
|
||||
recordLabels: item.recordLabels?.map((item) => item.name) || [],
|
||||
releaseDate: item.year ? new Date(Date.UTC(item.year, 0, 1)).toISOString() : null,
|
||||
releaseTypes: item.releaseTypes || [],
|
||||
releaseYear: item.year ? Number(item.year) : null,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.SUBSONIC,
|
||||
|
|
@ -287,6 +289,7 @@ const normalizeAlbum = (
|
|||
updatedAt: item.created,
|
||||
userFavorite: item.starred || false,
|
||||
userRating: item.userRating || null,
|
||||
version: item.version || null,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -117,6 +117,10 @@ const song = z.object({
|
|||
year: z.number().optional(),
|
||||
});
|
||||
|
||||
const recordLabel = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
const album = z.object({
|
||||
album: z.string(),
|
||||
artist: z.string(),
|
||||
|
|
@ -135,11 +139,14 @@ const album = z.object({
|
|||
isVideo: z.boolean(),
|
||||
name: z.string(),
|
||||
parent: z.string(),
|
||||
recordLabels: z.array(recordLabel).optional(),
|
||||
releaseTypes: z.array(z.string()).optional(),
|
||||
song: z.array(song),
|
||||
songCount: z.number(),
|
||||
starred: z.boolean().optional(),
|
||||
title: z.string(),
|
||||
userRating: z.number().optional(),
|
||||
version: z.string().optional(),
|
||||
year: z.number().optional(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
font-family: var(--theme-content-font-family);
|
||||
font-family: var(--theme-content-font-family);
|
||||
}
|
||||
|
||||
.label.sm {
|
||||
font-size: var(--theme-font-size-sm);
|
||||
font-size: var(--theme-font-size-sm);
|
||||
}
|
||||
|
||||
.label.md {
|
||||
font-size: var(--theme-font-size-md);
|
||||
font-size: var(--theme-font-size-md);
|
||||
}
|
||||
|
||||
.label.lg {
|
||||
font-size: var(--theme-font-size-lg);
|
||||
font-size: var(--theme-font-size-lg);
|
||||
}
|
||||
|
||||
.label.xl {
|
||||
font-size: var(--theme-font-size-xl);
|
||||
font-size: var(--theme-font-size-xl);
|
||||
}
|
||||
|
||||
.label.xs {
|
||||
font-size: var(--theme-font-size-xs);
|
||||
font-size: var(--theme-font-size-xs);
|
||||
}
|
||||
|
||||
|
||||
.remove {
|
||||
transition: color 0.1s ease-in-out;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import clsx from 'clsx';
|
|||
|
||||
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 (
|
||||
<MantinePill
|
||||
classNames={{
|
||||
|
|
@ -17,8 +19,10 @@ export const Pill = ({ children, size = 'md', ...props }: MantinePillProps) => {
|
|||
}),
|
||||
remove: styles.remove,
|
||||
root: styles.root,
|
||||
...classNames,
|
||||
}}
|
||||
size="md"
|
||||
radius={radius}
|
||||
size={size}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -182,7 +182,9 @@ export type Album = {
|
|||
originalDate: null | string;
|
||||
participants: null | Record<string, RelatedArtist[]>;
|
||||
playCount: null | number;
|
||||
recordLabels: string[];
|
||||
releaseDate: null | string;
|
||||
releaseTypes: string[];
|
||||
releaseYear: null | number;
|
||||
serverId: string;
|
||||
serverType: ServerType;
|
||||
|
|
@ -194,6 +196,7 @@ export type Album = {
|
|||
updatedAt: string;
|
||||
userFavorite: boolean;
|
||||
userRating: null | number;
|
||||
version: null | string;
|
||||
} & { songs?: Song[] };
|
||||
|
||||
export type AlbumArtist = {
|
||||
|
|
|
|||
9
src/types/mantine.d.ts
vendored
Normal file
9
src/types/mantine.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { PillVariant } from '@mantine/core';
|
||||
|
||||
type ExtendedPillVariant = 'outline' | PillVariant;
|
||||
|
||||
declare module '@mantine/core' {
|
||||
export interface PillProps {
|
||||
variant?: ExtendedPillVariant;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue