diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 1debe99ed..6262ef233 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -189,6 +189,20 @@ export const controller: GeneralController = { server.type, )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); }, + deletePlaylistImage(args) { + const server = getServerById(args.apiClientProps.serverId); + + if (!server) { + throw new Error( + `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deletePlaylistImage`, + ); + } + + return apiController( + 'deletePlaylistImage', + server.type, + )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); + }, getAlbumArtistDetail(args) { const server = getServerById(args.apiClientProps.serverId); @@ -960,4 +974,18 @@ export const controller: GeneralController = { server.type, )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); }, + uploadPlaylistImage(args) { + const server = getServerById(args.apiClientProps.serverId); + + if (!server) { + throw new Error( + `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: uploadPlaylistImage`, + ); + } + + return apiController( + 'uploadPlaylistImage', + server.type, + )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); + }, }; diff --git a/src/renderer/api/navidrome/navidrome-api.ts b/src/renderer/api/navidrome/navidrome-api.ts index 9391eb8fa..73904c524 100644 --- a/src/renderer/api/navidrome/navidrome-api.ts +++ b/src/renderer/api/navidrome/navidrome-api.ts @@ -55,6 +55,15 @@ export const contract = c.router({ 500: resultWithHeaders(ndType._response.error), }, }, + deletePlaylistImage: { + body: null, + method: 'DELETE', + path: 'playlist/:id/image', + responses: { + 200: resultWithHeaders(ndType._response.deletePlaylistImage), + 500: resultWithHeaders(ndType._response.error), + }, + }, getAlbumArtistDetail: { method: 'GET', path: 'artist/:id', @@ -214,6 +223,15 @@ export const contract = c.router({ 500: resultWithHeaders(ndType._response.error), }, }, + uploadPlaylistImage: { + body: ndType._parameters.uploadPlaylistImage, + method: 'POST', + path: 'playlist/:id/image', + responses: { + 200: resultWithHeaders(ndType._response.uploadPlaylistImage), + 500: resultWithHeaders(ndType._response.error), + }, + }, }); const axiosClient = axios.create({}); diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 4a0e398f6..6ff7df6bd 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -1,3 +1,4 @@ +import axios from 'axios'; import { set } from 'idb-keyval'; import orderBy from 'lodash/orderBy'; @@ -12,6 +13,8 @@ import { albumArtistListSortMap, albumListSortMap, AuthenticationResponse, + DeletePlaylistImageArgs, + DeletePlaylistImageResponse, genreListSortMap, InternalControllerEndpoint, playlistListSortMap, @@ -23,6 +26,8 @@ import { SortOrder, sortOrderMap, tagListSortMap, + UploadPlaylistImageArgs, + UploadPlaylistImageResponse, userListSortMap, } from '/@/shared/types/domain-types'; import { ServerFeature } from '/@/shared/types/features-types'; @@ -30,6 +35,7 @@ import { ServerFeature } from '/@/shared/types/features-types'; const VERSION_INFO: VersionInfo = [ // Why 2? Subsonic controller will return 1 for its own implementation // Use 2 to denote that Navidrome's own API has a different endpoint + ['0.61.0', { [ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1] }], ['0.60.4', { [ServerFeature.TRACK_YES_NO_RATING_FILTER]: [1] }], ['0.57.0', { [ServerFeature.SERVER_PLAY_QUEUE]: [2] }], ['0.56.0', { [ServerFeature.TRACK_ALBUM_ARTIST_SEARCH]: [1] }], @@ -187,6 +193,23 @@ export const NavidromeController: InternalControllerEndpoint = { return null; }, + deletePlaylistImage: async ( + args: DeletePlaylistImageArgs, + ): Promise => { + const { apiClientProps, query } = args; + + const res = await ndApiClient(apiClientProps as any).deletePlaylistImage({ + params: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to delete playlist image'); + } + + return res.body.data.status === 'ok'; + }, getAlbumArtistDetail: async (args) => { const { apiClientProps, query } = args; @@ -1170,4 +1193,40 @@ export const NavidromeController: InternalControllerEndpoint = { return null; }, + uploadPlaylistImage: async ( + args: UploadPlaylistImageArgs, + ): Promise => { + const { apiClientProps, body, query } = args; + + const server = apiClientProps.server; + const serverUrl = server?.url?.replace(/\/$/, ''); + + if (!serverUrl) { + throw new Error('Server is required'); + } + + const form = new FormData(); + const bytes = body.image as Uint8Array; + const fileLike = + typeof File !== 'undefined' + ? new File([bytes], 'image', { type: 'application/octet-stream' }) + : new Blob([bytes], { type: 'application/octet-stream' }); + form.append('image', fileLike as any); + + const res = await axios.post(`${serverUrl}/api/playlist/${query.id}/image`, form, { + headers: { + 'Content-Type': 'multipart/form-data', + ...(server?.ndCredential && { + 'x-nd-authorization': `Bearer ${server.ndCredential}`, + }), + }, + signal: apiClientProps.signal, + }); + + if (res.status !== 200) { + throw new Error('Failed to upload playlist image'); + } + + return res.data?.status === 'ok'; + }, }; diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx index 5f539cf60..b78da0ee9 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-header.tsx @@ -8,6 +8,8 @@ import { useListContext } from '/@/renderer/context/list-context'; import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; import { PlaylistDetailSongListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header-filters'; +import { useDeletePlaylistImage } from '/@/renderer/features/playlists/mutations/delete-playlist-image-mutation'; +import { useUploadPlaylistImage } from '/@/renderer/features/playlists/mutations/upload-playlist-image-mutation'; import { FilterBar } from '/@/renderer/features/shared/components/filter-bar'; import { LibraryHeader, @@ -18,9 +20,14 @@ import { ListSearchInput } from '/@/renderer/features/shared/components/list-sea import { AppRoute } from '/@/renderer/router/routes'; import { useCurrentServer } from '/@/renderer/store'; import { formatDurationString } from '/@/renderer/utils'; +import { hasFeature } from '/@/shared/api/utils'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { FileButton } from '/@/shared/components/file-button/file-button'; +import { Group } from '/@/shared/components/group/group'; import { Stack } from '/@/shared/components/stack/stack'; import { useLocalStorage } from '/@/shared/hooks/use-local-storage'; -import { LibraryItem, Song } from '/@/shared/types/domain-types'; +import { LibraryItem, Playlist, Song } from '/@/shared/types/domain-types'; +import { ServerFeature } from '/@/shared/types/features-types'; import { Play } from '/@/shared/types/types'; interface PlaylistDetailSongListHeaderProps { @@ -30,6 +37,64 @@ interface PlaylistDetailSongListHeaderProps { onToggleQueryBuilder?: () => void; } +function ImageUploadOverlay({ data }: { data?: Playlist }) { + const uploadPlaylistImageMutation = useUploadPlaylistImage({}); + const deletePlaylistImageMutation = useDeletePlaylistImage({}); + const server = useCurrentServer(); + + if (!data) return null; + if (!hasFeature(server, ServerFeature.PLAYLIST_IMAGE_UPLOAD)) return null; + + return ( + + { + if (!file || !data?._serverId) return; + + const buffer = await file.arrayBuffer(); + uploadPlaylistImageMutation.mutate({ + apiClientProps: { + serverId: data._serverId, + }, + body: { image: new Uint8Array(buffer) }, + query: { id: data.id }, + }); + }} + > + {(props) => ( + + )} + + { + e.stopPropagation(); + if (!data?._serverId) return; + deletePlaylistImageMutation.mutate({ + apiClientProps: { + serverId: data._serverId, + }, + query: { id: data.id }, + }); + }} + radius="xl" + size="xs" + variant="default" + /> + + ); +} + export const PlaylistDetailSongListHeader = ({ isSmartPlaylist, }: PlaylistDetailSongListHeaderProps) => { @@ -94,6 +159,7 @@ export const PlaylistDetailSongListHeader = ({ ) : ( } imageUrl={imageUrl} item={{ imageId: detailQuery?.data?.imageId, diff --git a/src/renderer/features/playlists/mutations/delete-playlist-image-mutation.ts b/src/renderer/features/playlists/mutations/delete-playlist-image-mutation.ts new file mode 100644 index 000000000..d81866583 --- /dev/null +++ b/src/renderer/features/playlists/mutations/delete-playlist-image-mutation.ts @@ -0,0 +1,38 @@ +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 { DeletePlaylistImageArgs, DeletePlaylistImageResponse } from '/@/shared/types/domain-types'; + +export const useDeletePlaylistImage = (args: MutationHookArgs) => { + const { options } = args || {}; + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (args) => { + return api.controller.deletePlaylistImage({ + ...args, + apiClientProps: { serverId: args.apiClientProps.serverId }, + }); + }, + onSuccess: (_data, variables) => { + const { apiClientProps, query } = variables; + const serverId = apiClientProps.serverId; + + if (!serverId) return; + + queryClient.invalidateQueries({ + queryKey: queryKeys.playlists.list(serverId), + }); + + if (query?.id) { + queryClient.invalidateQueries({ + queryKey: queryKeys.playlists.detail(serverId, query.id), + }); + } + }, + ...options, + }); +}; diff --git a/src/renderer/features/playlists/mutations/upload-playlist-image-mutation.ts b/src/renderer/features/playlists/mutations/upload-playlist-image-mutation.ts new file mode 100644 index 000000000..f36b5a00d --- /dev/null +++ b/src/renderer/features/playlists/mutations/upload-playlist-image-mutation.ts @@ -0,0 +1,38 @@ +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 { UploadPlaylistImageArgs, UploadPlaylistImageResponse } from '/@/shared/types/domain-types'; + +export const useUploadPlaylistImage = (args: MutationHookArgs) => { + const { options } = args || {}; + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (args) => { + return api.controller.uploadPlaylistImage({ + ...args, + apiClientProps: { serverId: args.apiClientProps.serverId }, + }); + }, + onSuccess: (_data, variables) => { + const { apiClientProps, query } = variables; + const serverId = apiClientProps.serverId; + + if (!serverId) return; + + queryClient.invalidateQueries({ + queryKey: queryKeys.playlists.list(serverId), + }); + + if (query?.id) { + queryClient.invalidateQueries({ + queryKey: queryKeys.playlists.detail(serverId, query.id), + }); + } + }, + ...options, + }); +}; diff --git a/src/renderer/features/shared/components/library-header.module.css b/src/renderer/features/shared/components/library-header.module.css index 1a058e567..224e4e9e4 100644 --- a/src/renderer/features/shared/components/library-header.module.css +++ b/src/renderer/features/shared/components/library-header.module.css @@ -112,6 +112,7 @@ } .image-section { + position: relative; z-index: 15; display: flex; grid-area: image; @@ -124,6 +125,21 @@ } } +.image-overlay { + position: absolute; + right: var(--theme-spacing-xs); + bottom: var(--theme-spacing-xs); + z-index: 2; + pointer-events: none; + opacity: 0; + transition: opacity 120ms ease; +} + +.image-section:hover .image-overlay { + pointer-events: auto; + opacity: 1; +} + .metadata-section { z-index: 15; display: flex; diff --git a/src/renderer/features/shared/components/library-header.tsx b/src/renderer/features/shared/components/library-header.tsx index c4ce53dbc..4b3bcd476 100644 --- a/src/renderer/features/shared/components/library-header.tsx +++ b/src/renderer/features/shared/components/library-header.tsx @@ -35,6 +35,7 @@ interface LibraryHeaderProps { children?: ReactNode; compact?: boolean; containerClassName?: string; + imageOverlay?: ReactNode; imagePlaceholderUrl?: null | string; imageUrl?: null | string; item: { @@ -56,6 +57,7 @@ export const LibraryHeader = forwardRef( children, compact, containerClassName, + imageOverlay, imageUrl, item, title, @@ -168,6 +170,16 @@ export const LibraryHeader = forwardRef( src={imageUrl || ''} type="header" /> + {imageOverlay && ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="presentation" + > + {imageOverlay} +
+ )} {title && (
diff --git a/src/shared/api/navidrome/navidrome-normalize.ts b/src/shared/api/navidrome/navidrome-normalize.ts index 76ca2555a..5e3bcd51b 100644 --- a/src/shared/api/navidrome/navidrome-normalize.ts +++ b/src/shared/api/navidrome/navidrome-normalize.ts @@ -490,6 +490,8 @@ const normalizePlaylist = ( item: z.infer, server?: null | ServerListItem, ): Playlist => { + const imageId = !item.uploadedImage ? item.id : `pl-${item.id}&square=true&_=${item.updatedAt}`; + return { _itemType: LibraryItem.PLAYLIST, _serverId: server?.id || 'unknown', @@ -498,7 +500,7 @@ const normalizePlaylist = ( duration: item.duration * 1000, genres: [], id: item.id, - imageId: item.id, + imageId, imageUrl: null, name: item.name, owner: item.ownerName, @@ -508,6 +510,7 @@ const normalizePlaylist = ( size: item.size, songCount: item.songCount, sync: item.sync, + uploadedImage: item.uploadedImage, }; }; diff --git a/src/shared/api/navidrome/navidrome-types.ts b/src/shared/api/navidrome/navidrome-types.ts index 69533b4c6..6b4c38641 100644 --- a/src/shared/api/navidrome/navidrome-types.ts +++ b/src/shared/api/navidrome/navidrome-types.ts @@ -624,6 +624,7 @@ const playlist = z.object({ songCount: z.number(), sync: z.boolean(), updatedAt: z.string(), + uploadedImage: z.string().optional(), }); const playlistList = z.array(playlist); @@ -659,6 +660,18 @@ const updatePlaylist = playlist; const updatePlaylistParameters = createPlaylistParameters.partial(); +const uploadPlaylistImage = z.object({ + status: z.string(), +}); + +const uploadPlaylistImageParameters = z.object({ + image: z.instanceof(Uint8Array), +}); + +const deletePlaylistImage = z.object({ + status: z.string(), +}); + const deletePlaylist = z.null(); const addToPlaylist = z.object({ @@ -760,6 +773,7 @@ export const ndType = { songList: songListParameters, tagList: tagListParameters, updatePlaylist: updatePlaylistParameters, + uploadPlaylistImage: uploadPlaylistImageParameters, userList: userListParameters, }, _response: { @@ -771,6 +785,7 @@ export const ndType = { authenticate, createPlaylist, deletePlaylist, + deletePlaylistImage, error, genre, genreList, @@ -787,6 +802,7 @@ export const ndType = { songList, tagList, updatePlaylist, + uploadPlaylistImage, user, userList, }, diff --git a/src/shared/components/file-button/file-button.tsx b/src/shared/components/file-button/file-button.tsx new file mode 100644 index 000000000..f463b3147 --- /dev/null +++ b/src/shared/components/file-button/file-button.tsx @@ -0,0 +1,12 @@ +import { + FileButton as MantineFileButton, + FileButtonProps as MantineFileButtonProps, +} from '@mantine/core'; +import { CSSProperties } from 'react'; + +export interface FileButtonProps extends MantineFileButtonProps { + maxWidth?: CSSProperties['maxWidth']; + width?: CSSProperties['width']; +} + +export const FileButton = MantineFileButton; diff --git a/src/shared/components/icon/icon.tsx b/src/shared/components/icon/icon.tsx index e849d1bab..0a3949d9b 100644 --- a/src/shared/components/icon/icon.tsx +++ b/src/shared/components/icon/icon.tsx @@ -28,6 +28,7 @@ import { LuArrowUpToLine, LuBookOpen, LuBraces, + LuCamera, LuCheck, LuChevronDown, LuChevronLast, @@ -41,7 +42,6 @@ import { LuCloudDownload, LuCornerDownRight, LuCornerUpRight, - LuDelete, LuDisc, LuDisc3, LuDownload, @@ -117,6 +117,7 @@ import { LuTable, LuTimer, LuTimerOff, + LuTrash, LuTriangleAlert, LuUpload, LuUser, @@ -248,7 +249,7 @@ export const AppIcon = { check: LuCheck, clipboardCopy: LuClipboardCopy, collection: LuPackage2, - delete: LuDelete, + delete: LuTrash, disc: LuDisc, download: LuDownload, dragHorizontal: LuGripHorizontal, @@ -351,6 +352,7 @@ export const AppIcon = { unfavorite: LuHeartCrack, unpin: LuPinOff, upload: LuUpload, + uploadImage: LuCamera, user: LuUser, userManage: LuUserRoundCog, visibility: MdOutlineVisibility, diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index 4fe507d0f..155897a4e 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -344,6 +344,7 @@ export type Playlist = { size: null | number; songCount: null | number; sync?: boolean | null; + uploadedImage?: string; }; export type RelatedAlbumArtist = { @@ -968,6 +969,16 @@ export type DeletePlaylistArgs = BaseEndpointArgs & { query: DeletePlaylistQuery; }; +export type DeletePlaylistImageArgs = BaseEndpointArgs & { + query: DeletePlaylistImageQuery; +}; + +export type DeletePlaylistImageQuery = { + id: string; +}; + +export type DeletePlaylistImageResponse = boolean; + export type DeletePlaylistQuery = { id: string }; // Delete Playlist @@ -1106,6 +1117,21 @@ export type UpdatePlaylistQuery = { // Update Playlist export type UpdatePlaylistResponse = null | undefined; +export type UploadPlaylistImageArgs = BaseEndpointArgs & { + body: UploadPlaylistImageBody; + query: UploadPlaylistImageQuery; +}; + +export type UploadPlaylistImageBody = { + image: Uint8Array; +}; + +export type UploadPlaylistImageQuery = { + id: string; +}; + +export type UploadPlaylistImageResponse = boolean; + type PlaylistListSortMap = { jellyfin: Record; navidrome: Record; @@ -1390,6 +1416,7 @@ export type ControllerEndpoint = { args: DeleteInternetRadioStationArgs, ) => Promise; deletePlaylist: (args: DeletePlaylistArgs) => Promise; + deletePlaylistImage?: (args: DeletePlaylistImageArgs) => Promise; getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise; getAlbumArtistInfo?: (args: AlbumArtistInfoArgs) => Promise; getAlbumArtistList: (args: AlbumArtistListArgs) => Promise; @@ -1443,6 +1470,7 @@ export type ControllerEndpoint = { args: UpdateInternetRadioStationArgs, ) => Promise; updatePlaylist: (args: UpdatePlaylistArgs) => Promise; + uploadPlaylistImage?: (args: UploadPlaylistImageArgs) => Promise; }; export type DownloadArgs = BaseEndpointArgs & { @@ -1515,6 +1543,9 @@ export type InternalControllerEndpoint = { deletePlaylist: ( args: ReplaceApiClientProps, ) => Promise; + deletePlaylistImage?: ( + args: ReplaceApiClientProps, + ) => Promise; getAlbumArtistDetail: ( args: ReplaceApiClientProps, ) => Promise; @@ -1599,6 +1630,9 @@ export type InternalControllerEndpoint = { updatePlaylist: ( args: ReplaceApiClientProps, ) => Promise; + uploadPlaylistImage?: ( + args: ReplaceApiClientProps, + ) => Promise; }; export type LyricGetQuery = { diff --git a/src/shared/types/features-types.ts b/src/shared/types/features-types.ts index 00218d266..5720bb166 100644 --- a/src/shared/types/features-types.ts +++ b/src/shared/types/features-types.ts @@ -8,6 +8,7 @@ export enum ServerFeature { MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect', OS_FORM_POST = 'osFormPost', OS_TRANSCODE_DECISION = 'osTranscodeDecision', + PLAYLIST_IMAGE_UPLOAD = 'playlistImageUpload', PLAYLISTS_SMART = 'playlistsSmart', PUBLIC_PLAYLIST = 'publicPlaylist', SERVER_PLAY_QUEUE = 'serverPlayQueue',