add native nd radio endpoints, support radio station images

This commit is contained in:
jeffvli
2026-04-02 18:26:26 -07:00
parent fbf82c1ef0
commit db06e7f601
18 changed files with 789 additions and 90 deletions
+3
View File
@@ -364,6 +364,9 @@
"input_name": "name", "input_name": "name",
"input_streamUrl": "stream url" "input_streamUrl": "stream url"
}, },
"editRadioStation": {
"success": "radio station updated successfully"
},
"deletePlaylist": { "deletePlaylist": {
"input_confirm": "type the name of the $t(entity.playlist, {\"count\": 1}) to confirm", "input_confirm": "type the name of the $t(entity.playlist, {\"count\": 1}) to confirm",
"success": "$t(entity.playlist, {\"count\": 1}) deleted successfully", "success": "$t(entity.playlist, {\"count\": 1}) deleted successfully",
+28
View File
@@ -175,6 +175,20 @@ export const controller: GeneralController = {
server.type, server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
}, },
deleteInternetRadioStationImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deleteInternetRadioStationImage`,
);
}
return apiController(
'deleteInternetRadioStationImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
deletePlaylist(args) { deletePlaylist(args) {
const server = getServerById(args.apiClientProps.serverId); const server = getServerById(args.apiClientProps.serverId);
@@ -974,6 +988,20 @@ export const controller: GeneralController = {
server.type, server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
}, },
uploadInternetRadioStationImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: uploadInternetRadioStationImage`,
);
}
return apiController(
'uploadInternetRadioStationImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
uploadPlaylistImage(args) { uploadPlaylistImage(args) {
const server = getServerById(args.apiClientProps.serverId); const server = getServerById(args.apiClientProps.serverId);
@@ -46,6 +46,24 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error), 500: resultWithHeaders(ndType._response.error),
}, },
}, },
deleteInternetRadioStation: {
body: null,
method: 'DELETE',
path: 'radio/:id',
responses: {
200: resultWithHeaders(ndType._response.deleteInternetRadioStation),
500: resultWithHeaders(ndType._response.error),
},
},
deleteInternetRadioStationImage: {
body: null,
method: 'DELETE',
path: 'radio/:id/image',
responses: {
200: resultWithHeaders(ndType._response.deleteInternetRadioStationImage),
500: resultWithHeaders(ndType._response.error),
},
},
deletePlaylist: { deletePlaylist: {
body: null, body: null,
method: 'DELETE', method: 'DELETE',
@@ -141,6 +159,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error), 500: resultWithHeaders(ndType._response.error),
}, },
}, },
getRadioList: {
method: 'GET',
path: 'radio',
query: ndType._parameters.radioList,
responses: {
200: resultWithHeaders(ndType._response.radioList),
500: resultWithHeaders(ndType._response.error),
},
},
getSongDetail: { getSongDetail: {
method: 'GET', method: 'GET',
path: 'song/:id', path: 'song/:id',
@@ -214,6 +241,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error), 500: resultWithHeaders(ndType._response.error),
}, },
}, },
updateInternetRadioStation: {
body: ndType._parameters.updateInternetRadioStation,
method: 'PUT',
path: 'radio/:id',
responses: {
200: resultWithHeaders(ndType._response.updateInternetRadioStation),
500: resultWithHeaders(ndType._response.error),
},
},
updatePlaylist: { updatePlaylist: {
body: ndType._parameters.updatePlaylist, body: ndType._parameters.updatePlaylist,
method: 'PUT', method: 'PUT',
@@ -223,6 +259,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error), 500: resultWithHeaders(ndType._response.error),
}, },
}, },
uploadInternetRadioStationImage: {
body: ndType._parameters.uploadInternetRadioStationImage,
method: 'POST',
path: 'radio/:id/image',
responses: {
200: resultWithHeaders(ndType._response.uploadInternetRadioStationImage),
500: resultWithHeaders(ndType._response.error),
},
},
uploadPlaylistImage: { uploadPlaylistImage: {
body: ndType._parameters.uploadPlaylistImage, body: ndType._parameters.uploadPlaylistImage,
method: 'POST', method: 'POST',
@@ -6,13 +6,15 @@ import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api'; import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller'; import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
import { ndNormalize } from '/@/shared/api/navidrome/navidrome-normalize'; import { ndNormalize } from '/@/shared/api/navidrome/navidrome-normalize';
import { NDSongListSort } from '/@/shared/api/navidrome/navidrome-types'; import { NDRadioListSort, NDSongListSort } from '/@/shared/api/navidrome/navidrome-types';
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize'; import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
import { getFeatures, hasFeature, hasFeatureWithVersion, VersionInfo } from '/@/shared/api/utils'; import { getFeatures, hasFeature, hasFeatureWithVersion, VersionInfo } from '/@/shared/api/utils';
import { import {
albumArtistListSortMap, albumArtistListSortMap,
albumListSortMap, albumListSortMap,
AuthenticationResponse, AuthenticationResponse,
DeleteInternetRadioStationImageArgs,
DeleteInternetRadioStationImageResponse,
DeletePlaylistImageArgs, DeletePlaylistImageArgs,
DeletePlaylistImageResponse, DeletePlaylistImageResponse,
genreListSortMap, genreListSortMap,
@@ -26,6 +28,8 @@ import {
SortOrder, SortOrder,
sortOrderMap, sortOrderMap,
tagListSortMap, tagListSortMap,
UploadInternetRadioStationImageArgs,
UploadInternetRadioStationImageResponse,
UploadPlaylistImageArgs, UploadPlaylistImageArgs,
UploadPlaylistImageResponse, UploadPlaylistImageResponse,
userListSortMap, userListSortMap,
@@ -35,7 +39,13 @@ 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.61.0',
{
[ServerFeature.INTERNET_RADIO_IMAGE_UPLOAD]: [1],
[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] }],
@@ -177,7 +187,38 @@ export const NavidromeController: InternalControllerEndpoint = {
}; };
}, },
deleteFavorite: SubsonicController.deleteFavorite, deleteFavorite: SubsonicController.deleteFavorite,
deleteInternetRadioStation: SubsonicController.deleteInternetRadioStation, deleteInternetRadioStation: async (args) => {
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps).deleteInternetRadioStation({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to delete internet radio station');
}
return null;
},
deleteInternetRadioStationImage: async (
args: DeleteInternetRadioStationImageArgs,
): Promise<DeleteInternetRadioStationImageResponse> => {
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps as any).deleteInternetRadioStationImage({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to delete internet radio station image');
}
return res.body.data.status === 'ok';
},
deletePlaylist: async (args) => { deletePlaylist: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
@@ -570,7 +611,24 @@ export const NavidromeController: InternalControllerEndpoint = {
}, },
getImageRequest: SubsonicController.getImageRequest, getImageRequest: SubsonicController.getImageRequest,
getImageUrl: SubsonicController.getImageUrl, getImageUrl: SubsonicController.getImageUrl,
getInternetRadioStations: SubsonicController.getInternetRadioStations, getInternetRadioStations: async (args) => {
const { apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getRadioList({
query: {
_end: -1,
_order: 'ASC',
_sort: NDRadioListSort.NAME,
_start: 0,
},
});
if (res.status !== 200) {
throw new Error('Failed to get internet radio stations');
}
return res.body.data.map((station) => ndNormalize.internetRadioStation(station));
},
getLyrics: SubsonicController.getLyrics, getLyrics: SubsonicController.getLyrics,
getMusicFolderList: SubsonicController.getMusicFolderList, getMusicFolderList: SubsonicController.getMusicFolderList,
getPlaylistDetail: async (args) => { getPlaylistDetail: async (args) => {
@@ -1168,7 +1226,26 @@ export const NavidromeController: InternalControllerEndpoint = {
id: res.body.data.id, id: res.body.data.id,
}; };
}, },
updateInternetRadioStation: SubsonicController.updateInternetRadioStation, updateInternetRadioStation: async (args) => {
const { apiClientProps, body, query } = args;
const res = await ndApiClient(apiClientProps).updateInternetRadioStation({
body: {
homePageUrl: body.homepageUrl ?? '',
name: body.name,
streamUrl: body.streamUrl,
},
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to update internet radio station');
}
return null;
},
updatePlaylist: async (args) => { updatePlaylist: async (args) => {
const { apiClientProps, body, query } = args; const { apiClientProps, body, query } = args;
@@ -1193,6 +1270,42 @@ export const NavidromeController: InternalControllerEndpoint = {
return null; return null;
}, },
uploadInternetRadioStationImage: async (
args: UploadInternetRadioStationImageArgs,
): Promise<UploadInternetRadioStationImageResponse> => {
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/radio/${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 internet radio station image');
}
return res.data?.status === 'ok';
},
uploadPlaylistImage: async ( uploadPlaylistImage: async (
args: UploadPlaylistImageArgs, args: UploadPlaylistImageArgs,
): Promise<UploadPlaylistImageResponse> => { ): Promise<UploadPlaylistImageResponse> => {
@@ -11,7 +11,10 @@ import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists'; import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { RadioMetadataDisplay } from '/@/renderer/features/player/components/radio-metadata-display'; import { RadioMetadataDisplay } from '/@/renderer/features/player/components/radio-metadata-display';
import { useIsRadioActive } from '/@/renderer/features/radio/hooks/use-radio-player'; import {
useIsRadioActive,
useRadioPlayer,
} from '/@/renderer/features/radio/hooks/use-radio-player';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { import {
useAppStore, useAppStore,
@@ -50,9 +53,11 @@ export const LeftControls = () => {
const currentSong = usePlayerSong(); const currentSong = usePlayerSong();
const isRadioActive = useIsRadioActive(); const isRadioActive = useIsRadioActive();
const { currentStationArt } = useRadioPlayer();
const { bindings } = useHotkeySettings(); const { bindings } = useHotkeySettings();
const isRadioMode = isRadioActive; const isRadioMode = isRadioActive;
const hasRadioStationImage = Boolean(currentStationArt?.imageId || currentStationArt?.imageUrl);
const hideImage = image && !collapsed; const hideImage = image && !collapsed;
const isSongDefined = Boolean(currentSong?.id) && !isRadioMode; const isSongDefined = Boolean(currentSong?.id) && !isRadioMode;
const title = currentSong?.name; const title = currentSong?.name;
@@ -128,7 +133,22 @@ export const LeftControls = () => {
})} })}
openDelay={0} openDelay={0}
> >
{isRadioMode ? ( {isRadioMode && hasRadioStationImage ? (
<ItemImage
className={clsx(
styles.playerbarImage,
PlaybackSelectors.playerCoverArt,
)}
enableDebounce={false}
enableViewport={false}
fetchPriority="high"
id={currentStationArt?.imageId ?? undefined}
itemType={LibraryItem.RADIO_STATION}
serverId={currentStationArt?.serverId}
src={currentStationArt?.imageUrl ?? ''}
type="table"
/>
) : isRadioMode ? (
<Center <Center
className={clsx( className={clsx(
styles.playerbarImage, styles.playerbarImage,
@@ -1,11 +1,19 @@
import { t } from 'i18next'; import { t } from 'i18next';
import { MouseEvent } from 'react'; import { MouseEvent, type ReactNode, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { useDeleteInternetRadioStationImage } from '/@/renderer/features/radio/mutations/delete-internet-radio-station-image-mutation';
import { useUpdateRadioStation } from '/@/renderer/features/radio/mutations/update-radio-station-mutation'; import { useUpdateRadioStation } from '/@/renderer/features/radio/mutations/update-radio-station-mutation';
import { useUploadInternetRadioStationImage } from '/@/renderer/features/radio/mutations/upload-internet-radio-station-image-mutation';
import { useCurrentServer } from '/@/renderer/store'; import { useCurrentServer } from '/@/renderer/store';
import { logFn } from '/@/renderer/utils/logger'; import { logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message'; import { logMsg } from '/@/renderer/utils/logger-message';
import { hasFeature } from '/@/shared/api/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Box } from '/@/shared/components/box/box';
import { FileButton } from '/@/shared/components/file-button/file-button';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { closeAllModals, openModal } from '/@/shared/components/modal/modal'; import { closeAllModals, openModal } from '/@/shared/components/modal/modal';
import { ModalButton } from '/@/shared/components/modal/model-shared'; import { ModalButton } from '/@/shared/components/modal/model-shared';
@@ -15,19 +23,51 @@ import { toast } from '/@/shared/components/toast/toast';
import { useForm } from '/@/shared/hooks/use-form'; import { useForm } from '/@/shared/hooks/use-form';
import { import {
InternetRadioStation, InternetRadioStation,
LibraryItem,
ServerListItem, ServerListItem,
UpdateInternetRadioStationBody, UpdateInternetRadioStationBody,
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
interface EditRadioStationFormProps { interface EditRadioStationFormProps {
onCancel: () => void; onCancel: () => void;
station: InternetRadioStation; station: InternetRadioStation;
} }
type RadioStationImageProps = {
imageId: null | string;
imageUrl: null | string;
uploadedImage?: string;
};
export const EditRadioStationForm = ({ onCancel, station }: EditRadioStationFormProps) => { export const EditRadioStationForm = ({ onCancel, station }: EditRadioStationFormProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const mutation = useUpdateRadioStation({}); const updateMutation = useUpdateRadioStation({});
const uploadImageMutation = useUploadInternetRadioStationImage({});
const deleteImageMutation = useDeleteInternetRadioStationImage({});
const server = useCurrentServer(); const server = useCurrentServer();
const isCoverImageDisplayed = hasFeature(server, ServerFeature.INTERNET_RADIO_IMAGE_UPLOAD);
const stationImage: RadioStationImageProps = {
imageId: station.imageId ?? null,
imageUrl: station.imageUrl ?? null,
uploadedImage: station.uploadedImage ?? undefined,
};
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [pendingPreviewUrl, setPendingPreviewUrl] = useState<null | string>(null);
const [removeCustomCover, setRemoveCustomCover] = useState(false);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (!pendingFile) {
setPendingPreviewUrl(null);
return;
}
const url = URL.createObjectURL(pendingFile);
setPendingPreviewUrl(url);
return () => URL.revokeObjectURL(url);
}, [pendingFile]);
const form = useForm<UpdateInternetRadioStationBody>({ const form = useForm<UpdateInternetRadioStationBody>({
initialValues: { initialValues: {
@@ -37,74 +77,234 @@ export const EditRadioStationForm = ({ onCancel, station }: EditRadioStationForm
}, },
}); });
const handleSubmit = form.onSubmit((values) => { const handleSubmit = form.onSubmit(async (values) => {
if (!server) return; if (!server?.id) return;
mutation.mutate( setIsSaving(true);
{ try {
await updateMutation.mutateAsync({
apiClientProps: { serverId: server.id }, apiClientProps: { serverId: server.id },
body: values, body: values,
query: { id: station.id }, query: { id: station.id },
},
{
onError: (error) => {
logFn.error(logMsg.other.error, {
meta: { error: error as Error },
}); });
toast.error({ if (pendingFile) {
message: (error as Error).message, const buffer = await pendingFile.arrayBuffer();
title: t('error.genericError', { await uploadImageMutation.mutateAsync({
apiClientProps: { serverId: server.id },
body: { image: new Uint8Array(buffer) },
query: { id: station.id },
});
} else if (removeCustomCover && stationImage.uploadedImage) {
await deleteImageMutation.mutateAsync({
apiClientProps: { serverId: server.id },
query: { id: station.id },
});
}
toast.success({
message: t('form.editRadioStation.success', {
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
}) as string, }) as string,
}); });
},
onSuccess: () => {
closeAllModals(); closeAllModals();
}, } catch (err: unknown) {
}, logFn.error(logMsg.other.error, {
); meta: { error: err as Error },
}); });
return ( toast.error({
<form onSubmit={handleSubmit}> message: (err as Error)?.message,
<Stack gap="md"> title: t('error.genericError', { postProcess: 'sentenceCase' }) as string,
});
} finally {
setIsSaving(false);
}
});
const isSubmitDisabled = !form.values.name || !form.values.streamUrl || isSaving;
const hadUploadedCover = !!stationImage.uploadedImage;
const fieldNodes: ReactNode[] = [
<TextInput <TextInput
data-autofocus
key="name"
label={t('form.createRadioStation.input', { label={t('form.createRadioStation.input', {
context: 'name', context: 'name',
postProcess: 'titleCase', postProcess: 'titleCase',
})} })}
required required
{...form.getInputProps('name')} {...form.getInputProps('name')}
/> />,
<TextInput <TextInput
key="streamUrl"
label={t('form.createRadioStation.input', { label={t('form.createRadioStation.input', {
context: 'streamUrl', context: 'streamUrl',
postProcess: 'titleCase', postProcess: 'titleCase',
})} })}
required required
{...form.getInputProps('streamUrl')} {...form.getInputProps('streamUrl')}
/> />,
<TextInput <TextInput
key="homepageUrl"
label={t('form.createRadioStation.input', { label={t('form.createRadioStation.input', {
context: 'homepageUrl', context: 'homepageUrl',
postProcess: 'titleCase', postProcess: 'titleCase',
})} })}
{...form.getInputProps('homepageUrl')} {...form.getInputProps('homepageUrl')}
/>,
<Group justify="flex-end" key="actions">
<ModalButton disabled={isSaving} onClick={onCancel}>
{t('common.cancel')}
</ModalButton>
<ModalButton
disabled={isSubmitDisabled}
loading={isSaving}
type="submit"
variant="filled"
>
{t('common.save')}
</ModalButton>
</Group>,
];
return (
<form onSubmit={handleSubmit}>
{isCoverImageDisplayed && server?.id ? (
<Flex align="flex-start" gap="lg" wrap="wrap">
<RadioStationCoverField
hadUploadedCover={hadUploadedCover}
onClearPending={() => setPendingFile(null)}
onFileSelect={(file) => {
if (!file) return;
setRemoveCustomCover(false);
setPendingFile(file);
}}
onToggleRemoveCover={() => setRemoveCustomCover((v) => !v)}
pendingFile={pendingFile}
pendingPreviewUrl={pendingPreviewUrl}
removeCustomCover={removeCustomCover}
stationImage={stationImage}
/> />
<Group justify="flex-end"> <Stack gap="md" style={{ flex: '1 1 220px', minWidth: 0 }}>
<ModalButton onClick={onCancel} variant="subtle"> {fieldNodes}
{t('common.cancel', { postProcess: 'sentenceCase' })}
</ModalButton>
<ModalButton loading={mutation.isPending} type="submit" variant="filled">
{t('common.save', { postProcess: 'sentenceCase' })}
</ModalButton>
</Group>
</Stack> </Stack>
</Flex>
) : (
<Stack gap="md">{fieldNodes}</Stack>
)}
</form> </form>
); );
}; };
const COVER_SIZE = 240;
function RadioStationCoverField({
hadUploadedCover,
onClearPending,
onFileSelect,
onToggleRemoveCover,
pendingFile,
pendingPreviewUrl,
removeCustomCover,
stationImage,
}: {
hadUploadedCover: boolean;
onClearPending: () => void;
onFileSelect: (file: File | null) => void;
onToggleRemoveCover: () => void;
pendingFile: File | null;
pendingPreviewUrl: null | string;
removeCustomCover: boolean;
stationImage: RadioStationImageProps;
}) {
const server = useCurrentServer();
const showServerCover = !pendingPreviewUrl && !removeCustomCover;
const previewId = showServerCover ? stationImage.imageId || undefined : undefined;
const previewSrc = pendingPreviewUrl || (showServerCover ? stationImage.imageUrl || '' : '');
const secondaryAction = () => {
if (pendingFile) {
onClearPending();
return;
}
if (hadUploadedCover) {
onToggleRemoveCover();
}
};
const secondaryDisabled = !pendingFile && !hadUploadedCover;
const secondaryIcon = pendingFile ? 'x' : removeCustomCover ? 'arrowLeft' : 'delete';
const iconControls = (
<>
<FileButton accept="image/*" onChange={onFileSelect}>
{(props) => (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="sm"
variant="default"
{...props}
/>
)}
</FileButton>
<ActionIcon
disabled={secondaryDisabled}
icon={secondaryIcon}
iconProps={{ size: 'lg' }}
onClick={secondaryAction}
radius="xl"
size="sm"
variant="default"
/>
</>
);
const coverArt = (
<ItemImage
enableViewport={false}
id={previewId}
itemType={LibraryItem.RADIO_STATION}
serverId={server?.id}
src={previewSrc}
type="header"
/>
);
return (
<Box
style={{
borderRadius: 'var(--mantine-radius-md)',
flexShrink: 0,
height: COVER_SIZE,
overflow: 'hidden',
position: 'relative',
width: COVER_SIZE,
}}
>
{coverArt}
<Group
gap={4}
style={{
background: 'rgba(0, 0, 0, 0.55)',
borderRadius: 'var(--mantine-radius-md)',
bottom: 6,
padding: 4,
position: 'absolute',
right: 6,
}}
wrap="nowrap"
>
{iconControls}
</Group>
</Box>
);
}
export const openEditRadioStationModal = ( export const openEditRadioStationModal = (
station: InternetRadioStation, station: InternetRadioStation,
server: null | ServerListItem, server: null | ServerListItem,
@@ -119,8 +319,11 @@ export const openEditRadioStationModal = (
return; return;
} }
const hasImageUpload = hasFeature(server, ServerFeature.INTERNET_RADIO_IMAGE_UPLOAD);
openModal({ openModal({
children: <EditRadioStationForm onCancel={closeAllModals} station={station} />, children: <EditRadioStationForm onCancel={closeAllModals} station={station} />,
size: hasImageUpload ? 'lg' : 'md',
title: t('common.edit', { postProcess: 'titleCase' }) as string, title: t('common.edit', { postProcess: 'titleCase' }) as string,
}); });
}; };
@@ -20,11 +20,55 @@
.radio-item-button { .radio-item-button {
all: unset; all: unset;
box-sizing: border-box;
display: block;
flex: 1; flex: 1;
width: 100%; width: 100%;
min-width: 0;
text-align: left;
cursor: pointer;
}
.thumbnail {
flex-shrink: 0;
width: 3rem;
height: 3rem;
overflow: hidden;
border-radius: var(--mantine-radius-md);
}
.image-container {
width: 3rem;
height: 3rem;
}
.meta {
flex: 1;
min-width: 0;
}
.meta-line {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.radio-item-link { .radio-item-link {
color: inherit; color: inherit;
text-decoration: underline; text-decoration: underline;
} }
.radio-item-actions {
flex-shrink: 0;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.radio-item:hover .radio-item-actions,
.radio-item:focus-within .radio-item-actions {
pointer-events: auto;
opacity: 1;
}
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import styles from './radio-list-items.module.css'; import styles from './radio-list-items.module.css';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { openEditRadioStationModal } from '/@/renderer/features/radio/components/edit-radio-station-form'; import { openEditRadioStationModal } from '/@/renderer/features/radio/components/edit-radio-station-form';
import { import {
useRadioControls, useRadioControls,
@@ -12,15 +13,15 @@ import {
import { useDeleteRadioStation } from '/@/renderer/features/radio/mutations/delete-radio-station-mutation'; import { useDeleteRadioStation } from '/@/renderer/features/radio/mutations/delete-radio-station-mutation';
import { useCurrentServer, usePermissions } from '/@/renderer/store'; import { useCurrentServer, usePermissions } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Box } from '/@/shared/components/box/box';
import { Flex } from '/@/shared/components/flex/flex'; import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { closeAllModals, ConfirmModal, openModal } from '/@/shared/components/modal/modal'; import { closeAllModals, ConfirmModal, openModal } from '/@/shared/components/modal/modal';
import { Paper } from '/@/shared/components/paper/paper'; import { Paper } from '/@/shared/components/paper/paper';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { InternetRadioStation } from '/@/shared/types/domain-types'; import { InternetRadioStation, LibraryItem } from '/@/shared/types/domain-types';
interface RadioListItemProps { interface RadioListItemProps {
station: InternetRadioStation; station: InternetRadioStation;
@@ -44,8 +45,13 @@ const RadioListItem = ({ station }: RadioListItemProps) => {
const handleClick = () => { const handleClick = () => {
if (stationIsPlaying) { if (stationIsPlaying) {
stop(); stop();
} else { } else if (server?.id) {
play(station.streamUrl, station.name); play(station.streamUrl, station.name, {
id: station.id,
imageId: station.imageId,
imageUrl: station.imageUrl,
serverId: server.id,
});
} }
}; };
@@ -107,27 +113,39 @@ const RadioListItem = ({ station }: RadioListItemProps) => {
})} })}
p="md" p="md"
> >
<Flex align="flex-start" gap="md" justify="space-between"> <Flex align="center" gap="md" justify="space-between" wrap="nowrap">
<button className={styles['radio-item-button']} onClick={handleClick} role="button"> <button className={styles['radio-item-button']} onClick={handleClick} type="button">
<Stack gap="xs"> <Group align="center" gap="md" wrap="nowrap">
<Group gap="xs"> <Box className={styles.thumbnail}>
<Icon color="muted" icon="radio" size="md" /> <ItemImage
enableViewport={false}
id={station.imageId ?? undefined}
imageContainerProps={{
className: styles['image-container'],
}}
itemType={LibraryItem.RADIO_STATION}
serverId={server?.id}
src={station.imageUrl ?? ''}
type="table"
/>
</Box>
<Stack className={styles.meta} gap={4}>
<Text fw={500} size="md"> <Text fw={500} size="md">
{station.name} {station.name}
</Text> </Text>
</Group> <Text className={styles['meta-line']} isMuted size="sm">
<Text isMuted size="sm">
{station.streamUrl} {station.streamUrl}
</Text> </Text>
{station.homepageUrl && ( {station.homepageUrl ? (
<Text isMuted size="sm"> <Text className={styles['meta-line']} isMuted size="sm">
{station.homepageUrl} {station.homepageUrl}
</Text> </Text>
)} ) : null}
</Stack> </Stack>
</Group>
</button> </button>
{(permissions.radio.edit || permissions.radio.delete) && ( {(permissions.radio.edit || permissions.radio.delete) && (
<Group gap="xs"> <Group className={styles['radio-item-actions']} gap="xs">
{permissions.radio.edit && ( {permissions.radio.edit && (
<ActionIcon <ActionIcon
icon="edit" icon="edit"
@@ -7,6 +7,13 @@ import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/
import { usePlaybackType, usePlayerStoreBase, useSettingsStore } from '/@/renderer/store'; import { usePlaybackType, usePlayerStoreBase, useSettingsStore } from '/@/renderer/store';
import { PlayerStatus, PlayerType } from '/@/shared/types/types'; import { PlayerStatus, PlayerType } from '/@/shared/types/types';
export type RadioCurrentStationArt = {
id: string;
imageId?: null | string;
imageUrl?: null | string;
serverId: string;
};
export interface RadioMetadata { export interface RadioMetadata {
artist: null | string; artist: null | string;
title: null | string; title: null | string;
@@ -15,13 +22,18 @@ export interface RadioMetadata {
interface RadioStore { interface RadioStore {
actions: { actions: {
pause: () => void; pause: () => void;
play: (streamUrl?: string, stationName?: string) => void; play: (
streamUrl?: string,
stationName?: string,
stationArt?: null | RadioCurrentStationArt,
) => void;
setCurrentStreamUrl: (currentStreamUrl: null | string) => void; setCurrentStreamUrl: (currentStreamUrl: null | string) => void;
setIsPlaying: (isPlaying: boolean) => void; setIsPlaying: (isPlaying: boolean) => void;
setMetadata: (metadata: null | RadioMetadata) => void; setMetadata: (metadata: null | RadioMetadata) => void;
setStationName: (stationName: null | string) => void; setStationName: (stationName: null | string) => void;
stop: () => void; stop: () => void;
}; };
currentStationArt: null | RadioCurrentStationArt;
currentStreamUrl: null | string; currentStreamUrl: null | string;
isPlaying: boolean; isPlaying: boolean;
metadata: null | RadioMetadata; metadata: null | RadioMetadata;
@@ -34,7 +46,11 @@ export const useRadioStore = createWithEqualityFn<RadioStore>((set) => ({
set({ isPlaying: false }); set({ isPlaying: false });
usePlayerStoreBase.getState().mediaPause(); usePlayerStoreBase.getState().mediaPause();
}, },
play: (streamUrl?: string, stationName?: string) => { play: (
streamUrl?: string,
stationName?: string,
stationArt?: null | RadioCurrentStationArt,
) => {
set((state) => { set((state) => {
const newStreamUrl = streamUrl ?? state.currentStreamUrl; const newStreamUrl = streamUrl ?? state.currentStreamUrl;
const newStationName = stationName ?? state.stationName; const newStationName = stationName ?? state.stationName;
@@ -43,12 +59,19 @@ export const useRadioStore = createWithEqualityFn<RadioStore>((set) => ({
return state; return state;
} }
// Reset metadata when switching stations (streamUrl changes) const streamUrlExplicit = streamUrl !== undefined;
const isSwitchingStation = newStreamUrl !== state.currentStreamUrl; const isSwitchingStation =
streamUrlExplicit && streamUrl !== state.currentStreamUrl;
let nextStationArt = state.currentStationArt;
if (isSwitchingStation) {
nextStationArt = stationArt ?? null;
}
usePlayerStoreBase.getState().mediaPlay(); usePlayerStoreBase.getState().mediaPlay();
return { return {
currentStationArt: nextStationArt,
currentStreamUrl: newStreamUrl, currentStreamUrl: newStreamUrl,
isPlaying: true, isPlaying: true,
metadata: isSwitchingStation ? null : state.metadata, metadata: isSwitchingStation ? null : state.metadata,
@@ -64,6 +87,7 @@ export const useRadioStore = createWithEqualityFn<RadioStore>((set) => ({
const playbackType = useSettingsStore.getState().playback.type; const playbackType = useSettingsStore.getState().playback.type;
set({ set({
currentStationArt: null,
currentStreamUrl: null, currentStreamUrl: null,
isPlaying: false, isPlaying: false,
metadata: null, metadata: null,
@@ -79,6 +103,7 @@ export const useRadioStore = createWithEqualityFn<RadioStore>((set) => ({
} }
}, },
}, },
currentStationArt: null,
currentStreamUrl: null, currentStreamUrl: null,
isPlaying: false, isPlaying: false,
metadata: null, metadata: null,
@@ -90,12 +115,14 @@ export const useIsPlayingRadio = () => useRadioStore((state) => state.isPlaying)
export const useIsRadioActive = () => useRadioStore((state) => Boolean(state.currentStreamUrl)); export const useIsRadioActive = () => useRadioStore((state) => Boolean(state.currentStreamUrl));
export const useRadioPlayer = () => { export const useRadioPlayer = () => {
const currentStationArt = useRadioStore((state) => state.currentStationArt);
const currentStreamUrl = useRadioStore((state) => state.currentStreamUrl); const currentStreamUrl = useRadioStore((state) => state.currentStreamUrl);
const isPlaying = useRadioStore((state) => state.isPlaying); const isPlaying = useRadioStore((state) => state.isPlaying);
const metadata = useRadioStore((state) => state.metadata); const metadata = useRadioStore((state) => state.metadata);
const stationName = useRadioStore((state) => state.stationName); const stationName = useRadioStore((state) => state.stationName);
return { return {
currentStationArt,
currentStreamUrl, currentStreamUrl,
isPlaying, isPlaying,
metadata, metadata,
@@ -163,6 +190,7 @@ export const useRadioAudioInstance = () => {
setIsPlaying(false); setIsPlaying(false);
setCurrentStreamUrl(null); setCurrentStreamUrl(null);
setStationName(null); setStationName(null);
useRadioStore.setState({ currentStationArt: null, metadata: null });
}; };
mpvPlayerListener.rendererPlay(handleMpvPlay); mpvPlayerListener.rendererPlay(handleMpvPlay);
@@ -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 {
DeleteInternetRadioStationImageArgs,
DeleteInternetRadioStationImageResponse,
} from '/@/shared/types/domain-types';
export const useDeleteInternetRadioStationImage = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
return useMutation<
DeleteInternetRadioStationImageResponse,
AxiosError,
DeleteInternetRadioStationImageArgs,
null
>({
mutationFn: (args) => {
return api.controller.deleteInternetRadioStationImage({
...args,
apiClientProps: { serverId: args.apiClientProps.serverId },
});
},
onSuccess: (_data, variables) => {
const { apiClientProps } = variables;
const serverId = apiClientProps.serverId;
if (!serverId) return;
queryClient.invalidateQueries({
queryKey: queryKeys.radio.list(serverId),
});
},
...options,
});
};
@@ -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 {
UploadInternetRadioStationImageArgs,
UploadInternetRadioStationImageResponse,
} from '/@/shared/types/domain-types';
export const useUploadInternetRadioStationImage = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
return useMutation<
UploadInternetRadioStationImageResponse,
AxiosError,
UploadInternetRadioStationImageArgs,
null
>({
mutationFn: (args) => {
return api.controller.uploadInternetRadioStationImage({
...args,
apiClientProps: { serverId: args.apiClientProps.serverId },
});
},
onSuccess: (_data, variables) => {
const { apiClientProps } = variables;
const serverId = apiClientProps.serverId;
if (!serverId) return;
queryClient.invalidateQueries({
queryKey: queryKeys.radio.list(serverId),
});
},
...options,
});
};
@@ -167,7 +167,7 @@ const SidebarImage = () => {
const { setSideBar } = useAppStoreActions(); const { setSideBar } = useAppStoreActions();
const currentSong = usePlayerSong(); const currentSong = usePlayerSong();
const isRadioActive = useIsRadioActive(); const isRadioActive = useIsRadioActive();
const { isPlaying: isRadioPlaying } = useRadioPlayer(); const { currentStationArt, isPlaying: isRadioPlaying } = useRadioPlayer();
const { blurExplicitImages } = useGeneralSettings(); const { blurExplicitImages } = useGeneralSettings();
const imageUrl = useItemImageUrl({ const imageUrl = useItemImageUrl({
@@ -177,6 +177,14 @@ const SidebarImage = () => {
type: 'sidebar', type: 'sidebar',
}); });
const radioImageUrl = useItemImageUrl({
id: isRadioActive ? currentStationArt?.imageId || undefined : undefined,
imageUrl: isRadioActive ? currentStationArt?.imageUrl || undefined : undefined,
itemType: LibraryItem.RADIO_STATION,
serverId: isRadioActive ? currentStationArt?.serverId : undefined,
type: 'sidebar',
});
const isPlayingRadio = isRadioActive && isRadioPlaying; const isPlayingRadio = isRadioActive && isRadioPlaying;
const isSongDefined = Boolean(currentSong?.id); const isSongDefined = Boolean(currentSong?.id);
@@ -224,7 +232,9 @@ const SidebarImage = () => {
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
})} })}
> >
{isPlayingRadio ? ( {isRadioActive && radioImageUrl ? (
<img className={styles.sidebarImage} loading="eager" src={radioImageUrl} />
) : isRadioActive ? (
<Center <Center
className={styles.sidebarImage} className={styles.sidebarImage}
style={{ style={{
@@ -8,6 +8,7 @@ import {
AlbumArtist, AlbumArtist,
ExplicitStatus, ExplicitStatus,
Genre, Genre,
InternetRadioStation,
LibraryItem, LibraryItem,
Playlist, Playlist,
RelatedArtist, RelatedArtist,
@@ -543,10 +544,28 @@ const normalizeUser = (item: z.infer<typeof ndType._response.user>): User => {
}; };
}; };
const normalizeInternetRadioStation = (
item: z.infer<typeof ndType._response.radioStation>,
): InternetRadioStation => {
const homepageUrl = item.homePageUrl?.trim() ? item.homePageUrl : null;
const imageId = item.uploadedImage ? `ra-${item.id}&square=true&_=${item.updatedAt}` : item.id;
return {
homepageUrl,
id: item.id,
imageId,
imageUrl: null,
name: item.name,
streamUrl: item.streamUrl,
uploadedImage: item.uploadedImage || null,
};
};
export const ndNormalize = { export const ndNormalize = {
album: normalizeAlbum, album: normalizeAlbum,
albumArtist: normalizeAlbumArtist, albumArtist: normalizeAlbumArtist,
genre: normalizeGenre, genre: normalizeGenre,
internetRadioStation: normalizeInternetRadioStation,
playlist: normalizePlaylist, playlist: normalizePlaylist,
song: normalizeSong, song: normalizeSong,
user: normalizeUser, user: normalizeUser,
@@ -660,6 +660,12 @@ const updatePlaylist = playlist;
const updatePlaylistParameters = createPlaylistParameters.partial(); const updatePlaylistParameters = createPlaylistParameters.partial();
const updateInternetRadioStationParameters = z.object({
homePageUrl: z.string().optional(),
name: z.string(),
streamUrl: z.string(),
});
const uploadPlaylistImage = z.object({ const uploadPlaylistImage = z.object({
status: z.string(), status: z.string(),
}); });
@@ -672,8 +678,14 @@ const deletePlaylistImage = z.object({
status: z.string(), status: z.string(),
}); });
const uploadInternetRadioStationImage = uploadPlaylistImage;
const uploadInternetRadioStationImageParameters = uploadPlaylistImageParameters;
const deleteInternetRadioStationImage = deletePlaylistImage;
const deletePlaylist = z.null(); const deletePlaylist = z.null();
const deleteInternetRadioStation = deletePlaylist;
const addToPlaylist = z.object({ const addToPlaylist = z.object({
added: z.number(), added: z.number(),
}); });
@@ -748,12 +760,35 @@ const queue = z.object({
userId: z.string(), userId: z.string(),
}); });
export enum NDRadioListSort {
NAME = 'name',
}
const radioStation = z.object({
createdAt: z.string(),
homePageUrl: z.string().optional(),
id: z.string(),
name: z.string(),
streamUrl: z.string(),
updatedAt: z.string(),
uploadedImage: z.string().optional(),
});
const radioList = z.array(radioStation);
const updateInternetRadioStation = radioStation;
const radioListParameters = optionalPaginationParameters.extend({
_sort: z.nativeEnum(NDRadioListSort).optional(),
});
export const ndType = { export const ndType = {
_enum: { _enum: {
albumArtistList: NDAlbumArtistListSort, albumArtistList: NDAlbumArtistListSort,
albumList: NDAlbumListSort, albumList: NDAlbumListSort,
genreList: genreListSort, genreList: genreListSort,
playlistList: NDPlaylistListSort, playlistList: NDPlaylistListSort,
radioList: NDRadioListSort,
songList: NDSongListSort, songList: NDSongListSort,
tagList: NDTagListSort, tagList: NDTagListSort,
userList: ndUserListSort, userList: ndUserListSort,
@@ -767,12 +802,15 @@ export const ndType = {
genreList: genreListParameters, genreList: genreListParameters,
moveItem: moveItemParameters, moveItem: moveItemParameters,
playlistList: playlistListParameters, playlistList: playlistListParameters,
radioList: radioListParameters,
removeFromPlaylist: removeFromPlaylistParameters, removeFromPlaylist: removeFromPlaylistParameters,
saveQueue: saveQueueParameters, saveQueue: saveQueueParameters,
shareItem: shareItemParameters, shareItem: shareItemParameters,
songList: songListParameters, songList: songListParameters,
tagList: tagListParameters, tagList: tagListParameters,
updateInternetRadioStation: updateInternetRadioStationParameters,
updatePlaylist: updatePlaylistParameters, updatePlaylist: updatePlaylistParameters,
uploadInternetRadioStationImage: uploadInternetRadioStationImageParameters,
uploadPlaylistImage: uploadPlaylistImageParameters, uploadPlaylistImage: uploadPlaylistImageParameters,
userList: userListParameters, userList: userListParameters,
}, },
@@ -784,6 +822,8 @@ export const ndType = {
albumList, albumList,
authenticate, authenticate,
createPlaylist, createPlaylist,
deleteInternetRadioStation,
deleteInternetRadioStationImage,
deletePlaylist, deletePlaylist,
deletePlaylistImage, deletePlaylistImage,
error, error,
@@ -795,13 +835,17 @@ export const ndType = {
playlistSong, playlistSong,
playlistSongList, playlistSongList,
queue, queue,
radioList,
radioStation,
removeFromPlaylist, removeFromPlaylist,
saveQueue, saveQueue,
shareItem, shareItem,
song, song,
songList, songList,
tagList, tagList,
updateInternetRadioStation,
updatePlaylist, updatePlaylist,
uploadInternetRadioStationImage,
uploadPlaylistImage, uploadPlaylistImage,
user, user,
userList, userList,
@@ -432,6 +432,8 @@ const normalizeInternetRadioStation = (
return { return {
homepageUrl: item.homepageUrl || null, homepageUrl: item.homepageUrl || null,
id: item.id, id: item.id,
imageId: item.coverArt?.toString() || null,
imageUrl: null,
name: item.name, name: item.name,
streamUrl: item.streamUrl, streamUrl: item.streamUrl,
}; };
@@ -755,6 +755,7 @@ const playQueueByIndex = z.object({
}); });
const internetRadioStation = z.object({ const internetRadioStation = z.object({
coverArt: z.string().optional(),
homepageUrl: z.string().optional(), homepageUrl: z.string().optional(),
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
+41 -1
View File
@@ -959,6 +959,16 @@ export type DeleteInternetRadioStationArgs = BaseEndpointArgs & {
query: DeleteInternetRadioStationQuery; query: DeleteInternetRadioStationQuery;
}; };
export type DeleteInternetRadioStationImageArgs = BaseEndpointArgs & {
query: DeleteInternetRadioStationImageQuery;
};
export type DeleteInternetRadioStationImageQuery = {
id: string;
};
export type DeleteInternetRadioStationImageResponse = boolean;
export type DeleteInternetRadioStationQuery = { export type DeleteInternetRadioStationQuery = {
id: string; id: string;
}; };
@@ -999,10 +1009,13 @@ export type GetInternetRadioStationsArgs = BaseEndpointArgs;
export type GetInternetRadioStationsResponse = InternetRadioStation[]; export type GetInternetRadioStationsResponse = InternetRadioStation[];
export type InternetRadioStation = { export type InternetRadioStation = {
homepageUrl?: null | string; homepageUrl: null | string;
id: string; id: string;
imageId?: null | string;
imageUrl?: null | string;
name: string; name: string;
streamUrl: string; streamUrl: string;
uploadedImage?: null | string;
}; };
export type PlaylistListArgs = BaseEndpointArgs & { query: PlaylistListQuery }; export type PlaylistListArgs = BaseEndpointArgs & { query: PlaylistListQuery };
@@ -1117,6 +1130,21 @@ export type UpdatePlaylistQuery = {
// Update Playlist // Update Playlist
export type UpdatePlaylistResponse = null | undefined; export type UpdatePlaylistResponse = null | undefined;
export type UploadInternetRadioStationImageArgs = BaseEndpointArgs & {
body: UploadInternetRadioStationImageBody;
query: UploadInternetRadioStationImageQuery;
};
export type UploadInternetRadioStationImageBody = {
image: Uint8Array;
};
export type UploadInternetRadioStationImageQuery = {
id: string;
};
export type UploadInternetRadioStationImageResponse = boolean;
export type UploadPlaylistImageArgs = BaseEndpointArgs & { export type UploadPlaylistImageArgs = BaseEndpointArgs & {
body: UploadPlaylistImageBody; body: UploadPlaylistImageBody;
query: UploadPlaylistImageQuery; query: UploadPlaylistImageQuery;
@@ -1415,6 +1443,9 @@ export type ControllerEndpoint = {
deleteInternetRadioStation: ( deleteInternetRadioStation: (
args: DeleteInternetRadioStationArgs, args: DeleteInternetRadioStationArgs,
) => Promise<DeleteInternetRadioStationResponse>; ) => Promise<DeleteInternetRadioStationResponse>;
deleteInternetRadioStationImage?: (
args: DeleteInternetRadioStationImageArgs,
) => Promise<DeleteInternetRadioStationImageResponse>;
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>; deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
deletePlaylistImage?: (args: DeletePlaylistImageArgs) => Promise<DeletePlaylistImageResponse>; deletePlaylistImage?: (args: DeletePlaylistImageArgs) => Promise<DeletePlaylistImageResponse>;
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>; getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
@@ -1470,6 +1501,9 @@ export type ControllerEndpoint = {
args: UpdateInternetRadioStationArgs, args: UpdateInternetRadioStationArgs,
) => Promise<UpdateInternetRadioStationResponse>; ) => Promise<UpdateInternetRadioStationResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>; updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
uploadInternetRadioStationImage?: (
args: UploadInternetRadioStationImageArgs,
) => Promise<UploadInternetRadioStationImageResponse>;
uploadPlaylistImage?: (args: UploadPlaylistImageArgs) => Promise<UploadPlaylistImageResponse>; uploadPlaylistImage?: (args: UploadPlaylistImageArgs) => Promise<UploadPlaylistImageResponse>;
}; };
@@ -1540,6 +1574,9 @@ export type InternalControllerEndpoint = {
deleteInternetRadioStation: ( deleteInternetRadioStation: (
args: ReplaceApiClientProps<DeleteInternetRadioStationArgs>, args: ReplaceApiClientProps<DeleteInternetRadioStationArgs>,
) => Promise<DeleteInternetRadioStationResponse>; ) => Promise<DeleteInternetRadioStationResponse>;
deleteInternetRadioStationImage?: (
args: ReplaceApiClientProps<DeleteInternetRadioStationImageArgs>,
) => Promise<DeleteInternetRadioStationImageResponse>;
deletePlaylist: ( deletePlaylist: (
args: ReplaceApiClientProps<DeletePlaylistArgs>, args: ReplaceApiClientProps<DeletePlaylistArgs>,
) => Promise<DeletePlaylistResponse>; ) => Promise<DeletePlaylistResponse>;
@@ -1630,6 +1667,9 @@ export type InternalControllerEndpoint = {
updatePlaylist: ( updatePlaylist: (
args: ReplaceApiClientProps<UpdatePlaylistArgs>, args: ReplaceApiClientProps<UpdatePlaylistArgs>,
) => Promise<UpdatePlaylistResponse>; ) => Promise<UpdatePlaylistResponse>;
uploadInternetRadioStationImage?: (
args: ReplaceApiClientProps<UploadInternetRadioStationImageArgs>,
) => Promise<UploadInternetRadioStationImageResponse>;
uploadPlaylistImage?: ( uploadPlaylistImage?: (
args: ReplaceApiClientProps<UploadPlaylistImageArgs>, args: ReplaceApiClientProps<UploadPlaylistImageArgs>,
) => Promise<UploadPlaylistImageResponse>; ) => Promise<UploadPlaylistImageResponse>;
+1
View File
@@ -3,6 +3,7 @@
export enum ServerFeature { export enum ServerFeature {
ALBUM_YES_NO_RATING_FILTER = 'albumYesNoRatingFilter', ALBUM_YES_NO_RATING_FILTER = 'albumYesNoRatingFilter',
BFR = 'bfr', BFR = 'bfr',
INTERNET_RADIO_IMAGE_UPLOAD = 'internetRadioImageUpload',
LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured', LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured',
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured', LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect', MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect',