diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 7f0ca36e..bef36311 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -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; getSongDetail: (args: SongDetailArgs) => Promise; getSongList: (args: SongListArgs) => Promise; + getUserList: (args: UserListArgs) => Promise; updatePlaylist: (args: UpdatePlaylistArgs) => Promise; updateRating: (args: RatingArgs) => Promise; }>; @@ -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, }; diff --git a/src/renderer/api/jellyfin.api.ts b/src/renderer/api/jellyfin.api.ts index 5e92e7ca..d9e15959 100644 --- a/src/renderer/api/jellyfin.api.ts +++ b/src/renderer/api/jellyfin.api.ts @@ -433,26 +433,26 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise }; const createPlaylist = async (args: CreatePlaylistArgs): Promise => { - 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(); 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, }; }; diff --git a/src/renderer/api/navidrome.api.ts b/src/renderer/api/navidrome.api.ts index 9ee4bd0d..01cab6da 100644 --- a/src/renderer/api/navidrome.api.ts +++ b/src/renderer/api/navidrome.api.ts @@ -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 => { + 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(); + const itemCount = res.headers.get('x-total-count'); + + return { + items: data, + startIndex: query?.startIndex || 0, + totalRecordCount: Number(itemCount), + }; +}; + const getGenreList = async (args: GenreListArgs): Promise => { const { server, signal } = args; @@ -293,12 +326,14 @@ const getSongDetail = async (args: SongDetailArgs): Promise => { }; const createPlaylist = async (args: CreatePlaylistArgs): Promise => { - 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 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, }; diff --git a/src/renderer/api/navidrome.types.ts b/src/renderer/api/navidrome.types.ts index f7635534..33e5de18 100644 --- a/src/renderer/api/navidrome.types.ts +++ b/src/renderer/api/navidrome.types.ts @@ -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', +} diff --git a/src/renderer/api/normalize.ts b/src/renderer/api/normalize.ts index e5343fa6..0e48b40b 100644 --- a/src/renderer/api/normalize.ts +++ b/src/renderer/api/normalize.ts @@ -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, }; diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index a59e9135..2aaed496 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -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, + }, }; diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 190aec59..940f1c77 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -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 | 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; + ndParams?: { + owner?: string; + ownerId?: string; + public?: boolean; + rules?: Record; + 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; + ndParams?: { + owner?: string; + ownerId?: string; + public?: boolean; + rules?: Record; + 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; + +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; + navidrome: Record; + subsonic: Record; +}; + +export const userListSortMap: UserListSortMap = { + jellyfin: { + name: undefined, + }, + navidrome: { + name: NDUserListSort.NAME, + }, + subsonic: { + name: undefined, + }, +}; diff --git a/src/renderer/features/playlists/components/create-playlist-form.tsx b/src/renderer/features/playlists/components/create-playlist-form.tsx index 58bf4012..404a3996 100644 --- a/src/renderer/features/playlists/components/create-playlist-form.tsx +++ b/src/renderer/features/playlists/components/create-playlist-form.tsx @@ -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({ + const form = useForm({ 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 && ( )} diff --git a/src/renderer/features/playlists/components/playlist-detail-header.tsx b/src/renderer/features/playlists/components/playlist-detail-header.tsx index 8a3df147..50c4bc26 100644 --- a/src/renderer/features/playlists/components/playlist-detail-header.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-header.tsx @@ -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, ) => { 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: ( ), diff --git a/src/renderer/features/playlists/components/update-playlist-form.tsx b/src/renderer/features/playlists/components/update-playlist-form.tsx index 58102c5c..61ef7c05 100644 --- a/src/renderer/features/playlists/components/update-playlist-form.tsx +++ b/src/renderer/features/playlists/components/update-playlist-form.tsx @@ -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; 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({ 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')} /> +