diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 4be8280ce..14d290620 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -147,6 +147,20 @@ export const controller: GeneralController = { server.type, )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); }, + deleteArtistImage(args) { + const server = getServerById(args.apiClientProps.serverId); + + if (!server) { + throw new Error( + `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deleteArtistImage`, + ); + } + + return apiController( + 'deleteArtistImage', + server.type, + )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); + }, deleteFavorite(args) { const server = getServerById(args.apiClientProps.serverId); @@ -988,6 +1002,20 @@ export const controller: GeneralController = { server.type, )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); }, + uploadArtistImage(args) { + const server = getServerById(args.apiClientProps.serverId); + + if (!server) { + throw new Error( + `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: uploadArtistImage`, + ); + } + + return apiController( + 'uploadArtistImage', + server.type, + )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); + }, uploadInternetRadioStationImage(args) { const server = getServerById(args.apiClientProps.serverId); diff --git a/src/renderer/api/navidrome/navidrome-api.ts b/src/renderer/api/navidrome/navidrome-api.ts index 6497fbc9d..2bbdf7cc1 100644 --- a/src/renderer/api/navidrome/navidrome-api.ts +++ b/src/renderer/api/navidrome/navidrome-api.ts @@ -46,6 +46,15 @@ export const contract = c.router({ 500: resultWithHeaders(ndType._response.error), }, }, + deleteArtistImage: { + body: null, + method: 'DELETE', + path: 'artist/:id/image', + responses: { + 200: resultWithHeaders(ndType._response.deleteArtistImage), + 500: resultWithHeaders(ndType._response.error), + }, + }, deleteInternetRadioStation: { body: null, method: 'DELETE', @@ -259,6 +268,15 @@ export const contract = c.router({ 500: resultWithHeaders(ndType._response.error), }, }, + uploadArtistImage: { + body: ndType._parameters.uploadArtistImage, + method: 'POST', + path: 'artist/:id/image', + responses: { + 200: resultWithHeaders(ndType._response.uploadArtistImage), + 500: resultWithHeaders(ndType._response.error), + }, + }, uploadInternetRadioStationImage: { body: ndType._parameters.uploadInternetRadioStationImage, method: 'POST', diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index d8e94f986..21d98e2bc 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -13,6 +13,8 @@ import { albumArtistListSortMap, albumListSortMap, AuthenticationResponse, + DeleteArtistImageArgs, + DeleteArtistImageResponse, DeleteInternetRadioStationImageArgs, DeleteInternetRadioStationImageResponse, DeletePlaylistImageArgs, @@ -28,6 +30,8 @@ import { SortOrder, sortOrderMap, tagListSortMap, + UploadArtistImageArgs, + UploadArtistImageResponse, UploadInternetRadioStationImageArgs, UploadInternetRadioStationImageResponse, UploadPlaylistImageArgs, @@ -42,6 +46,7 @@ const VERSION_INFO: VersionInfo = [ [ '0.61.0', { + [ServerFeature.ARTIST_IMAGE_UPLOAD]: [1], [ServerFeature.INTERNET_RADIO_IMAGE_UPLOAD]: [1], [ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1], }, @@ -186,6 +191,21 @@ export const NavidromeController: InternalControllerEndpoint = { id: res.body.data.id, }; }, + deleteArtistImage: async (args: DeleteArtistImageArgs): Promise => { + const { apiClientProps, query } = args; + + const res = await ndApiClient(apiClientProps as any).deleteArtistImage({ + params: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to delete artist image'); + } + + return res.body.data.status === 'ok'; + }, deleteFavorite: SubsonicController.deleteFavorite, deleteInternetRadioStation: async (args) => { const { apiClientProps, query } = args; @@ -1270,6 +1290,40 @@ export const NavidromeController: InternalControllerEndpoint = { return null; }, + uploadArtistImage: async (args: UploadArtistImageArgs): 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/artist/${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 artist image'); + } + + return res.data?.status === 'ok'; + }, uploadInternetRadioStationImage: async ( args: UploadInternetRadioStationImageArgs, ): Promise => { diff --git a/src/renderer/features/artists/components/album-artist-detail-header.tsx b/src/renderer/features/artists/components/album-artist-detail-header.tsx index 59c4aae8f..63f690c5c 100644 --- a/src/renderer/features/artists/components/album-artist-detail-header.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-header.tsx @@ -1,5 +1,5 @@ -import { useQuery, useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query'; -import { forwardRef, Fragment, useCallback, useMemo } from 'react'; +import { useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query'; +import { forwardRef, Fragment, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router'; @@ -8,6 +8,8 @@ import styles from './album-artist-detail-header.module.css'; import { useItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; import { getArtistAlbumsGrouped } from '/@/renderer/features/artists/hooks/use-artist-albums-grouped'; +import { useDeleteArtistImage } from '/@/renderer/features/artists/mutations/delete-artist-image-mutation'; +import { useUploadArtistImage } from '/@/renderer/features/artists/mutations/upload-artist-image-mutation'; import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { @@ -20,17 +22,83 @@ import { AppRoute } from '/@/renderer/router/routes'; import { useAppStore, useCurrentServer, useShowRatings } from '/@/renderer/store'; import { useArtistReleaseTypeItems, usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { formatDurationString } from '/@/renderer/utils'; -import { SEPARATOR_STRING, sortAlbumList } from '/@/shared/api/utils'; +import { hasFeature, SEPARATOR_STRING, sortAlbumList } 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 { Text } from '/@/shared/components/text/text'; -import { AlbumListResponse, LibraryItem, ServerType } from '/@/shared/types/domain-types'; +import { + AlbumArtistDetailResponse, + AlbumListResponse, + LibraryItem, + ServerType, +} from '/@/shared/types/domain-types'; +import { ServerFeature } from '/@/shared/types/features-types'; import { Play } from '/@/shared/types/types'; interface AlbumArtistDetailHeaderProps { albumsQuery: UseSuspenseQueryResult; } +function ArtistImageUploadOverlay({ data }: { data?: AlbumArtistDetailResponse }) { + const uploadArtistImageMutation = useUploadArtistImage({}); + const deleteArtistImageMutation = useDeleteArtistImage({}); + const server = useCurrentServer(); + + if (!data) return null; + if (!hasFeature(server, ServerFeature.ARTIST_IMAGE_UPLOAD)) return null; + + return ( + + { + if (!file || !data?._serverId) return; + + const buffer = await file.arrayBuffer(); + uploadArtistImageMutation.mutate({ + apiClientProps: { + serverId: data._serverId, + }, + body: { image: new Uint8Array(buffer) }, + query: { id: data.id }, + }); + }} + > + {(props) => ( + + )} + + { + e.stopPropagation(); + if (!data?._serverId) return; + deleteArtistImageMutation.mutate({ + apiClientProps: { + serverId: data._serverId, + }, + query: { id: data.id }, + }); + }} + radius="xl" + size="xs" + variant="default" + /> + + ); +} + export const AlbumArtistDetailHeader = forwardRef( ({ albumsQuery }, ref) => { const { albumArtistId, artistId } = useParams() as { @@ -167,37 +235,23 @@ export const AlbumArtistDetailHeader = forwardRef { - return detailQuery.data?.imageUrl || imageUrl; - }, [detailQuery.data?.imageUrl, imageUrl]); - - const alternateImageUrl = artistInfoQuery.data?.imageUrl; - const hasImageId = Boolean(detailQuery.data?.imageId); - const fallbackHeaderImageUrl = alternateImageUrl || selectedImageUrl; - return ( } + imageUrl={headerImageUrl} item={{ imageId: detailQuery.data?.imageId, - imageUrl: hasImageId ? undefined : fallbackHeaderImageUrl, + imageUrl: detailQuery.data?.imageUrl, route: AppRoute.LIBRARY_ALBUM_ARTISTS, type: LibraryItem.ALBUM_ARTIST, }} diff --git a/src/renderer/features/artists/mutations/delete-artist-image-mutation.ts b/src/renderer/features/artists/mutations/delete-artist-image-mutation.ts new file mode 100644 index 000000000..8bf8f6697 --- /dev/null +++ b/src/renderer/features/artists/mutations/delete-artist-image-mutation.ts @@ -0,0 +1,41 @@ +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 { DeleteArtistImageArgs, DeleteArtistImageResponse } from '/@/shared/types/domain-types'; + +export const useDeleteArtistImage = (args: MutationHookArgs) => { + const { options } = args || {}; + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (args) => { + return api.controller.deleteArtistImage({ + ...args, + apiClientProps: { serverId: args.apiClientProps.serverId }, + }); + }, + onSuccess: (_data, variables) => { + const { apiClientProps, query } = variables; + const serverId = apiClientProps.serverId; + + if (!serverId) return; + + queryClient.invalidateQueries({ + queryKey: queryKeys.albumArtists.list(serverId), + }); + + if (query?.id) { + queryClient.invalidateQueries({ + queryKey: queryKeys.albumArtists.detail(serverId, { id: query.id }), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.albumArtists.info(serverId, { id: query.id }), + }); + } + }, + ...options, + }); +}; diff --git a/src/renderer/features/artists/mutations/upload-artist-image-mutation.ts b/src/renderer/features/artists/mutations/upload-artist-image-mutation.ts new file mode 100644 index 000000000..09b06dca0 --- /dev/null +++ b/src/renderer/features/artists/mutations/upload-artist-image-mutation.ts @@ -0,0 +1,41 @@ +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 { UploadArtistImageArgs, UploadArtistImageResponse } from '/@/shared/types/domain-types'; + +export const useUploadArtistImage = (args: MutationHookArgs) => { + const { options } = args || {}; + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (args) => { + return api.controller.uploadArtistImage({ + ...args, + apiClientProps: { serverId: args.apiClientProps.serverId }, + }); + }, + onSuccess: (_data, variables) => { + const { apiClientProps, query } = variables; + const serverId = apiClientProps.serverId; + + if (!serverId) return; + + queryClient.invalidateQueries({ + queryKey: queryKeys.albumArtists.list(serverId), + }); + + if (query?.id) { + queryClient.invalidateQueries({ + queryKey: queryKeys.albumArtists.detail(serverId, { id: query.id }), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.albumArtists.info(serverId, { id: query.id }), + }); + } + }, + ...options, + }); +}; diff --git a/src/shared/api/navidrome/navidrome-normalize.ts b/src/shared/api/navidrome/navidrome-normalize.ts index aa6738154..2a45227e8 100644 --- a/src/shared/api/navidrome/navidrome-normalize.ts +++ b/src/shared/api/navidrome/navidrome-normalize.ts @@ -18,14 +18,20 @@ import { } from '/@/shared/types/domain-types'; import { ServerListItem, ServerType } from '/@/shared/types/types'; -const getImageUrl = (args: { url: null | string }) => { - const { url } = args; - if (url === '/app/artist-placeholder.webp') { - return null; - } +// const getImageUrl = (args: { url: null | string }) => { +// const { url } = args; +// if (url === '/app/artist-placeholder.webp') { +// return null; +// } - return url; -}; +// return url; +// }; + +const navidromeImageIdWithCacheBust = ( + id: string, + uploadedImage: string | undefined, + updatedAt: string | undefined, +): string => (!uploadedImage ? id : `${id}&_=${updatedAt ?? ''}`); interface WithDate { playDate?: string; @@ -397,7 +403,7 @@ const normalizeAlbumArtist = ( }, server?: null | ServerListItem, ): AlbumArtist => { - const imageUrl = getImageUrl({ url: item?.largeImageUrl?.replace(/\?size=\d+/, '') || null }); + // const imageUrl = getImageUrl({ url: item?.largeImageUrl?.replace(/\?size=\d+/, '') || null }); let albumCount: number; let songCount: number; @@ -416,6 +422,12 @@ const normalizeAlbumArtist = ( songCount = item.songCount; } + const imageId = navidromeImageIdWithCacheBust( + item.id, + item.uploadedImage, + item.updatedAt ?? item.externalInfoUpdatedAt, + ); + return { _itemType: LibraryItem.ALBUM_ARTIST, _serverId: server?.id || 'unknown', @@ -435,8 +447,8 @@ const normalizeAlbumArtist = ( songCount: null, })), id: item.id, - imageId: item.id, - imageUrl: imageUrl || null, + imageId, + imageUrl: null, lastPlayedAt: normalizePlayDate(item), mbz: item.mbzArtistId || null, name: item.name, @@ -451,6 +463,7 @@ const normalizeAlbumArtist = ( userRating: artist.userRating || null, })) || [], songCount, + uploadedImage: item.uploadedImage, userFavorite: item.starred || false, userRating: item.rating || null, }; @@ -460,7 +473,7 @@ const normalizePlaylist = ( item: z.infer, server?: null | ServerListItem, ): Playlist => { - const imageId = !item.uploadedImage ? item.id : `${item.id}&_=${item.updatedAt}`; + const imageId = navidromeImageIdWithCacheBust(item.id, item.uploadedImage, item.updatedAt); return { _itemType: LibraryItem.PLAYLIST, @@ -517,7 +530,7 @@ const normalizeInternetRadioStation = ( item: z.infer, ): InternetRadioStation => { const homepageUrl = item.homePageUrl?.trim() ? item.homePageUrl : null; - const imageId = item.uploadedImage ? `${item.id}&_=${item.updatedAt}` : item.id; + const imageId = navidromeImageIdWithCacheBust(item.id, item.uploadedImage, item.updatedAt); return { homepageUrl, diff --git a/src/shared/api/navidrome/navidrome-types.ts b/src/shared/api/navidrome/navidrome-types.ts index a682710f2..154728bef 100644 --- a/src/shared/api/navidrome/navidrome-types.ts +++ b/src/shared/api/navidrome/navidrome-types.ts @@ -428,6 +428,7 @@ const albumArtist = z.object({ starredAt: z.string(), stats: z.record(z.string(), stats).optional(), updatedAt: z.string().optional(), + uploadedImage: z.string().optional(), }); const albumArtistList = z.array(albumArtist); @@ -683,6 +684,9 @@ const deletePlaylistImage = z.object({ const uploadInternetRadioStationImage = uploadPlaylistImage; const uploadInternetRadioStationImageParameters = uploadPlaylistImageParameters; +const uploadArtistImage = uploadPlaylistImage; +const uploadArtistImageParameters = uploadPlaylistImageParameters; +const deleteArtistImage = deletePlaylistImage; const deleteInternetRadioStationImage = deletePlaylistImage; const deletePlaylist = z.null(); @@ -813,6 +817,7 @@ export const ndType = { tagList: tagListParameters, updateInternetRadioStation: updateInternetRadioStationParameters, updatePlaylist: updatePlaylistParameters, + uploadArtistImage: uploadArtistImageParameters, uploadInternetRadioStationImage: uploadInternetRadioStationImageParameters, uploadPlaylistImage: uploadPlaylistImageParameters, userList: userListParameters, @@ -825,6 +830,7 @@ export const ndType = { albumList, authenticate, createPlaylist, + deleteArtistImage, deleteInternetRadioStation, deleteInternetRadioStationImage, deletePlaylist, @@ -848,6 +854,7 @@ export const ndType = { tagList, updateInternetRadioStation, updatePlaylist, + uploadArtistImage, uploadInternetRadioStationImage, uploadPlaylistImage, user, diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index 1b144e902..f90e96781 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -225,6 +225,7 @@ export type AlbumArtist = { playCount: null | number; similarArtists: null | RelatedArtist[]; songCount: null | number; + uploadedImage?: string; userFavorite: boolean; userRating: null | number; }; @@ -957,6 +958,16 @@ export type CreatePlaylistBody = { // Create Playlist export type CreatePlaylistResponse = undefined | { id: string }; +export type DeleteArtistImageArgs = BaseEndpointArgs & { + query: DeleteArtistImageQuery; +}; + +export type DeleteArtistImageQuery = { + id: string; +}; + +export type DeleteArtistImageResponse = boolean; + export type DeleteInternetRadioStationArgs = BaseEndpointArgs & { query: DeleteInternetRadioStationQuery; }; @@ -1132,6 +1143,21 @@ export type UpdatePlaylistQuery = { // Update Playlist export type UpdatePlaylistResponse = null | undefined; +export type UploadArtistImageArgs = BaseEndpointArgs & { + body: UploadArtistImageBody; + query: UploadArtistImageQuery; +}; + +export type UploadArtistImageBody = { + image: Uint8Array; +}; + +export type UploadArtistImageQuery = { + id: string; +}; + +export type UploadArtistImageResponse = boolean; + export type UploadInternetRadioStationImageArgs = BaseEndpointArgs & { body: UploadInternetRadioStationImageBody; query: UploadInternetRadioStationImageQuery; @@ -1441,6 +1467,7 @@ export type ControllerEndpoint = { args: CreateInternetRadioStationArgs, ) => Promise; createPlaylist: (args: CreatePlaylistArgs) => Promise; + deleteArtistImage?: (args: DeleteArtistImageArgs) => Promise; deleteFavorite: (args: FavoriteArgs) => Promise; deleteInternetRadioStation: ( args: DeleteInternetRadioStationArgs, @@ -1503,6 +1530,7 @@ export type ControllerEndpoint = { args: UpdateInternetRadioStationArgs, ) => Promise; updatePlaylist: (args: UpdatePlaylistArgs) => Promise; + uploadArtistImage?: (args: UploadArtistImageArgs) => Promise; uploadInternetRadioStationImage?: ( args: UploadInternetRadioStationImageArgs, ) => Promise; @@ -1572,6 +1600,9 @@ export type InternalControllerEndpoint = { createPlaylist: ( args: ReplaceApiClientProps, ) => Promise; + deleteArtistImage?: ( + args: ReplaceApiClientProps, + ) => Promise; deleteFavorite: (args: ReplaceApiClientProps) => Promise; deleteInternetRadioStation: ( args: ReplaceApiClientProps, @@ -1669,6 +1700,9 @@ export type InternalControllerEndpoint = { updatePlaylist: ( args: ReplaceApiClientProps, ) => Promise; + uploadArtistImage?: ( + args: ReplaceApiClientProps, + ) => Promise; uploadInternetRadioStationImage?: ( args: ReplaceApiClientProps, ) => Promise; diff --git a/src/shared/types/features-types.ts b/src/shared/types/features-types.ts index 0f1d1fedc..30387f33a 100644 --- a/src/shared/types/features-types.ts +++ b/src/shared/types/features-types.ts @@ -2,6 +2,7 @@ // For example: : "Playlists", : "Smart" = "PLAYLISTS_SMART" export enum ServerFeature { ALBUM_YES_NO_RATING_FILTER = 'albumYesNoRatingFilter', + ARTIST_IMAGE_UPLOAD = 'artistImageUpload', BFR = 'bfr', INTERNET_RADIO_IMAGE_UPLOAD = 'internetRadioImageUpload', LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured',