diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 99d83c201..b82c4d431 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -371,7 +371,6 @@ }, "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", - "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", "title": "edit $t(entity.playlist, {\"count\": 1})" }, diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 31061418b..a9362248c 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -10,6 +10,8 @@ import { ControllerEndpoint, InternalControllerEndpoint, ServerType, + SetPlaylistSongsArgs, + SetPlaylistSongsResponse, } from '/@/shared/types/domain-types'; type ApiController = { @@ -885,6 +887,20 @@ export const controller: GeneralController = { }), ); }, + setPlaylistSongs: function (args: SetPlaylistSongsArgs): Promise { + 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) { const server = getServerById(args.apiClientProps.serverId); diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 13052e9d0..6f0f877cd 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -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) => { const { apiClientProps, body, query } = args; @@ -1798,14 +1816,8 @@ export const JellyfinController: InternalControllerEndpoint = { const res = await jfApiClient(apiClientProps).updatePlaylist({ body: { - Genres: body.genres?.map((item) => ({ Id: item.id, Name: item.name })) || [], IsPublic: body.public, - MediaType: 'Audio', Name: body.name, - PremiereDate: null, - ProviderIds: {}, - Tags: [], - UserId: apiClientProps.server?.userId, // Required }, params: { id: query.id, @@ -1820,31 +1832,6 @@ export const JellyfinController: InternalControllerEndpoint = { }, }; -// const getArtistList = async (args: ArtistListArgs): Promise => { -// 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[]) { return Array.isArray(musicFolderId) ? musicFolderId[0] : musicFolderId; } diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 1c4e1e68f..4a0e398f6 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -745,7 +745,6 @@ export const NavidromeController: InternalControllerEndpoint = { args.context?.pathReplaceWith, ); }, - getSongList: async (args) => { const { apiClientProps, query } = args; @@ -819,6 +818,7 @@ export const NavidromeController: InternalControllerEndpoint = { totalRecordCount: albums.totalRecordCount, }; }, + getSongListCount: async ({ apiClientProps, query }) => NavidromeController.getSongList({ apiClientProps, @@ -1122,6 +1122,7 @@ export const NavidromeController: InternalControllerEndpoint = { }, scrobble: SubsonicController.scrobble, search: SubsonicController.search, + setPlaylistSongs: SubsonicController.setPlaylistSongs, setRating: SubsonicController.setRating, shareItem: async (args) => { const { apiClientProps, body } = args; diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 35c777912..73639fef0 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -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) => { const { apiClientProps, query } = args; diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx index f8a23825e..08b0a53d2 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx @@ -302,9 +302,9 @@ const PlaylistDetailTrackViewContent = ({ data }: { data: PlaylistSongListRespon }; const PlaylistDetailSongList = ({ data }: { data: PlaylistSongListResponse }) => { - const { displayMode } = useListContext(); + const { displayMode, mode } = useListContext(); - if (displayMode === LibraryItem.ALBUM) { + if (mode !== 'edit' && displayMode === LibraryItem.ALBUM) { return ; } diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx index 75da1b589..633d8e5b1 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx @@ -44,13 +44,7 @@ import { Modal } from '/@/shared/components/modal/modal'; import { Tooltip } from '/@/shared/components/tooltip/tooltip'; import { useDisclosure } from '/@/shared/hooks/use-disclosure'; import { useLocalStorage } from '/@/shared/hooks/use-local-storage'; -import { - LibraryItem, - Playlist, - SongListSort, - SortOrder, - UpdatePlaylistBody, -} from '/@/shared/types/domain-types'; +import { LibraryItem, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types'; import { ItemListKey } from '/@/shared/types/types'; interface PlaylistDetailSongListHeaderFiltersProps { @@ -124,7 +118,7 @@ export const PlaylistDetailSongListHeaderFilters = ({ isSmartPlaylist, }: PlaylistDetailSongListHeaderFiltersProps) => { const { t } = useTranslation(); - const { listKey: listKeyFromContext, mode, setMode } = useListContext(); + const { listData, listKey: listKeyFromContext, mode, setMode } = useListContext(); const { playlistId } = useParams() as { playlistId: string }; const playlistTarget = usePlaylistTarget(); const { setPlaylistBehavior } = useSettingsStoreActions(); @@ -170,10 +164,19 @@ export const PlaylistDetailSongListHeaderFilters = ({ key: 'playlist-header-collapsed', }); + const tracks = useMemo(() => { + if (!listData?.length) { + return []; + } + + return (listData as Song[]).map((song) => song.id); + }, [listData]); + return ( )} @@ -248,39 +251,33 @@ export const PlaylistDetailSongListHeaderFilters = ({ ); }; -export const openSaveAndReplaceModal = (playlistId: string, updateBody: UpdatePlaylistBody) => { +export const openSaveAndReplaceModal = ( + playlistId: string, + songIds: string[], + onSuccess: () => void, +) => { openContextModal({ - innerProps: { playlistId, updateBody }, + innerProps: { onSuccess, playlistId, songIds }, modalKey: 'saveAndReplace', size: 'sm', title: i18n.t('common.saveAndReplace', { postProcess: 'titleCase' }) as string, }); }; -const SaveAndReplaceButton = ({ - mode, - playlist, -}: { - mode: 'edit' | 'view' | undefined; - playlist: Playlist | undefined; -}) => { +const SaveAndReplaceButton = ({ mode, songIds }: { mode?: 'edit' | 'view'; songIds: string[] }) => { const { t } = useTranslation(); const { playlistId } = useParams() as { playlistId: string }; + const { setMode } = useListContext(); + + const onSuccess = useCallback(() => { + setMode?.('view'); + }, [setMode]); const handleOpenModal = useCallback(() => { - if (!playlistId || !playlist) return; + if (!playlistId) return; - const updateBody: UpdatePlaylistBody = { - comment: playlist.description ?? '', - name: playlist.name, - ownerId: playlist.ownerId ?? '', - public: playlist.public ?? false, - queryBuilderRules: playlist.rules ?? undefined, - sync: playlist.sync ?? false, - }; - - openSaveAndReplaceModal(playlistId, updateBody); - }, [playlistId, playlist]); + openSaveAndReplaceModal(playlistId, songIds, onSuccess); + }, [playlistId, songIds, onSuccess]); if (mode === 'view') { return null; @@ -297,78 +294,3 @@ const SaveAndReplaceButton = ({ ); }; -// 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(); - -// 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 ( -// -// {t('filter.genre', { postProcess: 'titleCase' })} -// -//
    -// {genres.map((genre) => ( -//
  • {genre.label}
  • -// ))} -//
-//
-//
-// ); -// }; - -// 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(); - -// 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 ( -// -// {t('filter.artist', { postProcess: 'titleCase' })} -// -//
    -// {artists.map((artist) => ( -//
  • {artist.label}
  • -// ))} -//
-//
-//
-// ); -// }; diff --git a/src/renderer/features/playlists/components/save-and-replace-context-modal.tsx b/src/renderer/features/playlists/components/save-and-replace-context-modal.tsx index 065929f90..783119128 100644 --- a/src/renderer/features/playlists/components/save-and-replace-context-modal.tsx +++ b/src/renderer/features/playlists/components/save-and-replace-context-modal.tsx @@ -2,21 +2,20 @@ import { closeAllModals, ContextModalProps } from '@mantine/modals'; import { useCallback } from 'react'; 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 { ConfirmModal } from '/@/shared/components/modal/modal'; import { Text } from '/@/shared/components/text/text'; import { toast } from '/@/shared/components/toast/toast'; -import { UpdatePlaylistBody } from '/@/shared/types/domain-types'; export const SaveAndReplaceContextModal = ({ innerProps, -}: ContextModalProps<{ playlistId: string; updateBody: UpdatePlaylistBody }>) => { +}: ContextModalProps<{ onSuccess: () => void; playlistId: string; songIds: string[] }>) => { const { t } = useTranslation(); - const { playlistId, updateBody } = innerProps; + const { onSuccess, playlistId, songIds } = innerProps; const serverId = useCurrentServerId(); - const updatePlaylistMutation = useUpdatePlaylist({}); + const updatePlaylistMutation = useUpdatePlaylistTracks({}); const handleConfirm = useCallback(() => { if (!serverId || !playlistId) { @@ -27,8 +26,10 @@ export const SaveAndReplaceContextModal = ({ updatePlaylistMutation.mutate( { apiClientProps: { serverId }, - body: updateBody, - query: { id: playlistId }, + body: { + id: playlistId, + songIds, + }, }, { onError: (err) => { @@ -41,6 +42,7 @@ export const SaveAndReplaceContextModal = ({ }); }, onSuccess: () => { + onSuccess(); closeAllModals(); toast.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 ( - {t('form.editPlaylist.editNote', { postProcess: 'sentenceCase' })} + {t('common.areYouSure', { postProcess: 'sentenceCase' })} ); }; diff --git a/src/renderer/features/playlists/mutations/update-playlist-tracks-mutation.ts b/src/renderer/features/playlists/mutations/update-playlist-tracks-mutation.ts new file mode 100644 index 000000000..9dfcbb769 --- /dev/null +++ b/src/renderer/features/playlists/mutations/update-playlist-tracks-mutation.ts @@ -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({ + 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, + }); +}; diff --git a/src/shared/api/jellyfin/jellyfin-types.ts b/src/shared/api/jellyfin/jellyfin-types.ts index 7a02f3997..074f13059 100644 --- a/src/shared/api/jellyfin/jellyfin-types.ts +++ b/src/shared/api/jellyfin/jellyfin-types.ts @@ -683,14 +683,9 @@ const createPlaylist = z.object({ const updatePlaylist = z.null(); const updatePlaylistParameters = z.object({ - Genres: z.array(genreItem), + Ids: z.string().array().optional(), IsPublic: z.boolean().optional(), - MediaType: z.literal('Audio'), - Name: z.string(), - PremiereDate: z.null(), - ProviderIds: z.object({}), - Tags: z.array(genericItem), - UserId: z.string(), + Name: z.string().optional(), }); const addToPlaylist = z.object({ diff --git a/src/shared/api/subsonic/subsonic-types.ts b/src/shared/api/subsonic/subsonic-types.ts index 849a79891..777d2db9b 100644 --- a/src/shared/api/subsonic/subsonic-types.ts +++ b/src/shared/api/subsonic/subsonic-types.ts @@ -467,7 +467,7 @@ const deletePlaylistParameters = z.object({ }); const createPlaylistParameters = z.object({ - name: z.string(), + name: z.string().optional(), playlistId: z.string().optional(), songId: z.array(z.string()).optional(), }); diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index 6db45a0dd..572f7bf26 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -1084,7 +1084,6 @@ export type UpdatePlaylistArgs = BaseEndpointArgs & { export type UpdatePlaylistBody = { _custom?: Record; comment?: string; - genres?: Genre[]; name: string; ownerId?: string; public?: boolean; @@ -1430,6 +1429,7 @@ export type ControllerEndpoint = { savePlayQueue: (args: SaveQueueArgs) => Promise; scrobble: (args: ScrobbleArgs) => Promise; search: (args: SearchArgs) => Promise; + setPlaylistSongs: (args: SetPlaylistSongsArgs) => Promise; setRating?: (args: SetRatingArgs) => Promise; shareItem?: (args: ShareItemArgs) => Promise; updateInternetRadioStation: ( @@ -1581,6 +1581,9 @@ export type InternalControllerEndpoint = { savePlayQueue: (args: ReplaceApiClientProps) => Promise; scrobble: (args: ReplaceApiClientProps) => Promise; search: (args: ReplaceApiClientProps) => Promise; + setPlaylistSongs: ( + args: ReplaceApiClientProps, + ) => Promise; setRating?: (args: ReplaceApiClientProps) => Promise; shareItem?: (args: ReplaceApiClientProps) => Promise; updateInternetRadioStation: ( @@ -1637,6 +1640,15 @@ export type ServerInfo = { 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 & { query: SimilarSongsQuery; };