mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-09 20:29:36 +02:00
support navidrome playlist image upload
This commit is contained in:
@@ -189,6 +189,20 @@ export const controller: GeneralController = {
|
|||||||
server.type,
|
server.type,
|
||||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
)?.(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) {
|
getAlbumArtistDetail(args) {
|
||||||
const server = getServerById(args.apiClientProps.serverId);
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
@@ -960,4 +974,18 @@ export const controller: GeneralController = {
|
|||||||
server.type,
|
server.type,
|
||||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
)?.(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),
|
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: {
|
getAlbumArtistDetail: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'artist/:id',
|
path: 'artist/:id',
|
||||||
@@ -214,6 +223,15 @@ export const contract = c.router({
|
|||||||
500: resultWithHeaders(ndType._response.error),
|
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({});
|
const axiosClient = axios.create({});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import axios from 'axios';
|
||||||
import { set } from 'idb-keyval';
|
import { set } from 'idb-keyval';
|
||||||
import orderBy from 'lodash/orderBy';
|
import orderBy from 'lodash/orderBy';
|
||||||
|
|
||||||
@@ -12,6 +13,8 @@ import {
|
|||||||
albumArtistListSortMap,
|
albumArtistListSortMap,
|
||||||
albumListSortMap,
|
albumListSortMap,
|
||||||
AuthenticationResponse,
|
AuthenticationResponse,
|
||||||
|
DeletePlaylistImageArgs,
|
||||||
|
DeletePlaylistImageResponse,
|
||||||
genreListSortMap,
|
genreListSortMap,
|
||||||
InternalControllerEndpoint,
|
InternalControllerEndpoint,
|
||||||
playlistListSortMap,
|
playlistListSortMap,
|
||||||
@@ -23,6 +26,8 @@ import {
|
|||||||
SortOrder,
|
SortOrder,
|
||||||
sortOrderMap,
|
sortOrderMap,
|
||||||
tagListSortMap,
|
tagListSortMap,
|
||||||
|
UploadPlaylistImageArgs,
|
||||||
|
UploadPlaylistImageResponse,
|
||||||
userListSortMap,
|
userListSortMap,
|
||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
import { ServerFeature } from '/@/shared/types/features-types';
|
import { ServerFeature } from '/@/shared/types/features-types';
|
||||||
@@ -30,6 +35,7 @@ import { ServerFeature } from '/@/shared/types/features-types';
|
|||||||
const VERSION_INFO: VersionInfo = [
|
const VERSION_INFO: VersionInfo = [
|
||||||
// Why 2? Subsonic controller will return 1 for its own implementation
|
// Why 2? Subsonic controller will return 1 for its own implementation
|
||||||
// Use 2 to denote that Navidrome's own API has a different endpoint
|
// 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.60.4', { [ServerFeature.TRACK_YES_NO_RATING_FILTER]: [1] }],
|
||||||
['0.57.0', { [ServerFeature.SERVER_PLAY_QUEUE]: [2] }],
|
['0.57.0', { [ServerFeature.SERVER_PLAY_QUEUE]: [2] }],
|
||||||
['0.56.0', { [ServerFeature.TRACK_ALBUM_ARTIST_SEARCH]: [1] }],
|
['0.56.0', { [ServerFeature.TRACK_ALBUM_ARTIST_SEARCH]: [1] }],
|
||||||
@@ -187,6 +193,23 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
|
|
||||||
return null;
|
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) => {
|
getAlbumArtistDetail: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
@@ -1170,4 +1193,40 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
|
|
||||||
return null;
|
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 { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||||
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
||||||
import { PlaylistDetailSongListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header-filters';
|
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 { FilterBar } from '/@/renderer/features/shared/components/filter-bar';
|
||||||
import {
|
import {
|
||||||
LibraryHeader,
|
LibraryHeader,
|
||||||
@@ -18,9 +20,14 @@ import { ListSearchInput } from '/@/renderer/features/shared/components/list-sea
|
|||||||
import { AppRoute } from '/@/renderer/router/routes';
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
import { formatDurationString } from '/@/renderer/utils';
|
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 { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
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';
|
import { Play } from '/@/shared/types/types';
|
||||||
|
|
||||||
interface PlaylistDetailSongListHeaderProps {
|
interface PlaylistDetailSongListHeaderProps {
|
||||||
@@ -30,6 +37,64 @@ interface PlaylistDetailSongListHeaderProps {
|
|||||||
onToggleQueryBuilder?: () => void;
|
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 = ({
|
export const PlaylistDetailSongListHeader = ({
|
||||||
isSmartPlaylist,
|
isSmartPlaylist,
|
||||||
}: PlaylistDetailSongListHeaderProps) => {
|
}: PlaylistDetailSongListHeaderProps) => {
|
||||||
@@ -94,6 +159,7 @@ export const PlaylistDetailSongListHeader = ({
|
|||||||
) : (
|
) : (
|
||||||
<LibraryHeader
|
<LibraryHeader
|
||||||
compact
|
compact
|
||||||
|
imageOverlay={<ImageUploadOverlay data={detailQuery?.data} />}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
item={{
|
item={{
|
||||||
imageId: detailQuery?.data?.imageId,
|
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 {
|
.image-section {
|
||||||
|
position: relative;
|
||||||
z-index: 15;
|
z-index: 15;
|
||||||
display: flex;
|
display: flex;
|
||||||
grid-area: image;
|
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 {
|
.metadata-section {
|
||||||
z-index: 15;
|
z-index: 15;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ interface LibraryHeaderProps {
|
|||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
|
imageOverlay?: ReactNode;
|
||||||
imagePlaceholderUrl?: null | string;
|
imagePlaceholderUrl?: null | string;
|
||||||
imageUrl?: null | string;
|
imageUrl?: null | string;
|
||||||
item: {
|
item: {
|
||||||
@@ -56,6 +57,7 @@ export const LibraryHeader = forwardRef(
|
|||||||
children,
|
children,
|
||||||
compact,
|
compact,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
|
imageOverlay,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
item,
|
item,
|
||||||
title,
|
title,
|
||||||
@@ -168,6 +170,16 @@ export const LibraryHeader = forwardRef(
|
|||||||
src={imageUrl || ''}
|
src={imageUrl || ''}
|
||||||
type="header"
|
type="header"
|
||||||
/>
|
/>
|
||||||
|
{imageOverlay && (
|
||||||
|
<div
|
||||||
|
className={styles.imageOverlay}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
{imageOverlay}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{title && (
|
{title && (
|
||||||
<div className={styles.metadataSection}>
|
<div className={styles.metadataSection}>
|
||||||
|
|||||||
@@ -490,6 +490,8 @@ const normalizePlaylist = (
|
|||||||
item: z.infer<typeof ndType._response.playlist>,
|
item: z.infer<typeof ndType._response.playlist>,
|
||||||
server?: null | ServerListItem,
|
server?: null | ServerListItem,
|
||||||
): Playlist => {
|
): Playlist => {
|
||||||
|
const imageId = !item.uploadedImage ? item.id : `pl-${item.id}&square=true&_=${item.updatedAt}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
_itemType: LibraryItem.PLAYLIST,
|
_itemType: LibraryItem.PLAYLIST,
|
||||||
_serverId: server?.id || 'unknown',
|
_serverId: server?.id || 'unknown',
|
||||||
@@ -498,7 +500,7 @@ const normalizePlaylist = (
|
|||||||
duration: item.duration * 1000,
|
duration: item.duration * 1000,
|
||||||
genres: [],
|
genres: [],
|
||||||
id: item.id,
|
id: item.id,
|
||||||
imageId: item.id,
|
imageId,
|
||||||
imageUrl: null,
|
imageUrl: null,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
owner: item.ownerName,
|
owner: item.ownerName,
|
||||||
@@ -508,6 +510,7 @@ const normalizePlaylist = (
|
|||||||
size: item.size,
|
size: item.size,
|
||||||
songCount: item.songCount,
|
songCount: item.songCount,
|
||||||
sync: item.sync,
|
sync: item.sync,
|
||||||
|
uploadedImage: item.uploadedImage,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -624,6 +624,7 @@ const playlist = z.object({
|
|||||||
songCount: z.number(),
|
songCount: z.number(),
|
||||||
sync: z.boolean(),
|
sync: z.boolean(),
|
||||||
updatedAt: z.string(),
|
updatedAt: z.string(),
|
||||||
|
uploadedImage: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const playlistList = z.array(playlist);
|
const playlistList = z.array(playlist);
|
||||||
@@ -659,6 +660,18 @@ const updatePlaylist = playlist;
|
|||||||
|
|
||||||
const updatePlaylistParameters = createPlaylistParameters.partial();
|
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 deletePlaylist = z.null();
|
||||||
|
|
||||||
const addToPlaylist = z.object({
|
const addToPlaylist = z.object({
|
||||||
@@ -760,6 +773,7 @@ export const ndType = {
|
|||||||
songList: songListParameters,
|
songList: songListParameters,
|
||||||
tagList: tagListParameters,
|
tagList: tagListParameters,
|
||||||
updatePlaylist: updatePlaylistParameters,
|
updatePlaylist: updatePlaylistParameters,
|
||||||
|
uploadPlaylistImage: uploadPlaylistImageParameters,
|
||||||
userList: userListParameters,
|
userList: userListParameters,
|
||||||
},
|
},
|
||||||
_response: {
|
_response: {
|
||||||
@@ -771,6 +785,7 @@ export const ndType = {
|
|||||||
authenticate,
|
authenticate,
|
||||||
createPlaylist,
|
createPlaylist,
|
||||||
deletePlaylist,
|
deletePlaylist,
|
||||||
|
deletePlaylistImage,
|
||||||
error,
|
error,
|
||||||
genre,
|
genre,
|
||||||
genreList,
|
genreList,
|
||||||
@@ -787,6 +802,7 @@ export const ndType = {
|
|||||||
songList,
|
songList,
|
||||||
tagList,
|
tagList,
|
||||||
updatePlaylist,
|
updatePlaylist,
|
||||||
|
uploadPlaylistImage,
|
||||||
user,
|
user,
|
||||||
userList,
|
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;
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
LuArrowUpToLine,
|
LuArrowUpToLine,
|
||||||
LuBookOpen,
|
LuBookOpen,
|
||||||
LuBraces,
|
LuBraces,
|
||||||
|
LuCamera,
|
||||||
LuCheck,
|
LuCheck,
|
||||||
LuChevronDown,
|
LuChevronDown,
|
||||||
LuChevronLast,
|
LuChevronLast,
|
||||||
@@ -41,7 +42,6 @@ import {
|
|||||||
LuCloudDownload,
|
LuCloudDownload,
|
||||||
LuCornerDownRight,
|
LuCornerDownRight,
|
||||||
LuCornerUpRight,
|
LuCornerUpRight,
|
||||||
LuDelete,
|
|
||||||
LuDisc,
|
LuDisc,
|
||||||
LuDisc3,
|
LuDisc3,
|
||||||
LuDownload,
|
LuDownload,
|
||||||
@@ -117,6 +117,7 @@ import {
|
|||||||
LuTable,
|
LuTable,
|
||||||
LuTimer,
|
LuTimer,
|
||||||
LuTimerOff,
|
LuTimerOff,
|
||||||
|
LuTrash,
|
||||||
LuTriangleAlert,
|
LuTriangleAlert,
|
||||||
LuUpload,
|
LuUpload,
|
||||||
LuUser,
|
LuUser,
|
||||||
@@ -248,7 +249,7 @@ export const AppIcon = {
|
|||||||
check: LuCheck,
|
check: LuCheck,
|
||||||
clipboardCopy: LuClipboardCopy,
|
clipboardCopy: LuClipboardCopy,
|
||||||
collection: LuPackage2,
|
collection: LuPackage2,
|
||||||
delete: LuDelete,
|
delete: LuTrash,
|
||||||
disc: LuDisc,
|
disc: LuDisc,
|
||||||
download: LuDownload,
|
download: LuDownload,
|
||||||
dragHorizontal: LuGripHorizontal,
|
dragHorizontal: LuGripHorizontal,
|
||||||
@@ -351,6 +352,7 @@ export const AppIcon = {
|
|||||||
unfavorite: LuHeartCrack,
|
unfavorite: LuHeartCrack,
|
||||||
unpin: LuPinOff,
|
unpin: LuPinOff,
|
||||||
upload: LuUpload,
|
upload: LuUpload,
|
||||||
|
uploadImage: LuCamera,
|
||||||
user: LuUser,
|
user: LuUser,
|
||||||
userManage: LuUserRoundCog,
|
userManage: LuUserRoundCog,
|
||||||
visibility: MdOutlineVisibility,
|
visibility: MdOutlineVisibility,
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ export type Playlist = {
|
|||||||
size: null | number;
|
size: null | number;
|
||||||
songCount: null | number;
|
songCount: null | number;
|
||||||
sync?: boolean | null;
|
sync?: boolean | null;
|
||||||
|
uploadedImage?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RelatedAlbumArtist = {
|
export type RelatedAlbumArtist = {
|
||||||
@@ -968,6 +969,16 @@ export type DeletePlaylistArgs = BaseEndpointArgs & {
|
|||||||
query: DeletePlaylistQuery;
|
query: DeletePlaylistQuery;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DeletePlaylistImageArgs = BaseEndpointArgs & {
|
||||||
|
query: DeletePlaylistImageQuery;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeletePlaylistImageQuery = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeletePlaylistImageResponse = boolean;
|
||||||
|
|
||||||
export type DeletePlaylistQuery = { id: string };
|
export type DeletePlaylistQuery = { id: string };
|
||||||
|
|
||||||
// Delete Playlist
|
// Delete Playlist
|
||||||
@@ -1106,6 +1117,21 @@ export type UpdatePlaylistQuery = {
|
|||||||
// Update Playlist
|
// Update Playlist
|
||||||
export type UpdatePlaylistResponse = null | undefined;
|
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 = {
|
type PlaylistListSortMap = {
|
||||||
jellyfin: Record<PlaylistListSort, JFPlaylistListSort | undefined>;
|
jellyfin: Record<PlaylistListSort, JFPlaylistListSort | undefined>;
|
||||||
navidrome: Record<PlaylistListSort, NDPlaylistListSort | undefined>;
|
navidrome: Record<PlaylistListSort, NDPlaylistListSort | undefined>;
|
||||||
@@ -1390,6 +1416,7 @@ export type ControllerEndpoint = {
|
|||||||
args: DeleteInternetRadioStationArgs,
|
args: DeleteInternetRadioStationArgs,
|
||||||
) => Promise<DeleteInternetRadioStationResponse>;
|
) => Promise<DeleteInternetRadioStationResponse>;
|
||||||
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
|
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
|
||||||
|
deletePlaylistImage?: (args: DeletePlaylistImageArgs) => Promise<DeletePlaylistImageResponse>;
|
||||||
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
|
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
|
||||||
getAlbumArtistInfo?: (args: AlbumArtistInfoArgs) => Promise<AlbumArtistInfoResponse | null>;
|
getAlbumArtistInfo?: (args: AlbumArtistInfoArgs) => Promise<AlbumArtistInfoResponse | null>;
|
||||||
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
|
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
|
||||||
@@ -1443,6 +1470,7 @@ export type ControllerEndpoint = {
|
|||||||
args: UpdateInternetRadioStationArgs,
|
args: UpdateInternetRadioStationArgs,
|
||||||
) => Promise<UpdateInternetRadioStationResponse>;
|
) => Promise<UpdateInternetRadioStationResponse>;
|
||||||
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
|
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
|
||||||
|
uploadPlaylistImage?: (args: UploadPlaylistImageArgs) => Promise<UploadPlaylistImageResponse>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DownloadArgs = BaseEndpointArgs & {
|
export type DownloadArgs = BaseEndpointArgs & {
|
||||||
@@ -1515,6 +1543,9 @@ export type InternalControllerEndpoint = {
|
|||||||
deletePlaylist: (
|
deletePlaylist: (
|
||||||
args: ReplaceApiClientProps<DeletePlaylistArgs>,
|
args: ReplaceApiClientProps<DeletePlaylistArgs>,
|
||||||
) => Promise<DeletePlaylistResponse>;
|
) => Promise<DeletePlaylistResponse>;
|
||||||
|
deletePlaylistImage?: (
|
||||||
|
args: ReplaceApiClientProps<DeletePlaylistImageArgs>,
|
||||||
|
) => Promise<DeletePlaylistImageResponse>;
|
||||||
getAlbumArtistDetail: (
|
getAlbumArtistDetail: (
|
||||||
args: ReplaceApiClientProps<AlbumArtistDetailArgs>,
|
args: ReplaceApiClientProps<AlbumArtistDetailArgs>,
|
||||||
) => Promise<AlbumArtistDetailResponse>;
|
) => Promise<AlbumArtistDetailResponse>;
|
||||||
@@ -1599,6 +1630,9 @@ export type InternalControllerEndpoint = {
|
|||||||
updatePlaylist: (
|
updatePlaylist: (
|
||||||
args: ReplaceApiClientProps<UpdatePlaylistArgs>,
|
args: ReplaceApiClientProps<UpdatePlaylistArgs>,
|
||||||
) => Promise<UpdatePlaylistResponse>;
|
) => Promise<UpdatePlaylistResponse>;
|
||||||
|
uploadPlaylistImage?: (
|
||||||
|
args: ReplaceApiClientProps<UploadPlaylistImageArgs>,
|
||||||
|
) => Promise<UploadPlaylistImageResponse>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LyricGetQuery = {
|
export type LyricGetQuery = {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export enum ServerFeature {
|
|||||||
MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect',
|
MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect',
|
||||||
OS_FORM_POST = 'osFormPost',
|
OS_FORM_POST = 'osFormPost',
|
||||||
OS_TRANSCODE_DECISION = 'osTranscodeDecision',
|
OS_TRANSCODE_DECISION = 'osTranscodeDecision',
|
||||||
|
PLAYLIST_IMAGE_UPLOAD = 'playlistImageUpload',
|
||||||
PLAYLISTS_SMART = 'playlistsSmart',
|
PLAYLISTS_SMART = 'playlistsSmart',
|
||||||
PUBLIC_PLAYLIST = 'publicPlaylist',
|
PUBLIC_PLAYLIST = 'publicPlaylist',
|
||||||
SERVER_PLAY_QUEUE = 'serverPlayQueue',
|
SERVER_PLAY_QUEUE = 'serverPlayQueue',
|
||||||
|
|||||||
Reference in New Issue
Block a user