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",
|
"apiRouteError": "unable to route request",
|
||||||
"audioDeviceFetchError": "an error occurred when trying to get audio devices",
|
"audioDeviceFetchError": "an error occurred when trying to get audio devices",
|
||||||
"authenticationFailed": "authentication failed",
|
"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",
|
"credentialsRequired": "credentials required",
|
||||||
"endpointNotImplementedError": "endpoint {{endpoint}} is not implemented for {{serverType}}",
|
"endpointNotImplementedError": "endpoint {{endpoint}} is not implemented for {{serverType}}",
|
||||||
"genericError": "an error occurred",
|
"genericError": "an error occurred",
|
||||||
|
@ -185,6 +185,15 @@ export const contract = c.router({
|
|||||||
400: jfType._response.error,
|
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: {
|
getSongDetail: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'users/:userId/items/:id',
|
path: 'users/:userId/items/:id',
|
||||||
|
@ -134,7 +134,7 @@ const normalizeSong = (
|
|||||||
imageUrl: null,
|
imageUrl: null,
|
||||||
name: entry.Name,
|
name: entry.Name,
|
||||||
})),
|
})),
|
||||||
albumId: item.AlbumId,
|
albumId: item.AlbumId || `dummy/${item.Id}`,
|
||||||
artistName: item?.ArtistItems?.[0]?.Name,
|
artistName: item?.ArtistItems?.[0]?.Name,
|
||||||
artists: item?.ArtistItems?.map((entry) => ({
|
artists: item?.ArtistItems?.map((entry) => ({
|
||||||
id: entry.Id,
|
id: entry.Id,
|
||||||
|
@ -387,11 +387,13 @@ const genericItem = z.object({
|
|||||||
Name: z.string(),
|
Name: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const songDetailParameters = baseParameters;
|
||||||
|
|
||||||
const song = z.object({
|
const song = z.object({
|
||||||
Album: z.string(),
|
Album: z.string(),
|
||||||
AlbumArtist: z.string(),
|
AlbumArtist: z.string(),
|
||||||
AlbumArtists: z.array(genericItem),
|
AlbumArtists: z.array(genericItem),
|
||||||
AlbumId: z.string(),
|
AlbumId: z.string().optional(),
|
||||||
AlbumPrimaryImageTag: z.string(),
|
AlbumPrimaryImageTag: z.string(),
|
||||||
ArtistItems: z.array(genericItem),
|
ArtistItems: z.array(genericItem),
|
||||||
Artists: z.array(z.string()),
|
Artists: z.array(z.string()),
|
||||||
@ -709,6 +711,7 @@ export const jfType = {
|
|||||||
search: searchParameters,
|
search: searchParameters,
|
||||||
similarArtistList: similarArtistListParameters,
|
similarArtistList: similarArtistListParameters,
|
||||||
similarSongs: similarSongsParameters,
|
similarSongs: similarSongsParameters,
|
||||||
|
songDetail: songDetailParameters,
|
||||||
songList: songListParameters,
|
songList: songListParameters,
|
||||||
updatePlaylist: updatePlaylistParameters,
|
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' },
|
{ 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 = [
|
export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||||
{ id: 'play' },
|
{ id: 'play' },
|
||||||
{ id: 'playLast' },
|
{ id: 'playLast' },
|
||||||
|
@ -55,6 +55,10 @@ const AlbumDetailRoute = lazy(
|
|||||||
() => import('/@/renderer/features/albums/routes/album-detail-route'),
|
() => 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 GenreListRoute = lazy(() => import('/@/renderer/features/genres/routes/genre-list-route'));
|
||||||
|
|
||||||
const SettingsRoute = lazy(() => import('/@/renderer/features/settings/routes/settings-route'));
|
const SettingsRoute = lazy(() => import('/@/renderer/features/settings/routes/settings-route'));
|
||||||
@ -144,6 +148,11 @@ export const AppRouter = () => {
|
|||||||
errorElement={<RouteErrorBoundary />}
|
errorElement={<RouteErrorBoundary />}
|
||||||
path={AppRoute.LIBRARY_ALBUMS_DETAIL}
|
path={AppRoute.LIBRARY_ALBUMS_DETAIL}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
element={<DummyAlbumDetailRoute />}
|
||||||
|
errorElement={<RouteErrorBoundary />}
|
||||||
|
path={AppRoute.FAKE_LIBRARY_ALBUM_DETAILS}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
element={<SongListRoute />}
|
element={<SongListRoute />}
|
||||||
errorElement={<RouteErrorBoundary />}
|
errorElement={<RouteErrorBoundary />}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
export enum AppRoute {
|
export enum AppRoute {
|
||||||
ACTION_REQUIRED = '/action-required',
|
ACTION_REQUIRED = '/action-required',
|
||||||
EXPLORE = '/explore',
|
EXPLORE = '/explore',
|
||||||
|
FAKE_LIBRARY_ALBUM_DETAILS = '/library/albums/dummy/:albumId',
|
||||||
HOME = '/',
|
HOME = '/',
|
||||||
LIBRARY_ALBUMS = '/library/albums',
|
LIBRARY_ALBUMS = '/library/albums',
|
||||||
LIBRARY_ALBUMS_DETAIL = '/library/albums/:albumId',
|
LIBRARY_ALBUMS_DETAIL = '/library/albums/:albumId',
|
||||||
|
Loading…
Reference in New Issue
Block a user