feat(playlist): support updating playlist track order (#1875)

* feat(playlist): support updating playlist track order

* force track mode when editing

* use common confirmation for save

* remove en editPLaylist key
This commit is contained in:
Kendall Garner
2026-03-28 04:36:08 +00:00
committed by GitHub
parent 86e6b88555
commit 68e6e3cf65
12 changed files with 150 additions and 160 deletions
-1
View File
@@ -371,7 +371,6 @@
}, },
"editPlaylist": { "editPlaylist": {
"publicJellyfinNote": "Jellyfin for some reason does not expose whether a playlist is public or not. If you wish for this to remain public, please have the following input selected", "publicJellyfinNote": "Jellyfin for some reason does not expose whether a playlist is public or not. If you wish for this to remain public, please have the following input selected",
"editNote": "manual edits are not recommended for large playlists. are you sure you accept the risk of data loss incurred by overwriting the existing playlist?",
"success": "$t(entity.playlist, {\"count\": 1}) updated successfully", "success": "$t(entity.playlist, {\"count\": 1}) updated successfully",
"title": "edit $t(entity.playlist, {\"count\": 1})" "title": "edit $t(entity.playlist, {\"count\": 1})"
}, },
+16
View File
@@ -10,6 +10,8 @@ import {
ControllerEndpoint, ControllerEndpoint,
InternalControllerEndpoint, InternalControllerEndpoint,
ServerType, ServerType,
SetPlaylistSongsArgs,
SetPlaylistSongsResponse,
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
type ApiController = { type ApiController = {
@@ -885,6 +887,20 @@ export const controller: GeneralController = {
}), }),
); );
}, },
setPlaylistSongs: function (args: SetPlaylistSongsArgs): Promise<SetPlaylistSongsResponse> {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: setPlaylistSongs`,
);
}
return apiController(
'setPlaylistSongs',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
setRating(args) { setRating(args) {
const server = getServerById(args.apiClientProps.serverId); const server = getServerById(args.apiClientProps.serverId);
@@ -1769,6 +1769,24 @@ export const JellyfinController: InternalControllerEndpoint = {
), ),
}; };
}, },
setPlaylistSongs: async (args) => {
const { apiClientProps, body } = args;
const res = await jfApiClient(apiClientProps).updatePlaylist({
body: {
Ids: body.songIds,
},
params: {
id: body.id,
},
});
if (res.status !== 204) {
throw new Error('Failed to update playlist songs');
}
return null;
},
updateInternetRadioStation: async (args) => { updateInternetRadioStation: async (args) => {
const { apiClientProps, body, query } = args; const { apiClientProps, body, query } = args;
@@ -1798,14 +1816,8 @@ export const JellyfinController: InternalControllerEndpoint = {
const res = await jfApiClient(apiClientProps).updatePlaylist({ const res = await jfApiClient(apiClientProps).updatePlaylist({
body: { body: {
Genres: body.genres?.map((item) => ({ Id: item.id, Name: item.name })) || [],
IsPublic: body.public, IsPublic: body.public,
MediaType: 'Audio',
Name: body.name, Name: body.name,
PremiereDate: null,
ProviderIds: {},
Tags: [],
UserId: apiClientProps.server?.userId, // Required
}, },
params: { params: {
id: query.id, id: query.id,
@@ -1820,31 +1832,6 @@ export const JellyfinController: InternalControllerEndpoint = {
}, },
}; };
// const getArtistList = async (args: ArtistListArgs): Promise<AlbumArtistListResponse> => {
// const { query, apiClientProps } = args;
// const res = await jfApiClient(apiClientProps).getAlbumArtistList({
// query: {
// Limit: query.limit,
// ParentId: query.musicFolderId,
// Recursive: true,
// SortBy: artistListSortMap.jellyfin[query.sortBy] || 'SortName,Name',
// SortOrder: sortOrderMap.jellyfin[query.sortOrder],
// StartIndex: query.startIndex,
// },
// });
// if (res.status !== 200) {
// throw new Error('Failed to get artist list');
// }
// return {
// items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)),
// startIndex: query.startIndex,
// totalRecordCount: res.body.TotalRecordCount,
// };
// };
function getLibraryId(musicFolderId?: string | string[]) { function getLibraryId(musicFolderId?: string | string[]) {
return Array.isArray(musicFolderId) ? musicFolderId[0] : musicFolderId; return Array.isArray(musicFolderId) ? musicFolderId[0] : musicFolderId;
} }
@@ -745,7 +745,6 @@ export const NavidromeController: InternalControllerEndpoint = {
args.context?.pathReplaceWith, args.context?.pathReplaceWith,
); );
}, },
getSongList: async (args) => { getSongList: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
@@ -819,6 +818,7 @@ export const NavidromeController: InternalControllerEndpoint = {
totalRecordCount: albums.totalRecordCount, totalRecordCount: albums.totalRecordCount,
}; };
}, },
getSongListCount: async ({ apiClientProps, query }) => getSongListCount: async ({ apiClientProps, query }) =>
NavidromeController.getSongList({ NavidromeController.getSongList({
apiClientProps, apiClientProps,
@@ -1122,6 +1122,7 @@ export const NavidromeController: InternalControllerEndpoint = {
}, },
scrobble: SubsonicController.scrobble, scrobble: SubsonicController.scrobble,
search: SubsonicController.search, search: SubsonicController.search,
setPlaylistSongs: SubsonicController.setPlaylistSongs,
setRating: SubsonicController.setRating, setRating: SubsonicController.setRating,
shareItem: async (args) => { shareItem: async (args) => {
const { apiClientProps, body } = args; const { apiClientProps, body } = args;
@@ -2118,6 +2118,22 @@ export const SubsonicController: InternalControllerEndpoint = {
), ),
}; };
}, },
setPlaylistSongs: async (args) => {
const { apiClientProps, body } = args;
const res = await ssApiClient(apiClientProps).createPlaylist({
query: {
playlistId: body.id,
songId: body.songIds,
},
});
if (res.status !== 200) {
throw new Error('Failed to update playlist songs');
}
return null;
},
setRating: async (args) => { setRating: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
@@ -302,9 +302,9 @@ const PlaylistDetailTrackViewContent = ({ data }: { data: PlaylistSongListRespon
}; };
const PlaylistDetailSongList = ({ data }: { data: PlaylistSongListResponse }) => { const PlaylistDetailSongList = ({ data }: { data: PlaylistSongListResponse }) => {
const { displayMode } = useListContext(); const { displayMode, mode } = useListContext();
if (displayMode === LibraryItem.ALBUM) { if (mode !== 'edit' && displayMode === LibraryItem.ALBUM) {
return <PlaylistDetailAlbumView data={data} />; return <PlaylistDetailAlbumView data={data} />;
} }
@@ -44,13 +44,7 @@ import { Modal } from '/@/shared/components/modal/modal';
import { Tooltip } from '/@/shared/components/tooltip/tooltip'; import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { useDisclosure } from '/@/shared/hooks/use-disclosure'; import { useDisclosure } from '/@/shared/hooks/use-disclosure';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage'; import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
import { import { LibraryItem, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types';
LibraryItem,
Playlist,
SongListSort,
SortOrder,
UpdatePlaylistBody,
} from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
interface PlaylistDetailSongListHeaderFiltersProps { interface PlaylistDetailSongListHeaderFiltersProps {
@@ -124,7 +118,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
isSmartPlaylist, isSmartPlaylist,
}: PlaylistDetailSongListHeaderFiltersProps) => { }: PlaylistDetailSongListHeaderFiltersProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { listKey: listKeyFromContext, mode, setMode } = useListContext(); const { listData, listKey: listKeyFromContext, mode, setMode } = useListContext();
const { playlistId } = useParams() as { playlistId: string }; const { playlistId } = useParams() as { playlistId: string };
const playlistTarget = usePlaylistTarget(); const playlistTarget = usePlaylistTarget();
const { setPlaylistBehavior } = useSettingsStoreActions(); const { setPlaylistBehavior } = useSettingsStoreActions();
@@ -170,10 +164,19 @@ export const PlaylistDetailSongListHeaderFilters = ({
key: 'playlist-header-collapsed', key: 'playlist-header-collapsed',
}); });
const tracks = useMemo(() => {
if (!listData?.length) {
return [];
}
return (listData as Song[]).map((song) => song.id);
}, [listData]);
return ( return (
<Flex justify="space-between" ref={containerRef}> <Flex justify="space-between" ref={containerRef}>
<Group gap="sm" w="100%"> <Group gap="sm" w="100%">
<Button <Button
disabled={isEditMode}
leftSection={<Icon icon="arrowLeftRight" />} leftSection={<Icon icon="arrowLeftRight" />}
onClick={handleToggleDisplayMode} onClick={handleToggleDisplayMode}
variant="subtle" variant="subtle"
@@ -199,15 +202,15 @@ export const PlaylistDetailSongListHeaderFilters = ({
<MoreButton onClick={handleMore} /> <MoreButton onClick={handleMore} />
</Group> </Group>
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
{isViewEditMode && <SaveAndReplaceButton mode={mode} playlist={detailQuery.data} />} {isViewEditMode && <SaveAndReplaceButton mode={mode} songIds={tracks} />}
{isViewEditMode && ( {isViewEditMode && (
<Button <Button
onClick={() => setMode?.(mode === 'edit' ? 'view' : 'edit')} onClick={() => setMode?.(mode === 'edit' ? 'view' : 'edit')}
uppercase uppercase
variant="subtle" variant={mode === 'edit' ? 'state-error' : 'subtle'}
> >
{mode === 'edit' {mode === 'edit'
? t('common.view', { postProcess: 'titleCase' }) ? t('common.cancel', { postProcess: 'titleCase' })
: t('common.edit', { postProcess: 'titleCase' })} : t('common.edit', { postProcess: 'titleCase' })}
</Button> </Button>
)} )}
@@ -248,39 +251,33 @@ export const PlaylistDetailSongListHeaderFilters = ({
); );
}; };
export const openSaveAndReplaceModal = (playlistId: string, updateBody: UpdatePlaylistBody) => { export const openSaveAndReplaceModal = (
playlistId: string,
songIds: string[],
onSuccess: () => void,
) => {
openContextModal({ openContextModal({
innerProps: { playlistId, updateBody }, innerProps: { onSuccess, playlistId, songIds },
modalKey: 'saveAndReplace', modalKey: 'saveAndReplace',
size: 'sm', size: 'sm',
title: i18n.t('common.saveAndReplace', { postProcess: 'titleCase' }) as string, title: i18n.t('common.saveAndReplace', { postProcess: 'titleCase' }) as string,
}); });
}; };
const SaveAndReplaceButton = ({ const SaveAndReplaceButton = ({ mode, songIds }: { mode?: 'edit' | 'view'; songIds: string[] }) => {
mode,
playlist,
}: {
mode: 'edit' | 'view' | undefined;
playlist: Playlist | undefined;
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { playlistId } = useParams() as { playlistId: string }; const { playlistId } = useParams() as { playlistId: string };
const { setMode } = useListContext();
const onSuccess = useCallback(() => {
setMode?.('view');
}, [setMode]);
const handleOpenModal = useCallback(() => { const handleOpenModal = useCallback(() => {
if (!playlistId || !playlist) return; if (!playlistId) return;
const updateBody: UpdatePlaylistBody = { openSaveAndReplaceModal(playlistId, songIds, onSuccess);
comment: playlist.description ?? '', }, [playlistId, songIds, onSuccess]);
name: playlist.name,
ownerId: playlist.ownerId ?? '',
public: playlist.public ?? false,
queryBuilderRules: playlist.rules ?? undefined,
sync: playlist.sync ?? false,
};
openSaveAndReplaceModal(playlistId, updateBody);
}, [playlistId, playlist]);
if (mode === 'view') { if (mode === 'view') {
return null; return null;
@@ -297,78 +294,3 @@ const SaveAndReplaceButton = ({
</Button> </Button>
); );
}; };
// const GenreFilterSelection = () => {
// const { t } = useTranslation();
// const { playlistId } = useParams() as { playlistId: string };
// const serverId = useCurrentServerId();
// const { data } = useQuery(playlistsQueries.songList({ query: { id: playlistId }, serverId }));
// const genres = useMemo(() => {
// const uniqueGenres = new Map<string, string>();
// data?.items.forEach((song) => {
// song.genres.forEach((genre) => {
// if (genre.id) {
// uniqueGenres.set(genre.id, genre.name);
// }
// });
// });
// return Array.from(uniqueGenres.entries()).map(([id, name]) => ({
// label: name,
// value: id,
// }));
// }, [data?.items]);
// return (
// <Stack p="md" style={{ background: 'var(--theme-colors-surface)', height: '12rem' }}>
// <Text>{t('filter.genre', { postProcess: 'titleCase' })}</Text>
// <ScrollArea>
// <ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
// {genres.map((genre) => (
// <li key={genre.value}>{genre.label}</li>
// ))}
// </ul>
// </ScrollArea>
// </Stack>
// );
// };
// const ArtistFilterSelection = () => {
// const { t } = useTranslation();
// const { playlistId } = useParams() as { playlistId: string };
// const serverId = useCurrentServerId();
// const { data } = useQuery(playlistsQueries.songList({ query: { id: playlistId }, serverId }));
// const artists = useMemo(() => {
// const uniqueArtists = new Map<string, string>();
// data?.items.forEach((song) => {
// song.artists.forEach((artist) => {
// if (artist.id) {
// uniqueArtists.set(artist.id, artist.name);
// }
// });
// });
// return Array.from(uniqueArtists.entries()).map(([id, name]) => ({
// label: name,
// value: id,
// }));
// }, [data?.items]);
// return (
// <Stack style={{ height: '12rem' }}>
// <Text>{t('filter.artist', { postProcess: 'titleCase' })}</Text>
// <ScrollArea>
// <ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
// {artists.map((artist) => (
// <li key={artist.value}>{artist.label}</li>
// ))}
// </ul>
// </ScrollArea>
// </Stack>
// );
// };
@@ -2,21 +2,20 @@ import { closeAllModals, ContextModalProps } from '@mantine/modals';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation'; import { useUpdatePlaylistTracks } from '/@/renderer/features/playlists/mutations/update-playlist-tracks-mutation';
import { useCurrentServerId } from '/@/renderer/store'; import { useCurrentServerId } from '/@/renderer/store';
import { ConfirmModal } from '/@/shared/components/modal/modal'; import { ConfirmModal } from '/@/shared/components/modal/modal';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { UpdatePlaylistBody } from '/@/shared/types/domain-types';
export const SaveAndReplaceContextModal = ({ export const SaveAndReplaceContextModal = ({
innerProps, innerProps,
}: ContextModalProps<{ playlistId: string; updateBody: UpdatePlaylistBody }>) => { }: ContextModalProps<{ onSuccess: () => void; playlistId: string; songIds: string[] }>) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { playlistId, updateBody } = innerProps; const { onSuccess, playlistId, songIds } = innerProps;
const serverId = useCurrentServerId(); const serverId = useCurrentServerId();
const updatePlaylistMutation = useUpdatePlaylist({}); const updatePlaylistMutation = useUpdatePlaylistTracks({});
const handleConfirm = useCallback(() => { const handleConfirm = useCallback(() => {
if (!serverId || !playlistId) { if (!serverId || !playlistId) {
@@ -27,8 +26,10 @@ export const SaveAndReplaceContextModal = ({
updatePlaylistMutation.mutate( updatePlaylistMutation.mutate(
{ {
apiClientProps: { serverId }, apiClientProps: { serverId },
body: updateBody, body: {
query: { id: playlistId }, id: playlistId,
songIds,
},
}, },
{ {
onError: (err) => { onError: (err) => {
@@ -41,6 +42,7 @@ export const SaveAndReplaceContextModal = ({
}); });
}, },
onSuccess: () => { onSuccess: () => {
onSuccess();
closeAllModals(); closeAllModals();
toast.success({ toast.success({
message: t('form.editPlaylist.success', { message: t('form.editPlaylist.success', {
@@ -50,11 +52,11 @@ export const SaveAndReplaceContextModal = ({
}, },
}, },
); );
}, [t, serverId, playlistId, updateBody, updatePlaylistMutation]); }, [serverId, playlistId, updatePlaylistMutation, songIds, t, onSuccess]);
return ( return (
<ConfirmModal loading={updatePlaylistMutation.isPending} onConfirm={handleConfirm}> <ConfirmModal loading={updatePlaylistMutation.isPending} onConfirm={handleConfirm}>
<Text>{t('form.editPlaylist.editNote', { postProcess: 'sentenceCase' })}</Text> <Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>
</ConfirmModal> </ConfirmModal>
); );
}; };
@@ -0,0 +1,40 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { SetPlaylistSongsArgs } from '/@/shared/types/domain-types';
export const useUpdatePlaylistTracks = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
return useMutation<null, AxiosError, SetPlaylistSongsArgs, null>({
mutationFn: (args) =>
api.controller.setPlaylistSongs({
...args,
apiClientProps: { serverId: args.apiClientProps.serverId },
}),
onSuccess: (_data, variables) => {
const { apiClientProps, body } = variables;
const serverId = apiClientProps.serverId;
if (!serverId) return;
queryClient.invalidateQueries({
queryKey: queryKeys.playlists.list(serverId),
});
if (body?.id) {
queryClient.invalidateQueries({
queryKey: queryKeys.playlists.detail(serverId, body.id),
});
queryClient.invalidateQueries({
queryKey: queryKeys.playlists.songList(serverId, body.id),
});
}
},
...options,
});
};
+2 -7
View File
@@ -683,14 +683,9 @@ const createPlaylist = z.object({
const updatePlaylist = z.null(); const updatePlaylist = z.null();
const updatePlaylistParameters = z.object({ const updatePlaylistParameters = z.object({
Genres: z.array(genreItem), Ids: z.string().array().optional(),
IsPublic: z.boolean().optional(), IsPublic: z.boolean().optional(),
MediaType: z.literal('Audio'), Name: z.string().optional(),
Name: z.string(),
PremiereDate: z.null(),
ProviderIds: z.object({}),
Tags: z.array(genericItem),
UserId: z.string(),
}); });
const addToPlaylist = z.object({ const addToPlaylist = z.object({
+1 -1
View File
@@ -467,7 +467,7 @@ const deletePlaylistParameters = z.object({
}); });
const createPlaylistParameters = z.object({ const createPlaylistParameters = z.object({
name: z.string(), name: z.string().optional(),
playlistId: z.string().optional(), playlistId: z.string().optional(),
songId: z.array(z.string()).optional(), songId: z.array(z.string()).optional(),
}); });
+13 -1
View File
@@ -1084,7 +1084,6 @@ export type UpdatePlaylistArgs = BaseEndpointArgs & {
export type UpdatePlaylistBody = { export type UpdatePlaylistBody = {
_custom?: Record<string, any>; _custom?: Record<string, any>;
comment?: string; comment?: string;
genres?: Genre[];
name: string; name: string;
ownerId?: string; ownerId?: string;
public?: boolean; public?: boolean;
@@ -1430,6 +1429,7 @@ export type ControllerEndpoint = {
savePlayQueue: (args: SaveQueueArgs) => Promise<void>; savePlayQueue: (args: SaveQueueArgs) => Promise<void>;
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>; scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
search: (args: SearchArgs) => Promise<SearchResponse>; search: (args: SearchArgs) => Promise<SearchResponse>;
setPlaylistSongs: (args: SetPlaylistSongsArgs) => Promise<SetPlaylistSongsResponse>;
setRating?: (args: SetRatingArgs) => Promise<RatingResponse>; setRating?: (args: SetRatingArgs) => Promise<RatingResponse>;
shareItem?: (args: ShareItemArgs) => Promise<ShareItemResponse>; shareItem?: (args: ShareItemArgs) => Promise<ShareItemResponse>;
updateInternetRadioStation: ( updateInternetRadioStation: (
@@ -1581,6 +1581,9 @@ export type InternalControllerEndpoint = {
savePlayQueue: (args: ReplaceApiClientProps<SaveQueueArgs>) => Promise<void>; savePlayQueue: (args: ReplaceApiClientProps<SaveQueueArgs>) => Promise<void>;
scrobble: (args: ReplaceApiClientProps<ScrobbleArgs>) => Promise<ScrobbleResponse>; scrobble: (args: ReplaceApiClientProps<ScrobbleArgs>) => Promise<ScrobbleResponse>;
search: (args: ReplaceApiClientProps<SearchArgs>) => Promise<SearchResponse>; search: (args: ReplaceApiClientProps<SearchArgs>) => Promise<SearchResponse>;
setPlaylistSongs: (
args: ReplaceApiClientProps<SetPlaylistSongsArgs>,
) => Promise<SetPlaylistSongsResponse>;
setRating?: (args: ReplaceApiClientProps<SetRatingArgs>) => Promise<RatingResponse>; setRating?: (args: ReplaceApiClientProps<SetRatingArgs>) => Promise<RatingResponse>;
shareItem?: (args: ReplaceApiClientProps<ShareItemArgs>) => Promise<ShareItemResponse>; shareItem?: (args: ReplaceApiClientProps<ShareItemArgs>) => Promise<ShareItemResponse>;
updateInternetRadioStation: ( updateInternetRadioStation: (
@@ -1637,6 +1640,15 @@ export type ServerInfo = {
export type ServerInfoArgs = BaseEndpointArgs; export type ServerInfoArgs = BaseEndpointArgs;
export type SetPlaylistSongsArgs = BaseEndpointArgs & { body: SetPlaylistSongsQuery };
export type SetPlaylistSongsQuery = {
id: string;
songIds: string[];
};
export type SetPlaylistSongsResponse = null;
export type SimilarSongsArgs = BaseEndpointArgs & { export type SimilarSongsArgs = BaseEndpointArgs & {
query: SimilarSongsQuery; query: SimilarSongsQuery;
}; };