Add owner to playlist update query

- Support smart playlist rules
- Add user list query
This commit is contained in:
jeffvli 2023-01-04 18:33:49 -08:00
parent 75ef43dffb
commit d63e5f5784
13 changed files with 309 additions and 59 deletions

View File

@ -35,6 +35,8 @@ import type {
RawArtistListResponse,
UpdatePlaylistArgs,
RawUpdatePlaylistResponse,
UserListArgs,
RawUserListResponse,
} from '/@/renderer/api/types';
import { subsonicApi } from '/@/renderer/api/subsonic.api';
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
@ -62,6 +64,7 @@ export type ControllerEndpoint = Partial<{
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<RawSongListResponse>;
getSongDetail: (args: SongDetailArgs) => Promise<RawSongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<RawSongListResponse>;
getUserList: (args: UserListArgs) => Promise<RawUserListResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<RawUpdatePlaylistResponse>;
updateRating: (args: RatingArgs) => Promise<RawRatingResponse>;
}>;
@ -96,6 +99,7 @@ const endpoints: ApiController = {
getPlaylistSongList: jellyfinApi.getPlaylistSongList,
getSongDetail: undefined,
getSongList: jellyfinApi.getSongList,
getUserList: undefined,
updatePlaylist: jellyfinApi.updatePlaylist,
updateRating: undefined,
},
@ -122,6 +126,7 @@ const endpoints: ApiController = {
getPlaylistSongList: navidromeApi.getPlaylistSongList,
getSongDetail: navidromeApi.getSongDetail,
getSongList: navidromeApi.getSongList,
getUserList: navidromeApi.getUserList,
updatePlaylist: navidromeApi.updatePlaylist,
updateRating: subsonicApi.updateRating,
},
@ -147,6 +152,7 @@ const endpoints: ApiController = {
getPlaylistList: undefined,
getSongDetail: undefined,
getSongList: undefined,
getUserList: undefined,
updatePlaylist: undefined,
updateRating: undefined,
},
@ -227,6 +233,10 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs) => {
);
};
const getUserList = async (args: UserListArgs) => {
return (apiController('getUserList') as ControllerEndpoint['getUserList'])?.(args);
};
export const controller = {
createPlaylist,
deletePlaylist,
@ -240,5 +250,6 @@ export const controller = {
getPlaylistList,
getPlaylistSongList,
getSongList,
getUserList,
updatePlaylist,
};

View File

@ -433,26 +433,26 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<JFPlaylistList>
};
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
const { query, server } = args;
const { body, server } = args;
const body = {
const json = {
MediaType: 'Audio',
Name: query.name,
Overview: query.comment || '',
Name: body.name,
Overview: body.comment || '',
UserId: server?.userId,
};
const data = await api
.post('playlists', {
headers: { 'X-MediaBrowser-Token': server?.credential },
json: body,
json,
prefixUrl: server?.url,
})
.json<JFCreatePlaylistResponse>();
return {
id: data.Id,
name: query.name,
name: body.name,
};
};
@ -760,12 +760,13 @@ const normalizePlaylist = (
imagePlaceholderUrl,
imageUrl: imageUrl || null,
name: item.Name,
owner: null,
ownerId: null,
public: null,
rules: null,
size: null,
songCount: item?.ChildCount || null,
userId: null,
username: null,
sync: null,
};
};

View File

@ -37,9 +37,13 @@ import type {
NDPlaylistSongListResponse,
NDPlaylistSongList,
NDPlaylistSong,
NDUserList,
NDUserListResponse,
NDUserListParams,
NDUser,
} from '/@/renderer/api/navidrome.types';
import { NDPlaylistListSort, NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types';
import type {
import { NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types';
import {
Album,
Song,
AuthenticationResponse,
@ -60,13 +64,14 @@ import type {
Playlist,
UpdatePlaylistResponse,
UpdatePlaylistArgs,
} from '/@/renderer/api/types';
import {
UserListArgs,
userListSortMap,
playlistListSortMap,
albumArtistListSortMap,
songListSortMap,
albumListSortMap,
sortOrderMap,
User,
} from '/@/renderer/api/types';
import { toast } from '/@/renderer/components/toast';
import { useAuthStore } from '/@/renderer/store';
@ -132,6 +137,34 @@ const authenticate = async (
};
};
const getUserList = async (args: UserListArgs): Promise<NDUserList> => {
const { query, server, signal } = args;
const searchParams: NDUserListParams = {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: userListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
...query.ndParams,
};
const res = await api.get('api/user', {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
});
const data = await res.json<NDUserListResponse>();
const itemCount = res.headers.get('x-total-count');
return {
items: data,
startIndex: query?.startIndex || 0,
totalRecordCount: Number(itemCount),
};
};
const getGenreList = async (args: GenreListArgs): Promise<NDGenreList> => {
const { server, signal } = args;
@ -293,12 +326,14 @@ const getSongDetail = async (args: SongDetailArgs): Promise<NDSongDetail> => {
};
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
const { query, server } = args;
const { body, server } = args;
const json: NDCreatePlaylistParams = {
comment: query.comment,
name: query.name,
public: query.public || false,
comment: body.comment,
name: body.name,
...body.ndParams,
public: body.ndParams?.public || false,
rules: body.ndParams?.rules ? body.ndParams.rules : undefined,
};
const data = await api
@ -311,7 +346,7 @@ const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistR
return {
id: data.id,
name: query.name,
name: body.name,
};
};
@ -321,7 +356,11 @@ const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistR
const json: NDUpdatePlaylistParams = {
comment: body.comment || '',
name: body.name,
public: body.public || false,
ownerId: body.ndParams?.ownerId || undefined,
ownerName: body.ndParams?.owner || undefined,
public: body.ndParams?.public || false,
rules: body.ndParams?.rules ? body.ndParams?.rules : undefined,
sync: body.ndParams?.sync || undefined,
};
const data = await api
@ -357,8 +396,8 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<NDPlaylistList>
const searchParams: NDPlaylistListParams = {
_end: query.startIndex + (query.limit || 0),
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : NDSortOrder.ASC,
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : NDPlaylistListSort.NAME,
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : undefined,
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
_start: query.startIndex,
...query.ndParams,
};
@ -583,12 +622,25 @@ const normalizePlaylist = (
imagePlaceholderUrl,
imageUrl,
name: item.name,
owner: item.ownerName,
ownerId: item.ownerId,
public: item.public,
rules: item?.rules || null,
size: item.size,
songCount: item.songCount,
userId: item.ownerId,
username: item.ownerName,
sync: item.sync,
};
};
const normalizeUser = (item: NDUser): User => {
return {
createdAt: item.createdAt,
email: item.email,
id: item.id,
isAdmin: item.isAdmin,
lastLoginAt: item.lastLoginAt,
name: item.userName,
updatedAt: item.updatedAt,
};
};
@ -606,6 +658,7 @@ export const navidromeApi = {
getPlaylistSongList,
getSongDetail,
getSongList,
getUserList,
updatePlaylist,
};
@ -614,4 +667,5 @@ export const ndNormalize = {
albumArtist: normalizeAlbumArtist,
playlist: normalizePlaylist,
song: normalizeSong,
user: normalizeUser,
};

View File

@ -8,6 +8,18 @@ export type NDAuthenticate = {
username: string;
};
export type NDUser = {
createdAt: string;
email: string;
id: string;
isAdmin: boolean;
lastAccessAt: string;
lastLoginAt: string;
name: string;
updatedAt: string;
userName: string;
};
export type NDGenre = {
id: string;
name: string;
@ -376,3 +388,20 @@ export const NDSongQueryFields = [
{ label: 'Play count', value: 'playcount' },
{ label: 'Rating', value: 'rating' },
];
export type NDUserListParams = {
_sort?: NDUserListSort;
} & NDPagination &
NDOrder;
export type NDUserListResponse = NDUser[];
export type NDUserList = {
items: NDUser[];
startIndex: number;
totalRecordCount: number;
};
export enum NDUserListSort {
NAME = 'name',
}

View File

@ -14,6 +14,7 @@ import type {
NDGenreList,
NDPlaylist,
NDSong,
NDUser,
} from '/@/renderer/api/navidrome.types';
import { SSGenreList, SSMusicFolderList } from '/@/renderer/api/subsonic.types';
import type {
@ -26,6 +27,7 @@ import type {
RawPlaylistDetailResponse,
RawPlaylistListResponse,
RawSongListResponse,
RawUserListResponse,
} from '/@/renderer/api/types';
import { ServerListItem } from '/@/renderer/types';
@ -211,6 +213,25 @@ const playlistDetail = (
return playlist;
};
const userList = (data: RawUserListResponse | undefined, server: ServerListItem | null) => {
let users;
switch (server?.type) {
case 'jellyfin':
break;
case 'navidrome':
users = data?.items.map((item) => ndNormalize.user(item as NDUser));
break;
case 'subsonic':
break;
}
return {
items: users,
startIndex: data?.startIndex,
totalRecordCount: data?.totalRecordCount,
};
};
export const normalize = {
albumArtistList,
albumDetail,
@ -220,4 +241,5 @@ export const normalize = {
playlistDetail,
playlistList,
songList,
userList,
};

View File

@ -7,6 +7,7 @@ import type {
PlaylistListQuery,
PlaylistDetailQuery,
PlaylistSongListQuery,
UserListQuery,
} from './types';
export const queryKeys = {
@ -79,4 +80,11 @@ export const queryKeys = {
},
root: (serverId: string) => [serverId, 'songs'] as const,
},
users: {
list: (serverId: string, query?: UserListQuery) => {
if (query) return [serverId, 'users', 'list', query] as const;
return [serverId, 'users', 'list'] as const;
},
root: (serverId: string) => [serverId, 'users'] as const,
},
};

View File

@ -33,6 +33,8 @@ import {
NDPlaylistListSort,
NDPlaylistDetail,
NDSongListSort,
NDUserList,
NDUserListSort,
} from '/@/renderer/api/navidrome.types';
import {
SSAlbumList,
@ -48,6 +50,16 @@ export enum SortOrder {
DESC = 'DESC',
}
export type User = {
createdAt: string | null;
email: string | null;
id: string;
isAdmin: boolean | null;
lastLoginAt: string | null;
name: string;
updatedAt: string | null;
};
export type ServerListItem = {
credential: string;
id: string;
@ -242,12 +254,13 @@ export type Playlist = {
imagePlaceholderUrl: string | null;
imageUrl: string | null;
name: string;
owner: string | null;
ownerId: string | null;
public: boolean | null;
rules?: Record<string, any> | null;
size: number | null;
songCount: number | null;
userId: string | null;
username: string | null;
sync?: boolean | null;
};
export type GenresResponse = Genre[];
@ -739,14 +752,19 @@ export type RawCreatePlaylistResponse = CreatePlaylistResponse | undefined;
export type CreatePlaylistResponse = { id: string; name: string };
export type CreatePlaylistQuery = {
export type CreatePlaylistBody = {
comment?: string;
name: string;
public?: boolean;
rules?: Record<string, any>;
ndParams?: {
owner?: string;
ownerId?: string;
public?: boolean;
rules?: Record<string, any>;
sync?: boolean;
};
};
export type CreatePlaylistArgs = { query: CreatePlaylistQuery } & BaseEndpointArgs;
export type CreatePlaylistArgs = { body: CreatePlaylistBody } & BaseEndpointArgs;
// Update Playlist
export type RawUpdatePlaylistResponse = UpdatePlaylistResponse | undefined;
@ -761,8 +779,13 @@ export type UpdatePlaylistBody = {
comment?: string;
genres?: Genre[];
name: string;
public?: boolean;
rules?: Record<string, any>;
ndParams?: {
owner?: string;
ownerId?: string;
public?: boolean;
rules?: Record<string, any>;
sync?: boolean;
};
};
export type UpdatePlaylistArgs = {
@ -880,3 +903,44 @@ export type CreateFavoriteResponse = { id: string };
export type CreateFavoriteQuery = { comment?: string; name: string; public?: boolean };
export type CreateFavoriteArgs = { query: CreateFavoriteQuery } & BaseEndpointArgs;
// User list
// Playlist List
export type RawUserListResponse = NDUserList | undefined;
export type UserListResponse = BasePaginatedResponse<User[]>;
export enum UserListSort {
NAME = 'name',
}
export type UserListQuery = {
limit?: number;
ndParams?: {
owner_id?: string;
};
searchTerm?: string;
sortBy: UserListSort;
sortOrder: SortOrder;
startIndex: number;
};
export type UserListArgs = { query: UserListQuery } & BaseEndpointArgs;
type UserListSortMap = {
jellyfin: Record<UserListSort, undefined>;
navidrome: Record<UserListSort, NDUserListSort | undefined>;
subsonic: Record<UserListSort, undefined>;
};
export const userListSortMap: UserListSortMap = {
jellyfin: {
name: undefined,
},
navidrome: {
name: NDUserListSort.NAME,
},
subsonic: {
name: undefined,
},
};

View File

@ -1,6 +1,6 @@
import { Group, Stack } from '@mantine/core';
import { useForm } from '@mantine/form';
import { CreatePlaylistQuery, ServerType } from '/@/renderer/api/types';
import { CreatePlaylistBody, ServerType } from '/@/renderer/api/types';
import { Button, Switch, TextInput, toast } from '/@/renderer/components';
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
import { useCurrentServer } from '/@/renderer/store';
@ -13,18 +13,20 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
const mutation = useCreatePlaylist();
const server = useCurrentServer();
const form = useForm<CreatePlaylistQuery>({
const form = useForm<CreatePlaylistBody>({
initialValues: {
comment: '',
name: '',
public: false,
rules: undefined,
ndParams: {
public: false,
rules: undefined,
},
},
});
const handleSubmit = form.onSubmit((values) => {
mutation.mutate(
{ query: values },
{ body: values },
{
onError: (err) => {
toast.error({ message: err.message, title: 'Error creating playlist' });
@ -56,7 +58,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
{isPublicDisplayed && (
<Switch
label="Is Public?"
{...form.getInputProps('public')}
{...form.getInputProps('ndParams.public')}
/>
)}
<Group position="right">

View File

@ -1,6 +1,7 @@
import { forwardRef, Fragment, Ref } from 'react';
import { Group, Stack } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals';
import { forwardRef, Fragment, Ref } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { RiMoreFill } from 'react-icons/ri';
import { generatePath, useNavigate, useParams } from 'react-router';
import { Link } from 'react-router-dom';
@ -14,6 +15,10 @@ import { AppRoute } from '/@/renderer/router/routes';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { LibraryItem, Play } from '/@/renderer/types';
import { formatDurationString } from '/@/renderer/utils';
import { UserListSort, SortOrder, UserListQuery } from '/@/renderer/api/types';
import { useCurrentServer } from '/@/renderer/store';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
interface PlaylistDetailHeaderProps {
background: string;
@ -27,10 +32,12 @@ export const PlaylistDetailHeader = forwardRef(
ref: Ref<HTMLDivElement>,
) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { playlistId } = useParams() as { playlistId: string };
const detailQuery = usePlaylistDetail({ id: playlistId });
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const server = useCurrentServer();
const handlePlay = (playType?: Play) => {
handlePlayQueueAdd?.({
@ -42,7 +49,20 @@ export const PlaylistDetailHeader = forwardRef(
});
};
const openUpdatePlaylistModal = () => {
const openUpdatePlaylistModal = async () => {
const query: UserListQuery = {
sortBy: UserListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
};
const users = await queryClient.fetchQuery({
queryFn: ({ signal }) => api.controller.getUserList({ query, server, signal }),
queryKey: queryKeys.users.list(server?.id || '', query),
});
const normalizedUsers = api.normalize.userList(users, server);
openModal({
children: (
<UpdatePlaylistForm
@ -50,10 +70,16 @@ export const PlaylistDetailHeader = forwardRef(
comment: detailQuery?.data?.description || undefined,
genres: detailQuery?.data?.genres,
name: detailQuery?.data?.name,
public: detailQuery?.data?.public || false,
rules: detailQuery?.data?.rules || undefined,
ndParams: {
owner: detailQuery?.data?.owner || undefined,
ownerId: detailQuery?.data?.ownerId || undefined,
public: detailQuery?.data?.public || false,
rules: detailQuery?.data?.rules || undefined,
sync: detailQuery?.data?.sync || undefined,
},
}}
query={{ id: playlistId }}
users={normalizedUsers.items}
onCancel={closeAllModals}
/>
),

View File

@ -1,27 +1,37 @@
import { Group, Stack } from '@mantine/core';
import { useForm } from '@mantine/form';
import { ServerType, UpdatePlaylistBody, UpdatePlaylistQuery } from '/@/renderer/api/types';
import { Button, Switch, TextInput, toast } from '/@/renderer/components';
import { ServerType, UpdatePlaylistBody, UpdatePlaylistQuery, User } from '/@/renderer/api/types';
import { Button, Select, Switch, TextInput, toast } from '/@/renderer/components';
import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
import { useCurrentServer } from '/@/renderer/store';
interface CreatePlaylistFormProps {
interface UpdatePlaylistFormProps {
body: Partial<UpdatePlaylistBody>;
onCancel: () => void;
query: UpdatePlaylistQuery;
users?: User[];
}
export const UpdatePlaylistForm = ({ query, body, onCancel }: CreatePlaylistFormProps) => {
export const UpdatePlaylistForm = ({ users, query, body, onCancel }: UpdatePlaylistFormProps) => {
const mutation = useUpdatePlaylist();
const server = useCurrentServer();
const userList = users?.map((user) => ({
label: user.name,
value: user.id,
}));
const form = useForm<UpdatePlaylistBody>({
initialValues: {
comment: '',
name: '',
public: false,
rules: undefined,
...body,
comment: body?.comment || '',
name: body?.name || '',
ndParams: {
owner: body?.ndParams?.owner || '',
ownerId: body?.ndParams?.ownerId || '',
public: body?.ndParams?.public || false,
rules: undefined,
sync: body?.ndParams?.sync || false,
},
},
});
@ -56,6 +66,11 @@ export const UpdatePlaylistForm = ({ query, body, onCancel }: CreatePlaylistForm
label="Description"
{...form.getInputProps('comment')}
/>
<Select
data={userList || []}
{...form.getInputProps('ndParams.ownerId')}
label="Owner"
/>
{isPublicDisplayed && (
<Switch
label="Is Public?"

View File

@ -0,0 +1 @@
export * from './queries/user-list-query';

View File

@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { queryKeys } from '/@/renderer/api/query-keys';
import type { RawUserListResponse, UserListQuery } from '/@/renderer/api/types';
import { useCurrentServer } from '/@/renderer/store';
import { api } from '/@/renderer/api';
import type { QueryOptions } from '/@/renderer/lib/react-query';
export const useUserList = (query: UserListQuery, options?: QueryOptions) => {
const server = useCurrentServer();
return useQuery({
enabled: !!server?.id,
queryFn: ({ signal }) => api.controller.getUserList({ query, server, signal }),
queryKey: queryKeys.users.list(server?.id || '', query),
select: useCallback(
(data: RawUserListResponse | undefined) => api.normalize.userList(data, server),
[server],
),
...options,
});
};

View File

@ -111,22 +111,17 @@ export interface UniqueId {
uniqueId: string;
}
export enum FilterGroupType {
AND = 'AND',
OR = 'OR',
}
export type AdvancedFilterRule = {
export type QueryBuilderRule = {
field?: string | null;
operator?: string | null;
uniqueId: string;
value?: string | number | Date | undefined | null | any;
};
export type AdvancedFilterGroup = {
group: AdvancedFilterGroup[];
rules: AdvancedFilterRule[];
type: FilterGroupType;
export type QueryBuilderGroup = {
group: QueryBuilderGroup[];
rules: QueryBuilderRule[];
type: 'any' | 'all';
uniqueId: string;
};