Add ability to add/remove songs from playlist (#17)

* Add api for add/remove playlist items

* Add playlistItemId property to normalized Song

- This is used for Navidrome to delete songs from playlists

* Add mutations for add/remove from playlist

* Add context modal for playlist add

* Add remove from playlist from context menu

* Set jellyfin to use playlistItemId

* Adjust font sizing

* Add playlist add from detail pages

* Bump mantine to v6-alpha.2
This commit is contained in:
Jeff 2023-01-29 18:40:26 -08:00 committed by GitHub
parent be39c2bc1f
commit 59f4f43e84
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1120 additions and 982 deletions

View file

@ -39,11 +39,16 @@ import type {
FavoriteArgs,
TopSongListArgs,
RawTopSongListResponse,
AddToPlaylistArgs,
RawAddToPlaylistResponse,
RemoveFromPlaylistArgs,
RawRemoveFromPlaylistResponse,
} from '/@/renderer/api/types';
import { subsonicApi } from '/@/renderer/api/subsonic.api';
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
export type ControllerEndpoint = Partial<{
addToPlaylist: (args: AddToPlaylistArgs) => Promise<RawAddToPlaylistResponse>;
clearPlaylist: () => void;
createFavorite: (args: FavoriteArgs) => Promise<RawFavoriteResponse>;
createPlaylist: (args: CreatePlaylistArgs) => Promise<RawCreatePlaylistResponse>;
@ -69,6 +74,7 @@ export type ControllerEndpoint = Partial<{
getSongList: (args: SongListArgs) => Promise<RawSongListResponse>;
getTopSongs: (args: TopSongListArgs) => Promise<RawTopSongListResponse>;
getUserList: (args: UserListArgs) => Promise<RawUserListResponse>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RawRemoveFromPlaylistResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<RawUpdatePlaylistResponse>;
updateRating: (args: RatingArgs) => Promise<RawRatingResponse>;
}>;
@ -81,6 +87,7 @@ type ApiController = {
const endpoints: ApiController = {
jellyfin: {
addToPlaylist: jellyfinApi.addToPlaylist,
clearPlaylist: undefined,
createFavorite: jellyfinApi.createFavorite,
createPlaylist: jellyfinApi.createPlaylist,
@ -106,10 +113,12 @@ const endpoints: ApiController = {
getSongList: jellyfinApi.getSongList,
getTopSongs: undefined,
getUserList: undefined,
removeFromPlaylist: jellyfinApi.removeFromPlaylist,
updatePlaylist: jellyfinApi.updatePlaylist,
updateRating: undefined,
},
navidrome: {
addToPlaylist: navidromeApi.addToPlaylist,
clearPlaylist: undefined,
createFavorite: subsonicApi.createFavorite,
createPlaylist: navidromeApi.createPlaylist,
@ -135,6 +144,7 @@ const endpoints: ApiController = {
getSongList: navidromeApi.getSongList,
getTopSongs: subsonicApi.getTopSongList,
getUserList: navidromeApi.getUserList,
removeFromPlaylist: navidromeApi.removeFromPlaylist,
updatePlaylist: navidromeApi.updatePlaylist,
updateRating: subsonicApi.updateRating,
},
@ -239,6 +249,14 @@ const deletePlaylist = async (args: DeletePlaylistArgs) => {
return (apiController('deletePlaylist') as ControllerEndpoint['deletePlaylist'])?.(args);
};
const addToPlaylist = async (args: AddToPlaylistArgs) => {
return (apiController('addToPlaylist') as ControllerEndpoint['addToPlaylist'])?.(args);
};
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs) => {
return (apiController('removeFromPlaylist') as ControllerEndpoint['removeFromPlaylist'])?.(args);
};
const getPlaylistDetail = async (args: PlaylistDetailArgs) => {
return (apiController('getPlaylistDetail') as ControllerEndpoint['getPlaylistDetail'])?.(args);
};
@ -270,6 +288,7 @@ const getTopSongList = async (args: TopSongListArgs) => {
};
export const controller = {
addToPlaylist,
createFavorite,
createPlaylist,
deleteFavorite,
@ -287,6 +306,7 @@ export const controller = {
getSongList,
getTopSongList,
getUserList,
removeFromPlaylist,
updatePlaylist,
updateRating,
};

View file

@ -1,6 +1,8 @@
import ky from 'ky';
import { nanoid } from 'nanoid/non-secure';
import type {
JFAddToPlaylist,
JFAddToPlaylistParams,
JFAlbum,
JFAlbumArtist,
JFAlbumArtistDetail,
@ -27,6 +29,8 @@ import type {
JFPlaylistDetailResponse,
JFPlaylistList,
JFPlaylistListResponse,
JFRemoveFromPlaylist,
JFRemoveFromPlaylistParams,
JFSong,
JFSongList,
JFSongListParams,
@ -64,6 +68,8 @@ import {
UpdatePlaylistArgs,
UpdatePlaylistResponse,
LibraryItem,
RemoveFromPlaylistArgs,
AddToPlaylistArgs,
} from '/@/renderer/api/types';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem, ServerType } from '/@/renderer/types';
@ -362,6 +368,45 @@ const getSongList = async (args: SongListArgs): Promise<JFSongList> => {
};
};
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<JFAddToPlaylist> => {
const { query, body, server, signal } = args;
const searchParams: JFAddToPlaylistParams = {
ids: body.songId,
userId: server?.userId || '',
};
await api
.post(`playlists/${query.id}/items`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<JFPlaylistDetailResponse>();
return null;
};
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs): Promise<JFRemoveFromPlaylist> => {
const { query, server, signal } = args;
const searchParams: JFRemoveFromPlaylistParams = {
entryIds: query.songId,
};
await api
.delete(`playlists/${query.id}/items`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<JFPlaylistDetailResponse>();
return null;
};
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<JFPlaylistDetail> => {
const { query, server, signal } = args;
@ -677,6 +722,7 @@ const normalizeSong = (
name: item.Name,
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
playCount: (item.UserData && item.UserData.PlayCount) || 0,
playlistItemId: item.PlaylistItemId,
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
releaseDate: null,
releaseYear: item.ProductionYear ? String(item.ProductionYear) : null,
@ -863,6 +909,7 @@ const normalizePlaylist = (
// };
export const jellyfinApi = {
addToPlaylist,
authenticate,
createFavorite,
createPlaylist,
@ -879,6 +926,7 @@ export const jellyfinApi = {
getPlaylistList,
getPlaylistSongList,
getSongList,
removeFromPlaylist,
updatePlaylist,
};

View file

@ -59,6 +59,25 @@ export type JFSongList = {
totalRecordCount: number;
};
export type JFAddToPlaylistResponse = {
added: number;
};
export type JFAddToPlaylistParams = {
ids: string[];
userId: string;
};
export type JFAddToPlaylist = null;
export type JFRemoveFromPlaylistResponse = null;
export type JFRemoveFromPlaylistParams = {
entryIds: string[];
};
export type JFRemoveFromPlaylist = null;
export interface JFPlaylistListResponse extends JFBasePaginatedResponse {
Items: JFPlaylist[];
}
@ -252,6 +271,7 @@ export type JFSong = {
MediaType: string;
Name: string;
ParentIndexNumber: number;
PlaylistItemId?: string;
PremiereDate?: string;
ProductionYear: number;
RunTimeTicks: number;

View file

@ -41,6 +41,12 @@ import type {
NDUserListResponse,
NDUserListParams,
NDUser,
NDAddToPlaylist,
NDAddToPlaylistBody,
NDAddToPlaylistResponse,
NDRemoveFromPlaylistParams,
NDRemoveFromPlaylistResponse,
NDRemoveFromPlaylist,
} from '/@/renderer/api/navidrome.types';
import { NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types';
import {
@ -73,6 +79,8 @@ import {
sortOrderMap,
User,
LibraryItem,
AddToPlaylistArgs,
RemoveFromPlaylistArgs,
} from '/@/renderer/api/types';
import { toast } from '/@/renderer/components/toast';
import { useAuthStore } from '/@/renderer/store';
@ -472,6 +480,44 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<NDPlayli
};
};
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<NDAddToPlaylist> => {
const { query, body, server, signal } = args;
const json: NDAddToPlaylistBody = {
ids: body.songId,
};
await api
.post(`api/playlist/${query.id}/tracks`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
json,
prefixUrl: server?.url,
signal,
})
.json<NDAddToPlaylistResponse>();
return null;
};
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs): Promise<NDRemoveFromPlaylist> => {
const { query, server, signal } = args;
const searchParams: NDRemoveFromPlaylistParams = {
id: query.songId,
};
await api
.delete(`api/playlist/${query.id}/tracks`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<NDRemoveFromPlaylistResponse>();
return null;
};
const getCoverArtUrl = (args: {
baseUrl: string;
coverArtId: string;
@ -501,10 +547,12 @@ const normalizeSong = (
imageSize?: number,
): Song => {
let id;
let playlistItemId;
// Dynamically determine the id field based on whether or not the item is a playlist song
if ('mediaFileId' in item) {
id = item.mediaFileId;
playlistItemId = item.id;
} else {
id = item.id;
}
@ -542,6 +590,7 @@ const normalizeSong = (
name: item.title,
path: item.path,
playCount: item.playCount,
playlistItemId,
releaseDate: new Date(item.year, 0, 1).toISOString(),
releaseYear: String(item.year),
serverId: server.id,
@ -675,6 +724,7 @@ const normalizeUser = (item: NDUser): User => {
};
export const navidromeApi = {
addToPlaylist,
authenticate,
createPlaylist,
deletePlaylist,
@ -689,6 +739,7 @@ export const navidromeApi = {
getSongDetail,
getSongList,
getUserList,
removeFromPlaylist,
updatePlaylist,
};

View file

@ -274,6 +274,26 @@ export type NDAlbumArtistListParams = {
} & NDPagination &
NDOrder;
export type NDAddToPlaylistResponse = {
added: number;
};
export type NDAddToPlaylistBody = {
ids: string[];
};
export type NDAddToPlaylist = null;
export type NDRemoveFromPlaylistResponse = {
ids: string[];
};
export type NDRemoveFromPlaylistParams = {
id: string[];
};
export type NDRemoveFromPlaylist = null;
export type NDCreatePlaylistParams = {
comment?: string;
name: string;

View file

@ -205,6 +205,7 @@ export type Song = {
name: string;
path: string | null;
playCount: number;
playlistItemId?: string;
releaseDate: string | null;
releaseYear: string | null;
serverId: string;
@ -777,6 +778,32 @@ export type RatingQuery = { id: string[]; rating: number };
export type RatingArgs = { query: RatingQuery } & BaseEndpointArgs;
// Add to playlist
export type RawAddToPlaylistResponse = null | undefined;
export type AddToPlaylistQuery = {
id: string;
};
export type AddToPlaylistBody = {
songId: string[];
};
export type AddToPlaylistArgs = {
body: AddToPlaylistBody;
query: AddToPlaylistQuery;
} & BaseEndpointArgs;
// Remove from playlist
export type RawRemoveFromPlaylistResponse = null | undefined;
export type RemoveFromPlaylistQuery = {
id: string;
songId: string[];
};
export type RemoveFromPlaylistArgs = { query: RemoveFromPlaylistQuery } & BaseEndpointArgs;
// Create Playlist
export type RawCreatePlaylistResponse = CreatePlaylistResponse | undefined;
@ -850,6 +877,7 @@ export type PlaylistListQuery = {
limit?: number;
ndParams?: {
owner_id?: string;
smart?: boolean;
};
searchTerm?: string;
sortBy: PlaylistListSort;