[bugfix]: Handle top-level songs for Jellyfin (#553)

* [bugfix]: Handle top-level songs for Jellyfin

If a song is at the top level of a music folder, Jellyfin will not
group that into an album (See https://jellyfin.org/docs/general/server/media/music/).

This PR introduces a few changes:
- Gives tracks with no album ID a special route (`/dummy/${id}`)
- Gives a new route for dummy albums, warning about the error. This is designed to look _like_ the album detail page

* `are are` > `are`

* revert name changes
This commit is contained in:
Kendall Garner 2024-04-30 03:18:43 +00:00 committed by GitHub
parent 620cca9ce3
commit 9cd8807a75
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 292 additions and 2 deletions

View File

@ -150,6 +150,7 @@
"apiRouteError": "unable to route request",
"audioDeviceFetchError": "an error occurred when trying to get audio devices",
"authenticationFailed": "authentication failed",
"badAlbum": "you are seeing this page because this song is not part of an album. you are most likely seeing this issue if you have a song at the top level of your music folder. jellyfin only groups tracks if they are in a folder.",
"credentialsRequired": "credentials required",
"endpointNotImplementedError": "endpoint {{endpoint}} is not implemented for {{serverType}}",
"genericError": "an error occurred",

View File

@ -185,6 +185,15 @@ export const contract = c.router({
400: jfType._response.error,
},
},
getSongData: {
method: 'GET',
path: 'users/:userId/items/:id',
query: jfType._parameters.songDetail,
responses: {
200: jfType._response.song,
400: jfType._response.error,
},
},
getSongDetail: {
method: 'GET',
path: 'users/:userId/items/:id',

View File

@ -134,7 +134,7 @@ const normalizeSong = (
imageUrl: null,
name: entry.Name,
})),
albumId: item.AlbumId,
albumId: item.AlbumId || `dummy/${item.Id}`,
artistName: item?.ArtistItems?.[0]?.Name,
artists: item?.ArtistItems?.map((entry) => ({
id: entry.Id,

View File

@ -387,11 +387,13 @@ const genericItem = z.object({
Name: z.string(),
});
const songDetailParameters = baseParameters;
const song = z.object({
Album: z.string(),
AlbumArtist: z.string(),
AlbumArtists: z.array(genericItem),
AlbumId: z.string(),
AlbumId: z.string().optional(),
AlbumPrimaryImageTag: z.string(),
ArtistItems: z.array(genericItem),
Artists: z.array(z.string()),
@ -709,6 +711,7 @@ export const jfType = {
search: searchParameters,
similarArtistList: similarArtistListParameters,
similarSongs: similarSongsParameters,
songDetail: songDetailParameters,
songList: songListParameters,
updatePlaylist: updatePlaylistParameters,
},

View File

@ -0,0 +1,260 @@
import { Button, Spinner, Spoiler, Text } from '/@/renderer/components';
import {
AnimatedPage,
LibraryHeader,
PlayButton,
useCreateFavorite,
useDeleteFavorite,
} from '/@/renderer/features/shared';
import { Fragment } from 'react';
import { generatePath, useParams } from 'react-router';
import { useContainerQuery, useFastAverageColor } from '/@/renderer/hooks';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { LibraryItem, SongDetailResponse } from '/@/renderer/api/types';
import { useCurrentServer } from '/@/renderer/store';
import { Stack, Group, Box, Center } from '@mantine/core';
import { Link } from 'react-router-dom';
import { AppRoute } from '/@/renderer/router/routes';
import { formatDurationString } from '/@/renderer/utils';
import { RiErrorWarningLine, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
import { SONG_ALBUM_PAGE } from '/@/renderer/features/context-menu/context-menu-items';
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu';
import { styled } from 'styled-components';
import { queryClient } from '/@/renderer/lib/react-query';
import { useQuery } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { useTranslation } from 'react-i18next';
const DetailContainer = styled.div`
display: flex;
flex-direction: column;
gap: 2rem;
padding: 1rem 2rem 5rem;
overflow: hidden;
`;
const DummyAlbumDetailRoute = () => {
const cq = useContainerQuery();
const { t } = useTranslation();
const { albumId } = useParams() as { albumId: string };
const server = useCurrentServer();
const queryKey = queryKeys.songs.detail(server?.id || '', albumId);
const detailQuery = useQuery({
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getSongDetail({
apiClientProps: { server, signal },
query: { id: albumId },
});
},
queryKey,
});
const { color: background, colorId } = useFastAverageColor({
id: albumId,
src: detailQuery.data?.imageUrl,
srcLoaded: !detailQuery.isLoading,
});
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
const handleFavorite = async () => {
if (!detailQuery?.data) return;
const wasFavorite = detailQuery.data.userFavorite;
try {
if (wasFavorite) {
await deleteFavoriteMutation.mutateAsync({
query: {
id: [detailQuery.data.id],
type: LibraryItem.SONG,
},
serverId: detailQuery.data.serverId,
});
} else {
await createFavoriteMutation.mutateAsync({
query: {
id: [detailQuery.data.id],
type: LibraryItem.SONG,
},
serverId: detailQuery.data.serverId,
});
}
queryClient.setQueryData<SongDetailResponse>(queryKey, {
...detailQuery.data,
userFavorite: !wasFavorite,
});
} catch (error) {
console.error(error);
}
};
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
const comment = detailQuery?.data?.comment;
const handleGeneralContextMenu = useHandleGeneralContextMenu(LibraryItem.SONG, SONG_ALBUM_PAGE);
const handlePlay = () => {
handlePlayQueueAdd?.({
byItemType: {
id: [albumId],
type: LibraryItem.SONG,
},
playType: playButtonBehavior,
});
};
if (!background || colorId !== albumId) {
return <Spinner container />;
}
const metadataItems = [
{
id: 'releaseYear',
secondary: false,
value: detailQuery?.data?.releaseYear,
},
{
id: 'duration',
secondary: false,
value: detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
},
];
return (
<AnimatedPage key={`dummy-album-detail-${albumId}`}>
<Stack ref={cq.ref}>
<LibraryHeader
background={background}
imageUrl={detailQuery?.data?.imageUrl}
item={{ route: AppRoute.LIBRARY_SONGS, type: LibraryItem.SONG }}
title={detailQuery?.data?.name || ''}
>
<Stack spacing="sm">
<Group spacing="sm">
{metadataItems.map((item, index) => (
<Fragment key={`item-${item.id}-${index}`}>
{index > 0 && <Text $noSelect></Text>}
<Text $secondary={item.secondary}>{item.value}</Text>
</Fragment>
))}
</Group>
<Group
mah="4rem"
spacing="md"
sx={{
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
overflow: 'hidden',
}}
>
{detailQuery?.data?.albumArtists.map((artist) => (
<Text
key={`artist-${artist.id}`}
$link
component={Link}
fw={600}
size="md"
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
variant="subtle"
>
{artist.name}
</Text>
))}
</Group>
</Stack>
</LibraryHeader>
</Stack>
<DetailContainer>
<Box component="section">
<Group
position="apart"
spacing="sm"
>
<Group>
<PlayButton onClick={() => handlePlay()} />
<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>
</Box>
{showGenres && (
<Box component="section">
<Group spacing="sm">
{detailQuery?.data?.genres?.map((genre) => (
<Button
key={`genre-${genre.id}`}
compact
component={Link}
radius={0}
size="md"
to={generatePath(AppRoute.LIBRARY_GENRES_SONGS, {
genreId: genre.id,
})}
variant="outline"
>
{genre.name}
</Button>
))}
</Group>
</Box>
)}
{comment && (
<Box component="section">
<Spoiler maxHeight={75}>{replaceURLWithHTMLLinks(comment)}</Spoiler>
</Box>
)}
<Box component="section">
<Center>
<Group mr={5}>
<RiErrorWarningLine
color="var(--danger-color)"
size={30}
/>
</Group>
<h2>{t('error.badAlbum', { postProcess: 'sentenceCase' })}</h2>
</Center>
</Box>
</DetailContainer>
</AnimatedPage>
);
};
export default DummyAlbumDetailRoute;

View File

@ -24,6 +24,13 @@ export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ divider: true, id: 'showDetails' },
];
export const SONG_ALBUM_PAGE: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ divider: true, id: 'addToPlaylist' },
];
export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },

View File

@ -55,6 +55,10 @@ const AlbumDetailRoute = lazy(
() => import('/@/renderer/features/albums/routes/album-detail-route'),
);
const DummyAlbumDetailRoute = lazy(
() => import('/@/renderer/features/albums/routes/dummy-album-detail-route'),
);
const GenreListRoute = lazy(() => import('/@/renderer/features/genres/routes/genre-list-route'));
const SettingsRoute = lazy(() => import('/@/renderer/features/settings/routes/settings-route'));
@ -144,6 +148,11 @@ export const AppRouter = () => {
errorElement={<RouteErrorBoundary />}
path={AppRoute.LIBRARY_ALBUMS_DETAIL}
/>
<Route
element={<DummyAlbumDetailRoute />}
errorElement={<RouteErrorBoundary />}
path={AppRoute.FAKE_LIBRARY_ALBUM_DETAILS}
/>
<Route
element={<SongListRoute />}
errorElement={<RouteErrorBoundary />}

View File

@ -1,6 +1,7 @@
export enum AppRoute {
ACTION_REQUIRED = '/action-required',
EXPLORE = '/explore',
FAKE_LIBRARY_ALBUM_DETAILS = '/library/albums/dummy/:albumId',
HOME = '/',
LIBRARY_ALBUMS = '/library/albums',
LIBRARY_ALBUMS_DETAIL = '/library/albums/:albumId',