mirror of
https://github.com/antebudimir/feishin.git
synced 2026-01-01 02:13:33 +00:00
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:
parent
be39c2bc1f
commit
59f4f43e84
23 changed files with 1120 additions and 982 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue