Implement Navidrome sharing (#575)

* add share item feature

* take care of (mostly) everything

* bugfixes

* allow clicking on notification to open url

* readd the missing modal after router migration

* remove unnecessary extension

---------

Co-authored-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
This commit is contained in:
Benjamin 2024-04-21 22:03:22 -05:00 committed by GitHub
parent 0d03b66fe5
commit cb2597d2c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 303 additions and 4 deletions

View file

@ -101,6 +101,7 @@
"setting": "setting",
"setting_one": "setting",
"setting_other": "settings",
"share": "share",
"size": "size",
"sortOrder": "order",
"title": "title",
@ -257,6 +258,14 @@
"input_optionMatchAll": "match all",
"input_optionMatchAny": "match any"
},
"shareItem": {
"allowDownloading": "allow downloading",
"description": "description",
"setExpiration": "set expiration",
"success": "share link copied to clipboard (or click here to open)",
"expireInvalid": "expiration must be in the future",
"createFailed": "failed to create share (is sharing enabled?)"
},
"updateServer": {
"success": "server updated successfully",
"title": "update server"
@ -315,6 +324,7 @@
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
"removeFromQueue": "$t(action.removeFromQueue)",
"setRating": "$t(action.setRating)",
"shareItem": "share item",
"showDetails": "get info"
},
"fullscreenPlayer": {

View file

@ -8,6 +8,7 @@ import type {
AlbumArtistDetailArgs,
AlbumArtistListArgs,
SetRatingArgs,
ShareItemArgs,
GenreListArgs,
CreatePlaylistArgs,
DeletePlaylistArgs,
@ -55,6 +56,7 @@ import type {
SimilarSongsArgs,
Song,
ServerType,
ShareItemResponse,
} from '/@/renderer/api/types';
import { DeletePlaylistResponse, RandomSongListArgs } from './types';
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
@ -102,6 +104,7 @@ export type ControllerEndpoint = Partial<{
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
search: (args: SearchArgs) => Promise<SearchResponse>;
setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
shareItem: (args: ShareItemArgs) => Promise<ShareItemResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
}>;
@ -149,6 +152,7 @@ const endpoints: ApiController = {
scrobble: jfController.scrobble,
search: jfController.search,
setRating: undefined,
shareItem: undefined,
updatePlaylist: jfController.updatePlaylist,
},
navidrome: {
@ -188,6 +192,7 @@ const endpoints: ApiController = {
scrobble: ssController.scrobble,
search: ssController.search3,
setRating: ssController.setRating,
shareItem: ndController.shareItem,
updatePlaylist: ndController.updatePlaylist,
},
subsonic: {
@ -223,6 +228,7 @@ const endpoints: ApiController = {
scrobble: ssController.scrobble,
search: ssController.search3,
setRating: undefined,
shareItem: undefined,
updatePlaylist: undefined,
},
};
@ -457,6 +463,15 @@ const updateRating = async (args: SetRatingArgs) => {
)?.(args);
};
const shareItem = async (args: ShareItemArgs) => {
return (
apiController(
'shareItem',
args.apiClientProps.server?.type,
) as ControllerEndpoint['shareItem']
)?.(args);
};
const getTopSongList = async (args: TopSongListArgs) => {
return (
apiController(
@ -555,6 +570,7 @@ export const controller = {
removeFromPlaylist,
scrobble,
search,
shareItem,
updatePlaylist,
updateRating,
};

View file

@ -4,6 +4,7 @@ export enum ServerFeature {
LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured',
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
PLAYLISTS_SMART = 'playlistsSmart',
SHARING_ALBUM_SONG = 'sharingAlbumSong',
}
export type ServerFeatures = Partial<Record<ServerFeature, boolean>>;

View file

@ -157,6 +157,16 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
shareItem: {
body: ndType._parameters.shareItem,
method: 'POST',
path: 'share',
responses: {
200: resultWithHeaders(ndType._response.shareItem),
404: resultWithHeaders(ndType._response.error),
500: resultWithHeaders(ndType._response.error),
},
},
updatePlaylist: {
body: ndType._parameters.updatePlaylist,
method: 'PUT',

View file

@ -47,6 +47,8 @@ import {
genreListSortMap,
ServerInfo,
ServerInfoArgs,
ShareItemArgs,
ShareItemResponse,
SimilarSongsArgs,
Song,
} from '../types';
@ -484,7 +486,10 @@ const removeFromPlaylist = async (
return null;
};
// The order should be in decreasing version, as the highest version match
// will automatically consider all lower versions matched
const VERSION_INFO: Array<[string, Record<string, number[]>]> = [
['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }],
['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }],
];
@ -544,11 +549,34 @@ const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
const features: ServerFeatures = {
lyricsMultipleStructured: !!navidromeFeatures[SubsonicExtensions.SONG_LYRICS],
playlistsSmart: !!navidromeFeatures[ServerFeature.PLAYLISTS_SMART],
sharingAlbumSong: !!navidromeFeatures[ServerFeature.SHARING_ALBUM_SONG],
};
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion! };
};
const shareItem = async (args: ShareItemArgs): Promise<ShareItemResponse> => {
const { body, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).shareItem({
body: {
description: body.description,
downloadable: body.downloadable,
expires: body.expires,
resourceIds: body.resourceIds,
resourceType: body.resourceType,
},
});
if (res.status !== 200) {
throw new Error('Failed to share item');
}
return {
id: res.body.data.id,
};
};
const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
const { apiClientProps, query } = args;
@ -620,5 +648,6 @@ export const ndController = {
getSongList,
getUserList,
removeFromPlaylist,
shareItem,
updatePlaylist,
};

View file

@ -343,6 +343,18 @@ const removeFromPlaylistParameters = z.object({
id: z.array(z.string()),
});
const shareItem = z.object({
id: z.string(),
});
const shareItemParameters = z.object({
description: z.string(),
downloadable: z.boolean(),
expires: z.number(),
resourceIds: z.string(),
resourceType: z.string(),
});
export const ndType = {
_enum: {
albumArtistList: ndAlbumArtistListSort,
@ -361,6 +373,7 @@ export const ndType = {
genreList: genreListParameters,
playlistList: playlistListParameters,
removeFromPlaylist: removeFromPlaylistParameters,
shareItem: shareItemParameters,
songList: songListParameters,
updatePlaylist: updatePlaylistParameters,
userList: userListParameters,
@ -382,6 +395,7 @@ export const ndType = {
playlistSong,
playlistSongList,
removeFromPlaylist,
shareItem,
song,
songList,
updatePlaylist,

View file

@ -766,6 +766,19 @@ export type RatingQuery = {
export type SetRatingArgs = { query: RatingQuery; serverId?: string } & BaseEndpointArgs;
// Sharing
export type ShareItemResponse = { id: string } | undefined;
export type ShareItemBody = {
description: string;
downloadable: boolean;
expires: number;
resourceIds: string;
resourceType: string;
};
export type ShareItemArgs = { body: ShareItemBody; serverId?: string } & BaseEndpointArgs;
// Add to playlist
export type AddToPlaylistResponse = null | undefined;

View file

@ -19,7 +19,8 @@ export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
{ children: true, disabled: false, divider: true, id: 'setRating' },
{ divider: true, id: 'shareItem' },
{ divider: true, id: 'showDetails' },
];
@ -53,7 +54,8 @@ export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
{ children: true, disabled: false, divider: true, id: 'setRating' },
{ divider: true, id: 'shareItem' },
{ divider: true, id: 'showDetails' },
];

View file

@ -11,6 +11,8 @@ import {
import { closeAllModals, openContextModal, openModal } from '@mantine/modals';
import { AnimatePresence } from 'framer-motion';
import isElectron from 'is-electron';
import { ServerFeature } from '/@/renderer/api/features-types';
import { hasFeature } from '/@/renderer/api/utils';
import { useTranslation } from 'react-i18next';
import {
RiAddBoxFill,
@ -25,6 +27,7 @@ import {
RiPlayListAddFill,
RiStarFill,
RiCloseCircleLine,
RiShareForwardFill,
RiInformationFill,
} from 'react-icons/ri';
import { AnyLibraryItems, LibraryItem, ServerType, AnyLibraryItem } from '/@/renderer/api/types';
@ -78,7 +81,7 @@ const ContextMenuContext = createContext<ContextMenuContextProps>({
},
});
const JELLYFIN_IGNORED_MENU_ITEMS: ContextMenuItemType[] = ['setRating'];
const JELLYFIN_IGNORED_MENU_ITEMS: ContextMenuItemType[] = ['setRating', 'shareItem'];
// const NAVIDROME_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
// const SUBSONIC_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
@ -602,6 +605,22 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
}
}, [ctx.dataNodes, moveToTopOfQueue, playbackType]);
const handleShareItem = useCallback(() => {
if (!ctx.dataNodes && !ctx.data) return;
const uniqueIds = ctx.data.map((node) => node.id);
openContextModal({
innerProps: {
itemIds: uniqueIds,
resourceType: ctx.data[0].itemType,
},
modal: 'shareItem',
size: 'md',
title: t('page.contextMenu.shareItem', { postProcess: 'sentenceCase' }),
});
}, [ctx.data, ctx.dataNodes, t]);
const handleRemoveSelected = useCallback(() => {
const uniqueIds = ctx.dataNodes?.map((row) => row.data.uniqueId);
if (!uniqueIds?.length) return;
@ -787,6 +806,13 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
onClick: () => {},
rightIcon: <RiArrowRightSFill size="1.2rem" />,
},
shareItem: {
disabled: !hasFeature(server, ServerFeature.SHARING_ALBUM_SONG),
id: 'shareItem',
label: t('page.contextMenu.shareItem', { postProcess: 'sentenceCase' }),
leftIcon: <RiShareForwardFill size="1.1rem" />,
onClick: handleShareItem,
},
showDetails: {
disabled: ctx.data?.length !== 1 || !ctx.data[0].itemType,
id: 'showDetails',
@ -810,6 +836,8 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
handleOpenItemDetails,
handlePlay,
handleUpdateRating,
handleShareItem,
server,
]);
const mergedRef = useMergedRef(ref, clickOutsideRef);

View file

@ -28,6 +28,7 @@ export type ContextMenuItemType =
| 'addToFavorites'
| 'removeFromFavorites'
| 'setRating'
| 'shareItem'
| 'deletePlaylist'
| 'createPlaylist'
| 'moveToBottomOfQueue'

View file

@ -0,0 +1,144 @@
import { Box, Group, Stack, TextInput } from '@mantine/core';
import { DateTimePicker } from '@mantine/dates';
import { useForm } from '@mantine/form';
import { closeModal, ContextModalProps } from '@mantine/modals';
import { Button, Switch, toast } from '/@/renderer/components';
import { useCurrentServer } from '/@/renderer/store';
import { useTranslation } from 'react-i18next';
import { useShareItem } from '../mutations/share-item-mutation';
export const ShareItemContextModal = ({
id,
innerProps,
}: ContextModalProps<{
itemIds: string[];
resourceType: string;
}>) => {
const { t } = useTranslation();
const { itemIds, resourceType } = innerProps;
const server = useCurrentServer();
const shareItemMutation = useShareItem({});
// Uses the same default as Navidrome: 1 year
const defaultDate = new Date();
defaultDate.setFullYear(defaultDate.getFullYear() + 1);
const form = useForm({
initialValues: {
allowDownloading: false,
description: '',
expires: defaultDate,
},
validate: {
expires: (value) =>
value > new Date()
? null
: t('form.shareItem.expireInvalid', {
postProcess: 'sentenceCase',
}),
},
});
const handleSubmit = form.onSubmit(async (values) => {
shareItemMutation.mutate(
{
body: {
description: values.description,
downloadable: values.allowDownloading,
expires: values.expires.getTime(),
resourceIds: itemIds.join(),
resourceType,
},
serverId: server?.id,
},
{
onError: () => {
toast.error({
message: t('form.shareItem.createFailed', {
postProcess: 'sentenceCase',
}),
});
},
onSuccess: (_data) => {
if (!server) throw new Error('Server not found');
if (!_data?.id) throw new Error('Failed to share item');
const shareUrl = `${server.url}/share/${_data.id}`;
navigator.clipboard.writeText(shareUrl);
toast.success({
autoClose: 5000,
id: 'share-item-toast',
message: t('form.shareItem.success', {
postProcess: 'sentenceCase',
}),
onClick: (a) => {
if (!(a.target instanceof HTMLElement)) return;
// Make sure we weren't clicking close (otherwise clicking close /also/ opens the url)
if (a.target.nodeName !== 'svg') {
window.open(shareUrl);
toast.hide('share-item-toast');
}
},
});
},
},
);
closeModal(id);
return null;
});
return (
<Box p="1rem">
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
label={t('form.shareItem.description', {
postProcess: 'titleCase',
})}
{...form.getInputProps('description')}
/>
<Switch
defaultChecked={false}
label={t('form.shareItem.allowDownloading', {
postProcess: 'titleCase',
})}
{...form.getInputProps('allowDownloading')}
/>
<DateTimePicker
clearable
label={t('form.shareItem.setExpiration', {
postProcess: 'titleCase',
})}
minDate={new Date()}
placeholder={defaultDate.toLocaleDateString()}
popoverProps={{ withinPortal: true }}
valueFormat="MM/DD/YYYY HH:mm"
{...form.getInputProps('expires')}
/>
<Group position="right">
<Group>
<Button
size="md"
variant="subtle"
onClick={() => closeModal(id)}
>
{t('common.cancel', { postProcess: 'titleCase' })}
</Button>
<Button
size="md"
type="submit"
variant="filled"
>
{t('common.share', { postProcess: 'titleCase' })}
</Button>
</Group>
</Group>
</Stack>
</form>
</Box>
);
};

View file

@ -0,0 +1,2 @@
export * from './components/share-item-context-modal';
export * from './mutations/share-item-mutation';

View file

@ -0,0 +1,24 @@
import { useMutation } from '@tanstack/react-query';
import { AnyLibraryItems, ShareItemResponse, ShareItemArgs } from '/@/renderer/api/types';
import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
export const useShareItem = (args: MutationHookArgs) => {
const { options } = args || {};
return useMutation<
ShareItemResponse,
AxiosError,
Omit<ShareItemArgs, 'server' | 'apiClientProps'>,
{ previous: { items: AnyLibraryItems } | undefined }
>({
mutationFn: (args) => {
const server = getServerById(args.serverId);
if (!server) throw new Error('Server not found');
return api.controller.shareItem({ ...args, apiClientProps: { server } });
},
...options,
});
};

View file

@ -7,6 +7,7 @@ import { TitlebarOutlet } from '/@/renderer/router/titlebar-outlet';
import { ModalsProvider } from '@mantine/modals';
import { BaseContextModal } from '/@/renderer/components';
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists';
import { ShareItemContextModal } from '/@/renderer/features/sharing';
const NowPlayingRoute = lazy(
() => import('/@/renderer/features/now-playing/routes/now-playing-route'),
@ -80,7 +81,11 @@ export const AppRouter = () => {
transition: 'fade',
},
}}
modals={{ addToPlaylist: AddToPlaylistContextModal, base: BaseContextModal }}
modals={{
addToPlaylist: AddToPlaylistContextModal,
base: BaseContextModal,
shareItem: ShareItemContextModal,
}}
>
<Routes>
<Route element={<TitlebarOutlet />}>