Merge pull request #450 from kgarner7/more-metadata

[feature]: Show album comment, Last.fm/MusicBrainz links
This commit is contained in:
Jeff 2024-02-02 14:56:46 -08:00 committed by GitHub
commit ccb0e14e48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 335 additions and 72 deletions

View File

@ -16,7 +16,11 @@
"removeFromQueue": "remove from queue",
"setRating": "set rating",
"toggleSmartPlaylistEditor": "toggle $t(entity.smartPlaylist) editor",
"viewPlaylists": "view $t(entity.playlist_other)"
"viewPlaylists": "view $t(entity.playlist_other)",
"openIn": {
"lastfm": "Open in Last.fm",
"musicbrainz": "Open in MusicBrainz"
}
},
"common": {
"action_one": "action",
@ -419,6 +423,8 @@
"discordUpdateInterval_description": "the time in seconds between each update (minimum 15 seconds)",
"enableRemote": "enable remote control server",
"enableRemote_description": "enables the remote control server to allow other devices to control the application",
"externalLinks": "show external links",
"externalLinks_description": "enables showing external links (Last.fm, MusicBrainz) on artist/album pages",
"exitToTray": "exit to tray",
"exitToTray_description": "exit the application to the system tray",
"floatingQueueArea": "show floating queue hover area",

View File

@ -202,6 +202,7 @@ const normalizeAlbum = (
imageSize?: number,
): Album => {
return {
albumArtist: item.AlbumArtist,
albumArtists:
item.AlbumArtists.map((entry) => ({
id: entry.Id,
@ -214,6 +215,7 @@ const normalizeAlbum = (
name: entry.Name,
})),
backdropImageUrl: null,
comment: null,
createdAt: item.DateCreated,
duration: item.RunTimeTicks / 10000,
genres: item.GenreItems?.map((entry) => ({
@ -232,6 +234,7 @@ const normalizeAlbum = (
isCompilation: null,
itemType: LibraryItem.ALBUM,
lastPlayedAt: null,
mbzId: item.ProviderIds?.MusicBrainzAlbum || null,
name: item.Name,
playCount: item.UserData?.PlayCount || 0,
releaseDate: item.PremiereDate?.split('T')[0] || null,
@ -287,6 +290,7 @@ const normalizeAlbumArtist = (
}),
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: null,
mbz: item.ProviderIds?.MusicBrainzArtist || null,
name: item.Name,
playCount: item.UserData?.PlayCount || 0,
serverId: server?.id || '',

View File

@ -422,6 +422,11 @@ const song = z.object({
UserData: userData.optional(),
});
const providerIds = z.object({
MusicBrainzAlbum: z.string().optional(),
MusicBrainzArtist: z.string().optional(),
});
const albumArtist = z.object({
BackdropImageTags: z.array(z.string()),
ChannelId: z.null(),
@ -435,6 +440,7 @@ const albumArtist = z.object({
LocationType: z.string(),
Name: z.string(),
Overview: z.string(),
ProviderIds: providerIds.optional(),
RunTimeTicks: z.number(),
ServerId: z.string(),
Type: z.string(),
@ -466,6 +472,7 @@ const album = z.object({
ParentLogoItemId: z.string(),
PremiereDate: z.string().optional(),
ProductionYear: z.number(),
ProviderIds: providerIds.optional(),
RunTimeTicks: z.number(),
ServerId: z.string(),
Songs: z.array(song).optional(), // This is not a native Jellyfin property -- this is used for combined album detail

View File

@ -151,9 +151,11 @@ const normalizeAlbum = (
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
return {
albumArtist: item.albumArtist,
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
backdropImageUrl: imageBackdropUrl,
comment: item.comment || null,
createdAt: item.createdAt.split('T')[0],
duration: item.duration * 1000 || null,
genres: item.genres?.map((genre) => ({
@ -168,6 +170,7 @@ const normalizeAlbum = (
isCompilation: item.compilation,
itemType: LibraryItem.ALBUM,
lastPlayedAt: normalizePlayDate(item),
mbzId: item.mbzAlbumId || null,
name: item.name,
playCount: item.playCount,
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
@ -216,6 +219,7 @@ const normalizeAlbumArtist = (
imageUrl: imageUrl || null,
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: normalizePlayDate(item),
mbz: item.mbzArtistId || null,
name: item.name,
playCount: item.playCount,
serverId: server?.id || 'unknown',

View File

@ -111,6 +111,7 @@ const album = z.object({
allArtistIds: z.string(),
artist: z.string(),
artistId: z.string(),
comment: z.string().optional(),
compilation: z.boolean(),
coverArtId: z.string().optional(), // Removed after v0.48.0
coverArtPath: z.string().optional(), // Removed after v0.48.0

View File

@ -126,6 +126,7 @@ const normalizeAlbumArtist = (
imageUrl,
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: null,
mbz: null,
name: item.name,
playCount: null,
serverId: server?.id || 'unknown',
@ -150,11 +151,13 @@ const normalizeAlbum = (
}) || null;
return {
albumArtist: item.artist,
albumArtists: item.artistId
? [{ id: item.artistId, imageUrl: null, name: item.artist }]
: [],
artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
backdropImageUrl: null,
comment: null,
createdAt: item.created,
duration: item.duration,
genres: item.genre
@ -173,6 +176,7 @@ const normalizeAlbum = (
isCompilation: null,
itemType: LibraryItem.ALBUM,
lastPlayedAt: null,
mbzId: null,
name: item.name,
playCount: null,
releaseDate: item.year ? new Date(item.year, 0, 1).toISOString() : null,

View File

@ -144,9 +144,11 @@ export type Genre = {
};
export type Album = {
albumArtist: string;
albumArtists: RelatedArtist[];
artists: RelatedArtist[];
backdropImageUrl: string | null;
comment: string | null;
createdAt: string;
duration: number | null;
genres: Genre[];
@ -156,6 +158,7 @@ export type Album = {
isCompilation: boolean | null;
itemType: LibraryItem.ALBUM;
lastPlayedAt: string | null;
mbzId: string | null;
name: string;
playCount: number | null;
releaseDate: string | null;
@ -228,6 +231,7 @@ export type AlbumArtist = {
imageUrl: string | null;
itemType: LibraryItem.ALBUM_ARTIST;
lastPlayedAt: string | null;
mbz: string | null;
name: string;
playCount: number | null;
serverId: string;

View File

@ -27,6 +27,7 @@ export * from './select';
export * from './skeleton';
export * from './slider';
export * from './spinner';
export * from './spoiler';
export * from './switch';
export * from './tabs';
export * from './text';

View File

@ -0,0 +1,39 @@
import clsx from 'clsx';
import { HTMLAttributes, ReactNode, useRef, useState } from 'react';
import styles from './spoiler.module.scss';
import { useIsOverflow } from '/@/renderer/hooks';
interface SpoilerProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
defaultOpened?: boolean;
maxHeight?: number;
}
export const Spoiler = ({ maxHeight, defaultOpened, children, ...props }: SpoilerProps) => {
const ref = useRef(null);
const isOverflow = useIsOverflow(ref);
const [isExpanded, setIsExpanded] = useState(!!defaultOpened);
const spoilerClassNames = clsx(styles.spoiler, {
[styles.canExpand]: isOverflow,
[styles.isExpanded]: isExpanded,
});
const handleToggleExpand = () => {
setIsExpanded((val) => !val);
};
return (
<div
ref={ref}
className={spoilerClassNames}
role="button"
style={{ maxHeight: maxHeight ?? '100px' }}
tabIndex={-1}
onClick={handleToggleExpand}
{...props}
>
{children}
</div>
);
};

View File

@ -0,0 +1,31 @@
.control:hover {
color: var(--btn-subtle-fg-hover);
text-decoration: none;
}
.spoiler {
position: relative;
text-align: justify;
width: 100%;
height: 100%;
overflow: hidden;
}
.spoiler:not(.is-expanded).can-expand:after {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 100%;
content: '';
background: linear-gradient(to top, var(--main-bg) 10%, transparent 60%);
pointer-events: none;
}
.spoiler.can-expand {
cursor: pointer;
}
.spoiler.is-expanded {
max-height: 2500px !important;
}

View File

@ -3,17 +3,7 @@ import { Skeleton } from '/@/renderer/components/skeleton';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
import { useMemo } from 'react';
import { Text } from '/@/renderer/components/text';
const URL_REGEX =
/((?:https?:\/\/)?(?:[\w-]{1,32}(?:\.[\w-]{1,32})+)(?:\/[\w\-./?%&=][^.|^\s]*)?)/g;
const replaceURLWithHTMLLinks = (text: string) => {
const urlRegex = new RegExp(URL_REGEX, 'g');
return text.replaceAll(
urlRegex,
(url) => `<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`,
);
};
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
export const NoteCell = ({ value }: ICellRendererParams) => {
const formattedValue = useMemo(() => {
@ -39,9 +29,10 @@ export const NoteCell = ({ value }: ICellRendererParams) => {
<CellContainer $position="left">
<Text
$secondary
dangerouslySetInnerHTML={{ __html: formattedValue }}
overflow="hidden"
/>
>
{formattedValue}
</Text>
</CellContainer>
);
};

View File

@ -4,13 +4,15 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
import { Box, Group, Stack } from '@mantine/core';
import { useSetState } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { FaLastfmSquare } from 'react-icons/fa';
import { RiHeartFill, RiHeartLine, RiMoreFill, RiSettings2Fill } from 'react-icons/ri';
import { SiMusicbrainz } from 'react-icons/si';
import { generatePath, useParams } from 'react-router';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/api/types';
import { Button, Popover } from '/@/renderer/components';
import { Button, Popover, Spoiler } from '/@/renderer/components';
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
import {
TableConfigDropdown,
@ -36,11 +38,13 @@ import { useAppFocus, useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useCurrentSong, useCurrentStatus } from '/@/renderer/store';
import {
useGeneralSettings,
usePlayButtonBehavior,
useSettingsStoreActions,
useTableSettings,
} from '/@/renderer/store/settings.store';
import { Play } from '/@/renderer/types';
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
const isFullWidthRow = (node: RowNode) => {
return node.id?.startsWith('disc-');
@ -54,6 +58,7 @@ const ContentContainer = styled.div`
const DetailContainer = styled.div`
display: flex;
flex-direction: column;
gap: 2rem;
padding: 1rem 2rem 5rem;
overflow: hidden;
`;
@ -75,6 +80,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
const status = useCurrentStatus();
const isFocused = useAppFocus();
const currentSong = useCurrentSong();
const { externalLinks } = useGeneralSettings();
const columnDefs = useMemo(
() => getColumnDefs(tableConfig.columns, false, 'albumDetail'),
@ -279,6 +285,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
};
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
const comment = detailQuery?.data?.comment;
const handleGeneralContextMenu = useHandleGeneralContextMenu(
LibraryItem.ALBUM,
@ -313,6 +320,8 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
const { rowClassRules } = useCurrentSongRowStyles({ tableRef });
const mbzId = detailQuery?.data?.mbzId;
return (
<ContentContainer>
<LibraryBackgroundOverlay $backgroundColor={background} />
@ -320,7 +329,6 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
<Box component="section">
<Group
position="apart"
py="1rem"
spacing="sm"
>
<Group>
@ -372,10 +380,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
</Group>
</Box>
{showGenres && (
<Box
component="section"
py="1rem"
>
<Box component="section">
<Group spacing="sm">
{detailQuery?.data?.genres?.map((genre) => (
<Button
@ -395,6 +400,51 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
</Group>
</Box>
)}
{externalLinks ? (
<Box component="section">
<Group spacing="sm">
<Button
compact
component="a"
href={`https://www.last.fm/music/${encodeURIComponent(
detailQuery?.data?.albumArtist || '',
)}/${encodeURIComponent(detailQuery.data?.name || '')}`}
radius="md"
rel="noopener noreferrer"
size="md"
target="_blank"
tooltip={{
label: t('action.openIn.lastfm'),
}}
variant="subtle"
>
<FaLastfmSquare size={25} />
</Button>
{mbzId ? (
<Button
compact
component="a"
href={`https://musicbrainz.org/release/${mbzId}`}
radius="md"
rel="noopener noreferrer"
size="md"
target="_blank"
tooltip={{
label: t('action.openIn.musicbrainz'),
}}
variant="subtle"
>
<SiMusicbrainz size={25} />
</Button>
) : null}
</Group>
</Box>
) : null}
{comment && (
<Box component="section">
<Spoiler maxHeight={75}>{replaceURLWithHTMLLinks(comment)}</Spoiler>
</Box>
)}
<Box style={{ minHeight: '300px' }}>
<VirtualTable
key={`table-${tableConfig.rowHeight}`}

View File

@ -1,7 +1,10 @@
import { useMemo } from 'react';
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
import { Box, Group, Stack } from '@mantine/core';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaLastfmSquare } from 'react-icons/fa';
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
import { SiMusicbrainz } from 'react-icons/si';
import { generatePath, useParams } from 'react-router';
import { createSearchParams, Link } from 'react-router-dom';
import styled from 'styled-components';
@ -14,7 +17,7 @@ import {
ServerType,
SortOrder,
} from '/@/renderer/api/types';
import { Button, Text, TextTitle } from '/@/renderer/components';
import { Button, Spoiler, TextTitle } from '/@/renderer/components';
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
@ -34,7 +37,7 @@ import { LibraryBackgroundOverlay } from '/@/renderer/features/shared/components
import { useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { useGeneralSettings, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { CardRow, Play, TableColumn } from '/@/renderer/types';
const ContentContainer = styled.div`
@ -45,7 +48,7 @@ const ContentContainer = styled.div`
const DetailContainer = styled.div`
display: flex;
flex-direction: column;
gap: 3rem;
gap: 2rem;
padding: 1rem 2rem 5rem;
overflow: hidden;
@ -59,6 +62,8 @@ interface AlbumArtistDetailContentProps {
}
export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailContentProps) => {
const { t } = useTranslation();
const { externalLinks } = useGeneralSettings();
const { albumArtistId } = useParams() as { albumArtistId: string };
const cq = useContainerQuery();
const handlePlayQueueAdd = usePlayQueueAdd();
@ -324,6 +329,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
detailQuery?.data?.biography !== undefined && detailQuery?.data?.biography !== null;
const showTopSongs = topSongsQuery?.data?.items?.length;
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
const mbzId = detailQuery?.data?.mbz;
const isLoading =
detailQuery?.isLoading ||
@ -335,61 +341,58 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
<ContentContainer ref={cq.ref}>
<LibraryBackgroundOverlay $backgroundColor={background} />
<DetailContainer>
<Stack spacing="lg">
<Group spacing="md">
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<Group spacing="xs">
<Button
compact
loading={
createFavoriteMutation.isLoading ||
deleteFavoriteMutation.isLoading
}
variant="subtle"
onClick={handleFavorite}
>
{detailQuery?.data?.userFavorite ? (
<RiHeartFill
color="red"
size={20}
/>
) : (
<RiHeartLine size={20} />
)}
</Button>
<Button
compact
variant="subtle"
onClick={(e) => {
if (!detailQuery?.data) return;
handleGeneralContextMenu(e, [detailQuery.data!]);
}}
>
<RiMoreFill size={20} />
</Button>
</Group>
</Group>
<Group spacing="md">
<Group spacing="md">
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<Group spacing="xs">
<Button
compact
uppercase
component={Link}
to={artistDiscographyLink}
loading={
createFavoriteMutation.isLoading || deleteFavoriteMutation.isLoading
}
variant="subtle"
onClick={handleFavorite}
>
View discography
{detailQuery?.data?.userFavorite ? (
<RiHeartFill
color="red"
size={20}
/>
) : (
<RiHeartLine size={20} />
)}
</Button>
<Button
compact
uppercase
component={Link}
to={artistSongsLink}
variant="subtle"
onClick={(e) => {
if (!detailQuery?.data) return;
handleGeneralContextMenu(e, [detailQuery.data!]);
}}
>
View all songs
<RiMoreFill size={20} />
</Button>
</Group>
</Stack>
</Group>
<Group spacing="md">
<Button
compact
uppercase
component={Link}
to={artistDiscographyLink}
variant="subtle"
>
View discography
</Button>
<Button
compact
uppercase
component={Link}
to={artistSongsLink}
variant="subtle"
>
View all songs
</Button>
</Group>
{showGenres ? (
<Box component="section">
<Group spacing="sm">
@ -411,6 +414,46 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
</Group>
</Box>
) : null}
{externalLinks ? (
<Box component="section">
<Group spacing="sm">
<Button
compact
component="a"
href={`https://www.last.fm/music/${encodeURIComponent(
detailQuery?.data?.name || '',
)}`}
radius="md"
rel="noopener noreferrer"
size="md"
target="_blank"
tooltip={{
label: t('action.openIn.lastfm'),
}}
variant="subtle"
>
<FaLastfmSquare size={25} />
</Button>
{mbzId ? (
<Button
compact
component="a"
href={`https://musicbrainz.org/artist/${mbzId}`}
radius="md"
rel="noopener noreferrer"
size="md"
target="_blank"
tooltip={{
label: t('action.openIn.musicbrainz'),
}}
variant="subtle"
>
<SiMusicbrainz size={25} />
</Button>
) : null}
</Group>
</Box>
) : null}
{showBiography ? (
<Box
component="section"
@ -422,11 +465,8 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
>
About {detailQuery?.data?.name}
</TextTitle>
<Text
$secondary
component="p"
<Spoiler
dangerouslySetInnerHTML={{ __html: detailQuery?.data?.biography || '' }}
sx={{ textAlign: 'justify' }}
/>
</Box>
) : null}

View File

@ -268,6 +268,26 @@ export const ControlSettings = () => {
isHidden: false,
title: t('setting.skipPlaylistPage', { postProcess: 'sentenceCase' }),
},
{
control: (
<Switch
defaultChecked={settings.externalLinks}
onChange={(e) => {
setSettings({
general: {
...settings,
externalLinks: e.currentTarget.checked,
},
});
}}
/>
),
description: t('setting.externalLinks', {
context: 'description',
postProcess: 'sentenceCase',
}),
title: t('setting.externalLinks', { postProcess: 'sentenceCase' }),
},
];
return <SettingsSection options={controlOptions} />;

View File

@ -5,3 +5,4 @@ export * from './use-container-query';
export * from './use-fast-average-color';
export * from './use-hide-scrollbar';
export * from './use-app-focus';
export * from './use-is-overflow';

View File

@ -0,0 +1,20 @@
import { MutableRefObject, useState, useLayoutEffect } from 'react';
export const useIsOverflow = (ref: MutableRefObject<HTMLDivElement | null>) => {
const [isOverflow, setIsOverflow] = useState<Boolean | undefined>(undefined);
useLayoutEffect(() => {
const { current } = ref;
const trigger = () => {
const hasOverflow = (current?.scrollHeight || 0) > (current?.clientHeight || 0);
setIsOverflow(hasOverflow);
};
if (current) {
trigger();
}
}, [ref]);
return isOverflow;
};

View File

@ -170,6 +170,7 @@ export interface SettingsState {
general: {
accent: string;
defaultFullPlaylist: boolean;
externalLinks: boolean;
followSystemTheme: boolean;
language: string;
playButtonBehavior: Play;
@ -282,6 +283,7 @@ const initialState: SettingsState = {
general: {
accent: 'rgb(53, 116, 252)',
defaultFullPlaylist: true,
externalLinks: true,
followSystemTheme: false,
language: 'en',
playButtonBehavior: Play.NOW,

View File

@ -0,0 +1,38 @@
// Inspired by https://github.com/navidrome/navidrome/blob/c530ccf13854e3a840ddf63eef5e2323fbe2827d/ui/src/common/AnchorMe.js
const URL_REGEX =
/((?:https?:\/\/)?(?:[\w-]{1,32}(?:\.[\w-]{1,32})+)(?:\/[\w\-./?%&=][^.|^\s]*)?)/g;
export const replaceURLWithHTMLLinks = (text: string) => {
const urlRegex = new RegExp(URL_REGEX, 'g');
const matches = text.matchAll(urlRegex);
const elements = [];
let lastIndex = 0;
for (const match of matches) {
const position = match.index!;
if (position > lastIndex) {
elements.push(text.substring(lastIndex, position));
}
const link = match[0];
elements.push(
<a
key={lastIndex}
href={link}
rel="noopener noreferrer"
target="_blank"
>
{link}
</a>,
);
lastIndex = position + link.length;
}
if (text.length > lastIndex) {
elements.push(text.substring(lastIndex));
}
return elements;
};