[enhancement]: allow downloading individual tracks for external use

This commit is contained in:
Kendall Garner 2024-08-25 17:08:38 -07:00
parent 10fca2dc12
commit c4677a63f6
No known key found for this signature in database
GPG key ID: 18D2767419676C87
10 changed files with 91 additions and 6 deletions

View file

@ -318,6 +318,7 @@
"createPlaylist": "$t(action.createPlaylist)", "createPlaylist": "$t(action.createPlaylist)",
"deletePlaylist": "$t(action.deletePlaylist)", "deletePlaylist": "$t(action.deletePlaylist)",
"deselectAll": "$t(action.deselectAll)", "deselectAll": "$t(action.deselectAll)",
"download": "download",
"moveToBottom": "$t(action.moveToBottom)", "moveToBottom": "$t(action.moveToBottom)",
"moveToTop": "$t(action.moveToTop)", "moveToTop": "$t(action.moveToTop)",
"numberSelected": "{{count}} selected", "numberSelected": "{{count}} selected",

View file

@ -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; const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean;
if (globalMediaKeysEnabled) { if (globalMediaKeysEnabled) {

View file

@ -47,7 +47,12 @@ const logger = (
ipcRenderer.send('logger', cb); ipcRenderer.send('logger', cb);
}; };
const download = (url: string) => {
ipcRenderer.send('download-url', url);
};
export const utils = { export const utils = {
download,
isLinux, isLinux,
isMacOS, isMacOS,
isWindows, isWindows,

View file

@ -58,6 +58,7 @@ import type {
ServerType, ServerType,
ShareItemResponse, ShareItemResponse,
MoveItemArgs, MoveItemArgs,
DownloadArgs,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { DeletePlaylistResponse, RandomSongListArgs } from './types'; import { DeletePlaylistResponse, RandomSongListArgs } from './types';
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller'; import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
@ -83,6 +84,7 @@ export type ControllerEndpoint = Partial<{
getArtistDetail: () => void; getArtistDetail: () => void;
getArtistInfo: (args: any) => void; getArtistInfo: (args: any) => void;
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>; getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
getDownloadUrl: (args: DownloadArgs) => string;
getFavoritesList: () => void; getFavoritesList: () => void;
getFolderItemList: () => void; getFolderItemList: () => void;
getFolderList: () => void; getFolderList: () => void;
@ -132,6 +134,7 @@ const endpoints: ApiController = {
getArtistDetail: undefined, getArtistDetail: undefined,
getArtistInfo: undefined, getArtistInfo: undefined,
getArtistList: undefined, getArtistList: undefined,
getDownloadUrl: jfController.getDownloadUrl,
getFavoritesList: undefined, getFavoritesList: undefined,
getFolderItemList: undefined, getFolderItemList: undefined,
getFolderList: undefined, getFolderList: undefined,
@ -173,6 +176,7 @@ const endpoints: ApiController = {
getArtistDetail: undefined, getArtistDetail: undefined,
getArtistInfo: undefined, getArtistInfo: undefined,
getArtistList: undefined, getArtistList: undefined,
getDownloadUrl: ssController.getDownloadUrl,
getFavoritesList: undefined, getFavoritesList: undefined,
getFolderItemList: undefined, getFolderItemList: undefined,
getFolderList: undefined, getFolderList: undefined,
@ -213,6 +217,7 @@ const endpoints: ApiController = {
getArtistDetail: undefined, getArtistDetail: undefined,
getArtistInfo: undefined, getArtistInfo: undefined,
getArtistList: undefined, getArtistList: undefined,
getDownloadUrl: ssController.getDownloadUrl,
getFavoritesList: undefined, getFavoritesList: undefined,
getFolderItemList: undefined, getFolderItemList: undefined,
getFolderList: undefined, getFolderList: undefined,
@ -554,6 +559,15 @@ const movePlaylistItem = async (args: MoveItemArgs) => {
)?.(args); )?.(args);
}; };
const getDownloadUrl = (args: DownloadArgs) => {
return (
apiController(
'getDownloadUrl',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getDownloadUrl']
)?.(args);
};
export const controller = { export const controller = {
addToPlaylist, addToPlaylist,
authenticate, authenticate,
@ -566,6 +580,7 @@ export const controller = {
getAlbumDetail, getAlbumDetail,
getAlbumList, getAlbumList,
getArtistList, getArtistList,
getDownloadUrl,
getGenreList, getGenreList,
getLyrics, getLyrics,
getMusicFolderList, getMusicFolderList,

View file

@ -54,6 +54,7 @@ import {
SimilarSongsArgs, SimilarSongsArgs,
Song, Song,
MoveItemArgs, MoveItemArgs,
DownloadArgs,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
import { jfNormalize } from './jellyfin-normalize'; import { jfNormalize } from './jellyfin-normalize';
@ -1043,6 +1044,12 @@ const movePlaylistItem = async (args: MoveItemArgs): Promise<void> => {
} }
}; };
const getDownloadUrl = (args: DownloadArgs) => {
const { apiClientProps, query } = args;
return `${apiClientProps.server?.url}/items/${query.id}/download?api_key=${apiClientProps.server?.credential}`;
};
export const jfController = { export const jfController = {
addToPlaylist, addToPlaylist,
authenticate, authenticate,
@ -1055,6 +1062,7 @@ export const jfController = {
getAlbumDetail, getAlbumDetail,
getAlbumList, getAlbumList,
getArtistList, getArtistList,
getDownloadUrl,
getGenreList, getGenreList,
getLyrics, getLyrics,
getMusicFolderList, getMusicFolderList,

View file

@ -27,6 +27,7 @@ import {
StructuredLyric, StructuredLyric,
SimilarSongsArgs, SimilarSongsArgs,
Song, Song,
DownloadArgs,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { randomString } from '/@/renderer/utils'; import { randomString } from '/@/renderer/utils';
import { ServerFeatures } from '/@/renderer/api/features-types'; import { ServerFeatures } from '/@/renderer/api/features-types';
@ -482,10 +483,23 @@ const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
}, []); }, []);
}; };
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 = { export const ssController = {
authenticate, authenticate,
createFavorite, createFavorite,
getArtistInfo, getArtistInfo,
getDownloadUrl,
getMusicFolderList, getMusicFolderList,
getRandomSongList, getRandomSongList,
getServerInfo, getServerInfo,

View file

@ -1202,3 +1202,11 @@ export type MoveItemQuery = {
export type MoveItemArgs = { export type MoveItemArgs = {
query: MoveItemQuery; query: MoveItemQuery;
} & BaseEndpointArgs; } & BaseEndpointArgs;
export type DownloadQuery = {
id: string;
};
export type DownloadArgs = {
query: DownloadQuery;
} & BaseEndpointArgs;

View file

@ -8,7 +8,8 @@ export const QUEUE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'addToFavorites' }, { id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' }, { divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' }, { 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: 'shareItem' },
{ divider: true, id: 'showDetails' }, { divider: true, id: 'showDetails' },
]; ];
@ -22,6 +23,7 @@ export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'addToFavorites' }, { id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' }, { divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, divider: true, id: 'setRating' }, { children: true, disabled: false, divider: true, id: 'setRating' },
{ id: 'download' },
{ divider: true, id: 'shareItem' }, { divider: true, id: 'shareItem' },
{ divider: true, id: 'showDetails' }, { divider: true, id: 'showDetails' },
]; ];
@ -43,6 +45,7 @@ export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'addToFavorites' }, { id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' }, { divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' }, { children: true, disabled: false, id: 'setRating' },
{ id: 'download' },
{ divider: true, id: 'shareItem' }, { divider: true, id: 'shareItem' },
{ divider: true, id: 'showDetails' }, { divider: true, id: 'showDetails' },
]; ];
@ -56,6 +59,7 @@ export const SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'addToFavorites' }, { id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' }, { divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' }, { children: true, disabled: false, id: 'setRating' },
{ id: 'download' },
{ divider: true, id: 'shareItem' }, { divider: true, id: 'shareItem' },
{ divider: true, id: 'showDetails' }, { divider: true, id: 'showDetails' },
]; ];

View file

@ -30,6 +30,7 @@ import {
RiShareForwardFill, RiShareForwardFill,
RiInformationFill, RiInformationFill,
RiRadio2Fill, RiRadio2Fill,
RiDownload2Line,
} from 'react-icons/ri'; } from 'react-icons/ri';
import { AnyLibraryItems, LibraryItem, ServerType, AnyLibraryItem } from '/@/renderer/api/types'; import { AnyLibraryItems, LibraryItem, ServerType, AnyLibraryItem } from '/@/renderer/api/types';
import { import {
@ -62,6 +63,7 @@ import { Play, PlaybackType } from '/@/renderer/types';
import { ItemDetailsModal } from '/@/renderer/features/item-details/components/item-details-modal'; import { ItemDetailsModal } from '/@/renderer/features/item-details/components/item-details-modal';
import { updateSong } from '/@/renderer/features/player/update-remote-song'; import { updateSong } from '/@/renderer/features/player/update-remote-song';
import { controller } from '/@/renderer/api/controller'; import { controller } from '/@/renderer/api/controller';
import { api } from '/@/renderer/api';
type ContextMenuContextProps = { type ContextMenuContextProps = {
closeContextMenu: () => void; closeContextMenu: () => void;
@ -90,6 +92,7 @@ const JELLYFIN_IGNORED_MENU_ITEMS: ContextMenuItemType[] = ['setRating', 'shareI
// const SUBSONIC_IGNORED_MENU_ITEMS: ContextMenuItemType[] = []; // const SUBSONIC_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null; const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const utils = isElectron() ? window.electron.utils : null;
export interface ContextMenuProviderProps { export interface ContextMenuProviderProps {
children: ReactNode; children: ReactNode;
@ -685,6 +688,20 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
handlePlayQueueAdd?.({ byData: [ctx.data[0], ...songs], playType: Play.NOW }); handlePlayQueueAdd?.({ byData: [ctx.data[0], ...songs], playType: Play.NOW });
}, [ctx, handlePlayQueueAdd]); }, [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<ContextMenuItemType, ContextMenuItem> = useMemo(() => { const contextMenuItems: Record<ContextMenuItemType, ContextMenuItem> = useMemo(() => {
return { return {
addToFavorites: { addToFavorites: {
@ -716,6 +733,13 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
leftIcon: <RiCloseCircleLine size="1.1rem" />, leftIcon: <RiCloseCircleLine size="1.1rem" />,
onClick: handleDeselectAll, onClick: handleDeselectAll,
}, },
download: {
disabled: ctx.data?.length !== 1,
id: 'download',
label: t('page.contextMenu.download', { postProcess: 'sentenceCase' }),
leftIcon: <RiDownload2Line size="1.1rem" />,
onClick: handleDownload,
},
moveToBottomOfQueue: { moveToBottomOfQueue: {
id: 'moveToBottomOfQueue', id: 'moveToBottomOfQueue',
label: t('page.contextMenu.moveToBottom', { postProcess: 'sentenceCase' }), label: t('page.contextMenu.moveToBottom', { postProcess: 'sentenceCase' }),
@ -860,18 +884,19 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
handleAddToPlaylist, handleAddToPlaylist,
openDeletePlaylistModal, openDeletePlaylistModal,
handleDeselectAll, handleDeselectAll,
ctx.data,
handleDownload,
handleMoveToBottom, handleMoveToBottom,
handleMoveToTop, handleMoveToTop,
handleSimilar,
handleRemoveFromFavorites, handleRemoveFromFavorites,
handleRemoveFromPlaylist, handleRemoveFromPlaylist,
handleRemoveSelected, handleRemoveSelected,
ctx.data, server,
handleShareItem,
handleOpenItemDetails, handleOpenItemDetails,
handlePlay, handlePlay,
handleUpdateRating, handleUpdateRating,
handleShareItem,
server,
handleSimilar,
]); ]);
const mergedRef = useMergedRef(ref, clickOutsideRef); const mergedRef = useMergedRef(ref, clickOutsideRef);

View file

@ -36,7 +36,8 @@ export type ContextMenuItemType =
| 'removeFromQueue' | 'removeFromQueue'
| 'deselectAll' | 'deselectAll'
| 'showDetails' | 'showDetails'
| 'playSimilarSongs'; | 'playSimilarSongs'
| 'download';
export type SetContextMenuItems = { export type SetContextMenuItems = {
children?: boolean; children?: boolean;