support navidrome playlist image upload

This commit is contained in:
jeffvli
2026-04-02 01:23:09 -07:00
parent 68dacea228
commit 7442f9d3ca
14 changed files with 347 additions and 4 deletions
+28
View File
@@ -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 } }));
},
};
@@ -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({});
@@ -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<DeletePlaylistImageResponse> => {
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<UploadPlaylistImageResponse> => {
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<ArrayBuffer>;
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';
},
};
@@ -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 (
<Group gap="xs">
<FileButton
accept="image/*"
onChange={async (file) => {
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) => (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="xs"
variant="default"
{...props}
/>
)}
</FileButton>
<ActionIcon
disabled={!data?.uploadedImage}
icon="delete"
iconProps={{ size: 'lg' }}
onClick={(e) => {
e.stopPropagation();
if (!data?._serverId) return;
deletePlaylistImageMutation.mutate({
apiClientProps: {
serverId: data._serverId,
},
query: { id: data.id },
});
}}
radius="xl"
size="xs"
variant="default"
/>
</Group>
);
}
export const PlaylistDetailSongListHeader = ({
isSmartPlaylist,
}: PlaylistDetailSongListHeaderProps) => {
@@ -94,6 +159,7 @@ export const PlaylistDetailSongListHeader = ({
) : (
<LibraryHeader
compact
imageOverlay={<ImageUploadOverlay data={detailQuery?.data} />}
imageUrl={imageUrl}
item={{
imageId: detailQuery?.data?.imageId,
@@ -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<DeletePlaylistImageResponse, AxiosError, DeletePlaylistImageArgs, null>({
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,
});
};
@@ -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<UploadPlaylistImageResponse, AxiosError, UploadPlaylistImageArgs, null>({
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,
});
};
@@ -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;
@@ -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 && (
<div
className={styles.imageOverlay}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
{imageOverlay}
</div>
)}
</div>
{title && (
<div className={styles.metadataSection}>
@@ -490,6 +490,8 @@ const normalizePlaylist = (
item: z.infer<typeof ndType._response.playlist>,
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,
};
};
@@ -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,
},
@@ -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;
+4 -2
View File
@@ -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,
+34
View File
@@ -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<PlaylistListSort, JFPlaylistListSort | undefined>;
navidrome: Record<PlaylistListSort, NDPlaylistListSort | undefined>;
@@ -1390,6 +1416,7 @@ export type ControllerEndpoint = {
args: DeleteInternetRadioStationArgs,
) => Promise<DeleteInternetRadioStationResponse>;
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
deletePlaylistImage?: (args: DeletePlaylistImageArgs) => Promise<DeletePlaylistImageResponse>;
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
getAlbumArtistInfo?: (args: AlbumArtistInfoArgs) => Promise<AlbumArtistInfoResponse | null>;
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
@@ -1443,6 +1470,7 @@ export type ControllerEndpoint = {
args: UpdateInternetRadioStationArgs,
) => Promise<UpdateInternetRadioStationResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
uploadPlaylistImage?: (args: UploadPlaylistImageArgs) => Promise<UploadPlaylistImageResponse>;
};
export type DownloadArgs = BaseEndpointArgs & {
@@ -1515,6 +1543,9 @@ export type InternalControllerEndpoint = {
deletePlaylist: (
args: ReplaceApiClientProps<DeletePlaylistArgs>,
) => Promise<DeletePlaylistResponse>;
deletePlaylistImage?: (
args: ReplaceApiClientProps<DeletePlaylistImageArgs>,
) => Promise<DeletePlaylistImageResponse>;
getAlbumArtistDetail: (
args: ReplaceApiClientProps<AlbumArtistDetailArgs>,
) => Promise<AlbumArtistDetailResponse>;
@@ -1599,6 +1630,9 @@ export type InternalControllerEndpoint = {
updatePlaylist: (
args: ReplaceApiClientProps<UpdatePlaylistArgs>,
) => Promise<UpdatePlaylistResponse>;
uploadPlaylistImage?: (
args: ReplaceApiClientProps<UploadPlaylistImageArgs>,
) => Promise<UploadPlaylistImageResponse>;
};
export type LyricGetQuery = {
+1
View File
@@ -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',