mirror of
https://github.com/jeffvli/feishin.git
synced 2024-11-20 06:27:09 +01:00
[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:
parent
620cca9ce3
commit
9cd8807a75
@ -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",
|
||||
|
@ -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',
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
},
|
||||
|
260
src/renderer/features/albums/routes/dummy-album-detail-route.tsx
Normal file
260
src/renderer/features/albums/routes/dummy-album-detail-route.tsx
Normal 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;
|
@ -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' },
|
||||
|
@ -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 />}
|
||||
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user