diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 16210264..22d74b30 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -318,6 +318,7 @@ "createPlaylist": "$t(action.createPlaylist)", "deletePlaylist": "$t(action.deletePlaylist)", "deselectAll": "$t(action.deselectAll)", + "download": "download", "moveToBottom": "$t(action.moveToBottom)", "moveToTop": "$t(action.moveToTop)", "numberSelected": "{{count}} selected", diff --git a/src/main/main.ts b/src/main/main.ts index 6c3a2cef..df4bda3a 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -364,6 +364,10 @@ const createWindow = async (first = true) => { } }); + ipcMain.on('download-url', (_event, url: string) => { + mainWindow?.webContents.downloadURL(url); + }); + const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean; if (globalMediaKeysEnabled) { diff --git a/src/main/preload/utils.ts b/src/main/preload/utils.ts index dc4a4132..bd90f956 100644 --- a/src/main/preload/utils.ts +++ b/src/main/preload/utils.ts @@ -47,7 +47,12 @@ const logger = ( ipcRenderer.send('logger', cb); }; +const download = (url: string) => { + ipcRenderer.send('download-url', url); +}; + export const utils = { + download, isLinux, isMacOS, isWindows, diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 4e9dd21f..d3cf0549 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -58,6 +58,7 @@ import type { ServerType, ShareItemResponse, MoveItemArgs, + DownloadArgs, } from '/@/renderer/api/types'; import { DeletePlaylistResponse, RandomSongListArgs } from './types'; import { ndController } from '/@/renderer/api/navidrome/navidrome-controller'; @@ -83,6 +84,7 @@ export type ControllerEndpoint = Partial<{ getArtistDetail: () => void; getArtistInfo: (args: any) => void; getArtistList: (args: ArtistListArgs) => Promise; + getDownloadUrl: (args: DownloadArgs) => string; getFavoritesList: () => void; getFolderItemList: () => void; getFolderList: () => void; @@ -132,6 +134,7 @@ const endpoints: ApiController = { getArtistDetail: undefined, getArtistInfo: undefined, getArtistList: undefined, + getDownloadUrl: jfController.getDownloadUrl, getFavoritesList: undefined, getFolderItemList: undefined, getFolderList: undefined, @@ -173,6 +176,7 @@ const endpoints: ApiController = { getArtistDetail: undefined, getArtistInfo: undefined, getArtistList: undefined, + getDownloadUrl: ssController.getDownloadUrl, getFavoritesList: undefined, getFolderItemList: undefined, getFolderList: undefined, @@ -213,6 +217,7 @@ const endpoints: ApiController = { getArtistDetail: undefined, getArtistInfo: undefined, getArtistList: undefined, + getDownloadUrl: ssController.getDownloadUrl, getFavoritesList: undefined, getFolderItemList: undefined, getFolderList: undefined, @@ -554,6 +559,15 @@ const movePlaylistItem = async (args: MoveItemArgs) => { )?.(args); }; +const getDownloadUrl = (args: DownloadArgs) => { + return ( + apiController( + 'getDownloadUrl', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['getDownloadUrl'] + )?.(args); +}; + export const controller = { addToPlaylist, authenticate, @@ -566,6 +580,7 @@ export const controller = { getAlbumDetail, getAlbumList, getArtistList, + getDownloadUrl, getGenreList, getLyrics, getMusicFolderList, diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 83507abc..8577d62c 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -54,6 +54,7 @@ import { SimilarSongsArgs, Song, MoveItemArgs, + DownloadArgs, } from '/@/renderer/api/types'; import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; import { jfNormalize } from './jellyfin-normalize'; @@ -1043,6 +1044,12 @@ const movePlaylistItem = async (args: MoveItemArgs): Promise => { } }; +const getDownloadUrl = (args: DownloadArgs) => { + const { apiClientProps, query } = args; + + return `${apiClientProps.server?.url}/items/${query.id}/download?api_key=${apiClientProps.server?.credential}`; +}; + export const jfController = { addToPlaylist, authenticate, @@ -1055,6 +1062,7 @@ export const jfController = { getAlbumDetail, getAlbumList, getArtistList, + getDownloadUrl, getGenreList, getLyrics, getMusicFolderList, diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index b6acc9e7..ac2e9ed9 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -27,6 +27,7 @@ import { StructuredLyric, SimilarSongsArgs, Song, + DownloadArgs, } from '/@/renderer/api/types'; import { randomString } from '/@/renderer/utils'; import { ServerFeatures } from '/@/renderer/api/features-types'; @@ -482,10 +483,23 @@ const getSimilarSongs = async (args: SimilarSongsArgs): Promise => { }, []); }; +const getDownloadUrl = (args: DownloadArgs) => { + const { apiClientProps, query } = args; + + return ( + `${apiClientProps.server?.url}/rest/download.view` + + `?id=${query.id}` + + `&${apiClientProps.server?.credential}` + + '&v=1.13.0' + + '&c=feishin' + ); +}; + export const ssController = { authenticate, createFavorite, getArtistInfo, + getDownloadUrl, getMusicFolderList, getRandomSongList, getServerInfo, diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 23536eb4..7a16060d 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -1202,3 +1202,11 @@ export type MoveItemQuery = { export type MoveItemArgs = { query: MoveItemQuery; } & BaseEndpointArgs; + +export type DownloadQuery = { + id: string; +}; + +export type DownloadArgs = { + query: DownloadQuery; +} & BaseEndpointArgs; diff --git a/src/renderer/features/context-menu/context-menu-items.tsx b/src/renderer/features/context-menu/context-menu-items.tsx index 4c786057..3d9f23e5 100644 --- a/src/renderer/features/context-menu/context-menu-items.tsx +++ b/src/renderer/features/context-menu/context-menu-items.tsx @@ -8,7 +8,8 @@ export const QUEUE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ { id: 'addToFavorites' }, { divider: true, id: 'removeFromFavorites' }, { children: true, disabled: false, id: 'setRating' }, - { disabled: false, id: 'deselectAll' }, + { disabled: false, divider: true, id: 'deselectAll' }, + { id: 'download' }, { divider: true, id: 'shareItem' }, { divider: true, id: 'showDetails' }, ]; @@ -22,6 +23,7 @@ export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ { id: 'addToFavorites' }, { divider: true, id: 'removeFromFavorites' }, { children: true, disabled: false, divider: true, id: 'setRating' }, + { id: 'download' }, { divider: true, id: 'shareItem' }, { divider: true, id: 'showDetails' }, ]; @@ -43,6 +45,7 @@ export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ { id: 'addToFavorites' }, { divider: true, id: 'removeFromFavorites' }, { children: true, disabled: false, id: 'setRating' }, + { id: 'download' }, { divider: true, id: 'shareItem' }, { divider: true, id: 'showDetails' }, ]; @@ -56,6 +59,7 @@ export const SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ { id: 'addToFavorites' }, { divider: true, id: 'removeFromFavorites' }, { children: true, disabled: false, id: 'setRating' }, + { id: 'download' }, { divider: true, id: 'shareItem' }, { divider: true, id: 'showDetails' }, ]; diff --git a/src/renderer/features/context-menu/context-menu-provider.tsx b/src/renderer/features/context-menu/context-menu-provider.tsx index cc9299de..35471dcd 100644 --- a/src/renderer/features/context-menu/context-menu-provider.tsx +++ b/src/renderer/features/context-menu/context-menu-provider.tsx @@ -30,6 +30,7 @@ import { RiShareForwardFill, RiInformationFill, RiRadio2Fill, + RiDownload2Line, } from 'react-icons/ri'; import { AnyLibraryItems, LibraryItem, ServerType, AnyLibraryItem } from '/@/renderer/api/types'; import { @@ -62,6 +63,7 @@ import { Play, PlaybackType } from '/@/renderer/types'; import { ItemDetailsModal } from '/@/renderer/features/item-details/components/item-details-modal'; import { updateSong } from '/@/renderer/features/player/update-remote-song'; import { controller } from '/@/renderer/api/controller'; +import { api } from '/@/renderer/api'; type ContextMenuContextProps = { closeContextMenu: () => void; @@ -90,6 +92,7 @@ const JELLYFIN_IGNORED_MENU_ITEMS: ContextMenuItemType[] = ['setRating', 'shareI // const SUBSONIC_IGNORED_MENU_ITEMS: ContextMenuItemType[] = []; const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null; +const utils = isElectron() ? window.electron.utils : null; export interface ContextMenuProviderProps { children: ReactNode; @@ -685,6 +688,20 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { handlePlayQueueAdd?.({ byData: [ctx.data[0], ...songs], playType: Play.NOW }); }, [ctx, handlePlayQueueAdd]); + const handleDownload = useCallback(() => { + const item = ctx.data[0]; + const url = api.controller.getDownloadUrl({ + apiClientProps: { server }, + query: { id: item.id }, + }); + + if (utils) { + utils.download(url!); + } else { + window.open(url, '_blank'); + } + }, [ctx.data, server]); + const contextMenuItems: Record = useMemo(() => { return { addToFavorites: { @@ -716,6 +733,13 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { leftIcon: , onClick: handleDeselectAll, }, + download: { + disabled: ctx.data?.length !== 1, + id: 'download', + label: t('page.contextMenu.download', { postProcess: 'sentenceCase' }), + leftIcon: , + onClick: handleDownload, + }, moveToBottomOfQueue: { id: 'moveToBottomOfQueue', label: t('page.contextMenu.moveToBottom', { postProcess: 'sentenceCase' }), @@ -860,18 +884,19 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { handleAddToPlaylist, openDeletePlaylistModal, handleDeselectAll, + ctx.data, + handleDownload, handleMoveToBottom, handleMoveToTop, + handleSimilar, handleRemoveFromFavorites, handleRemoveFromPlaylist, handleRemoveSelected, - ctx.data, + server, + handleShareItem, handleOpenItemDetails, handlePlay, handleUpdateRating, - handleShareItem, - server, - handleSimilar, ]); const mergedRef = useMergedRef(ref, clickOutsideRef); diff --git a/src/renderer/features/context-menu/events.ts b/src/renderer/features/context-menu/events.ts index 63e4fe32..4a64db17 100644 --- a/src/renderer/features/context-menu/events.ts +++ b/src/renderer/features/context-menu/events.ts @@ -36,7 +36,8 @@ export type ContextMenuItemType = | 'removeFromQueue' | 'deselectAll' | 'showDetails' - | 'playSimilarSongs'; + | 'playSimilarSongs' + | 'download'; export type SetContextMenuItems = { children?: boolean;