From c57c53972a883cbd51e1aac08f8d98dcd0a499ec Mon Sep 17 00:00:00 2001 From: jeffvli Date: Mon, 12 Dec 2022 20:44:23 -0800 Subject: [PATCH] Add additional controller endpoints --- packages/renderer/src/api/controller.ts | 44 +- packages/renderer/src/api/jellyfin.api.ts | 675 +++++++++++++++++++ packages/renderer/src/api/jellyfin.types.ts | 169 ++++- packages/renderer/src/api/navidrome.api.ts | 158 ++--- packages/renderer/src/api/navidrome.types.ts | 12 +- packages/renderer/src/api/query-keys.ts | 27 +- packages/renderer/src/api/subsonic.api.ts | 18 +- packages/renderer/src/api/types.ts | 502 ++++++++++++-- 8 files changed, 1411 insertions(+), 194 deletions(-) create mode 100644 packages/renderer/src/api/jellyfin.api.ts diff --git a/packages/renderer/src/api/controller.ts b/packages/renderer/src/api/controller.ts index e367e9259..6daff26f5 100644 --- a/packages/renderer/src/api/controller.ts +++ b/packages/renderer/src/api/controller.ts @@ -28,8 +28,14 @@ import type { RawPlaylistDetailResponse, PlaylistListArgs, RawPlaylistListResponse, + MusicFolderListArgs, + RawMusicFolderListResponse, + PlaylistSongListArgs, + ArtistListArgs, + RawArtistListResponse, } from '/@/api/types'; import { subsonicApi } from '/@/api/subsonic.api'; +import { jellyfinApi } from '/@/api/jellyfin.api'; export type ControllerEndpoint = Partial<{ clearPlaylist: () => void; @@ -42,15 +48,16 @@ export type ControllerEndpoint = Partial<{ getAlbumDetail: (args: AlbumDetailArgs) => Promise; getAlbumList: (args: AlbumListArgs) => Promise; getArtistDetail: () => void; - getArtistList: () => void; + getArtistList: (args: ArtistListArgs) => Promise; getFavoritesList: () => void; getFolderItemList: () => void; getFolderList: () => void; getFolderSongs: () => void; getGenreList: (args: GenreListArgs) => Promise; - getMusicFolderList: () => void; + getMusicFolderList: (args: MusicFolderListArgs) => Promise; getPlaylistDetail: (args: PlaylistDetailArgs) => Promise; getPlaylistList: (args: PlaylistListArgs) => Promise; + getPlaylistSongList: (args: PlaylistSongListArgs) => Promise; getSongDetail: (args: SongDetailArgs) => Promise; getSongList: (args: SongListArgs) => Promise; updatePlaylist: () => void; @@ -66,25 +73,27 @@ type ApiController = { const endpoints: ApiController = { jellyfin: { clearPlaylist: undefined, - createFavorite: undefined, - createPlaylist: undefined, - deleteFavorite: undefined, - deletePlaylist: undefined, - getAlbumArtistDetail: undefined, - getAlbumArtistList: undefined, - getAlbumDetail: undefined, - getAlbumList: undefined, + createFavorite: jellyfinApi.createFavorite, + createPlaylist: jellyfinApi.createPlaylist, + deleteFavorite: jellyfinApi.deleteFavorite, + deletePlaylist: jellyfinApi.deletePlaylist, + getAlbumArtistDetail: jellyfinApi.getAlbumArtistDetail, + getAlbumArtistList: jellyfinApi.getAlbumArtistList, + getAlbumDetail: jellyfinApi.getAlbumDetail, + getAlbumList: jellyfinApi.getAlbumList, getArtistDetail: undefined, - getArtistList: undefined, + getArtistList: jellyfinApi.getArtistList, getFavoritesList: undefined, getFolderItemList: undefined, getFolderList: undefined, getFolderSongs: undefined, - getGenreList: undefined, - getMusicFolderList: undefined, - getPlaylistDetail: undefined, - getPlaylistList: undefined, - getSongList: undefined, + getGenreList: jellyfinApi.getGenreList, + getMusicFolderList: jellyfinApi.getMusicFolderList, + getPlaylistDetail: jellyfinApi.getPlaylistDetail, + getPlaylistList: jellyfinApi.getPlaylistList, + getPlaylistSongList: jellyfinApi.getPlaylistSongList, + getSongDetail: undefined, + getSongList: jellyfinApi.getSongList, updatePlaylist: undefined, updateRating: undefined, }, @@ -108,6 +117,8 @@ const endpoints: ApiController = { getMusicFolderList: undefined, getPlaylistDetail: navidromeApi.getPlaylistDetail, getPlaylistList: navidromeApi.getPlaylistList, + getPlaylistSongList: navidromeApi.getPlaylistSongList, + getSongDetail: navidromeApi.getSongDetail, getSongList: navidromeApi.getSongList, updatePlaylist: undefined, updateRating: subsonicApi.updateRating, @@ -132,6 +143,7 @@ const endpoints: ApiController = { getMusicFolderList: undefined, getPlaylistDetail: undefined, getPlaylistList: undefined, + getSongDetail: undefined, getSongList: undefined, updatePlaylist: undefined, updateRating: undefined, diff --git a/packages/renderer/src/api/jellyfin.api.ts b/packages/renderer/src/api/jellyfin.api.ts new file mode 100644 index 000000000..2721939e7 --- /dev/null +++ b/packages/renderer/src/api/jellyfin.api.ts @@ -0,0 +1,675 @@ +import ky from 'ky'; +import _ from 'lodash'; +import { nanoid } from 'nanoid/non-secure'; +import type { + JFAlbum, + JFAlbumArtistDetail, + JFAlbumArtistDetailResponse, + JFAlbumArtistList, + JFAlbumArtistListParams, + JFAlbumArtistListResponse, + JFAlbumDetail, + JFAlbumDetailResponse, + JFAlbumList, + JFAlbumListParams, + JFAlbumListResponse, + JFArtistList, + JFArtistListParams, + JFArtistListResponse, + JFAuthenticate, + JFCreatePlaylistResponse, + JFGenreList, + JFGenreListResponse, + JFMusicFolderList, + JFMusicFolderListResponse, + JFPlaylistDetail, + JFPlaylistDetailResponse, + JFPlaylistList, + JFPlaylistListResponse, + JFSong, + JFSongList, + JFSongListParams, + JFSongListResponse, +} from '/@/api/jellyfin.types'; +import { JFCollectionType } from '/@/api/jellyfin.types'; +import type { + Album, + AlbumArtistDetailArgs, + AlbumArtistListArgs, + AlbumDetailArgs, + AlbumListArgs, + ArtistListArgs, + AuthenticationResponse, + CreatePlaylistArgs, + CreatePlaylistResponse, + DeletePlaylistArgs, + FavoriteArgs, + FavoriteResponse, + GenreListArgs, + MusicFolderListArgs, + PlaylistDetailArgs, + PlaylistListArgs, + PlaylistSongListArgs, + Song, + SongListArgs, +} from '/@/api/types'; +import { songListSortMap } from '/@/api/types'; +import { albumListSortMap } from '/@/api/types'; +import { artistListSortMap } from '/@/api/types'; +import { sortOrderMap } from '/@/api/types'; +import { albumArtistListSortMap } from '/@/api/types'; +import type { ServerListItem } from '/@/store'; +import { useAuthStore } from '/@/store'; +import { ServerType } from '/@/types'; +import { parseSearchParams } from '/@/utils'; + +const api = ky.create({}); + +const authenticate = async ( + url: string, + body: { + password: string; + username: string; + }, +): Promise => { + const cleanServerUrl = url.replace(/\/$/, ''); + + const data = await ky + .post(`${cleanServerUrl}/users/authenticatebyname`, { + headers: { + 'X-Emby-Authorization': + 'MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="0.0.1-alpha1"', + }, + json: { + pw: body.password, + username: body.username, + }, + }) + .json(); + + return { + credential: data.AccessToken, + userId: data.User.Id, + username: data.User.Name, + }; +}; + +const getMusicFolderList = async (args: MusicFolderListArgs): Promise => { + const { signal } = args; + const userId = useAuthStore.getState().currentServer?.userId; + + const data = await api + .get(`users/${userId}/items`, { + signal, + }) + .json(); + + const musicFolders = data.Items.filter( + (folder) => folder.CollectionType === JFCollectionType.MUSIC, + ); + + return { + items: musicFolders, + startIndex: data.StartIndex, + totalRecordCount: data.TotalRecordCount, + }; +}; + +const getGenreList = async (args: GenreListArgs): Promise => { + const { signal, server } = args; + + const data = await api + .get('genres', { + headers: { 'X-MediaBrowser-Token': server?.credential }, + prefixUrl: server?.url, + signal, + }) + .json(); + return data; +}; + +const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise => { + const { query, server, signal } = args; + + const searchParams = { + fields: 'Genres', + }; + + const data = await api + .get(`/users/${server?.userId}/items/${query.id}`, { + headers: { 'X-MediaBrowser-Token': server?.credential }, + prefixUrl: server?.url, + searchParams: parseSearchParams(searchParams), + signal, + }) + .json(); + + return data; +}; + +// const getAlbumArtistAlbums = () => { +// const { data: albumData } = await api.get(`/users/${auth.username}/items`, { +// params: { +// artistIds: options.id, +// fields: 'AudioInfo, ParentId, Genres, DateCreated, ChildCount, ParentId', +// includeItemTypes: 'MusicAlbum', +// parentId: options.musicFolderId, +// recursive: true, +// sortBy: 'SortName', +// }, +// }); + +// const { data: similarData } = await api.get(`/artists/${options.id}/similar`, { +// params: { limit: 15, parentId: options.musicFolderId, userId: auth.username }, +// }); +// }; + +const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise => { + const { query, server, signal } = args; + + const searchParams: JFAlbumArtistListParams = { + limit: query.limit, + parentId: query.musicFolderId, + recursive: true, + sortBy: albumArtistListSortMap.jellyfin[query.sortBy], + sortOrder: sortOrderMap.jellyfin[query.sortOrder], + startIndex: query.startIndex, + }; + + const data = await api + .get('artists/albumArtists', { + headers: { 'X-MediaBrowser-Token': server?.credential }, + prefixUrl: server?.url, + searchParams: parseSearchParams(searchParams), + signal, + }) + .json(); + + return data; +}; + +const getArtistList = async (args: ArtistListArgs): Promise => { + const { query, server, signal } = args; + + const searchParams: JFArtistListParams = { + limit: query.limit, + parentId: query.musicFolderId, + recursive: true, + sortBy: artistListSortMap.jellyfin[query.sortBy], + sortOrder: sortOrderMap.jellyfin[query.sortOrder], + startIndex: query.startIndex, + }; + + const data = await api + .get('artists', { + headers: { 'X-MediaBrowser-Token': server?.credential }, + prefixUrl: server?.url, + searchParams: parseSearchParams(searchParams), + signal, + }) + .json(); + + return data; +}; + +const getAlbumDetail = async (args: AlbumDetailArgs): Promise => { + const { query, server, signal } = args; + + const searchParams = { + fields: 'Genres, DateCreated, ChildCount', + }; + + const data = await api + .get(`users/${server?.userId}/items/${query.id}`, { + headers: { 'X-MediaBrowser-Token': server?.credential }, + prefixUrl: server?.url, + searchParams, + signal, + }) + .json(); + + const songsSearchParams = { + fields: 'Genres, DateCreated, MediaSources, ParentId', + parentId: query.id, + sortBy: 'SortName', + }; + + const songsData = await api + .get(`users/${server?.userId}/items`, { + headers: { 'X-MediaBrowser-Token': server?.credential }, + prefixUrl: server?.url, + searchParams: songsSearchParams, + signal, + }) + .json(); + + return { ...data, songs: songsData.Items }; +}; + +const getAlbumList = async (args: AlbumListArgs): Promise => { + const { query, server, signal } = args; + + const searchParams: JFAlbumListParams = { + includeItemTypes: 'MusicAlbum', + limit: query.limit, + parentId: query.musicFolderId, + recursive: true, + sortBy: albumListSortMap.jellyfin[query.sortBy], + sortOrder: sortOrderMap.jellyfin[query.sortOrder], + startIndex: query.startIndex, + }; + + const data = await api + .get(`users/${server?.userId}/items`, { + headers: { 'X-MediaBrowser-Token': server?.credential }, + prefixUrl: server?.url, + searchParams: parseSearchParams(searchParams), + signal, + }) + .json(); + + return { + items: data.Items, + startIndex: query.startIndex, + totalRecordCount: data.TotalRecordCount, + }; +}; + +const getSongList = async (args: SongListArgs): Promise => { + const { query, server, signal } = args; + + const searchParams: JFSongListParams = { + fields: 'Genres, DateCreated, MediaSources, ParentId', + includeItemTypes: 'Audio', + limit: query.limit, + parentId: query.musicFolderId, + recursive: true, + sortBy: songListSortMap.jellyfin[query.sortBy], + sortOrder: sortOrderMap.jellyfin[query.sortOrder], + startIndex: query.startIndex, + ...query.jfParams, + }; + + const data = await api + .get(`users/${server?.userId}/items`, { + headers: { 'X-MediaBrowser-Token': server?.credential }, + prefixUrl: server?.url, + searchParams: parseSearchParams(searchParams), + signal, + }) + .json(); + + return data; +}; + +const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise => { + const { query, server, signal } = args; + + const searchParams = { + fields: 'Genres, DateCreated, MediaSources, ChildCount, ParentId', + ids: query.id, + }; + + const data = await api + .get(`users/${server?.userId}/items/${query.id}`, { + headers: { 'X-MediaBrowser-Token': server?.credential }, + prefixUrl: server?.url, + searchParams, + signal, + }) + .json(); + + return data; +}; + +const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise => { + const { query, server, signal } = args; + + const searchParams: JFSongListParams = { + fields: 'Genres, DateCreated, MediaSources, UserData, ParentId', + includeItemTypes: 'Audio', + sortOrder: query.sortOrder ? sortOrderMap.jellyfin[query.sortOrder] : undefined, + startIndex: 0, + }; + + const data = await api + .get(`playlists/${query.id}/items`, { + headers: { 'X-MediaBrowser-Token': server?.credential }, + prefixUrl: server?.url, + searchParams: parseSearchParams(searchParams), + signal, + }) + .json(); + + return data; +}; + +const getPlaylistList = async (args: PlaylistListArgs): Promise => { + const { server, signal } = args; + + const searchParams = { + fields: 'ChildCount, Genres, DateCreated, ParentId, Overview', + includeItemTypes: 'Playlist', + recursive: true, + sortBy: 'SortName', + sortOrder: 'Ascending', + }; + + const data = await api + .get(`/users/${server?.userId}/items`, { + headers: { 'X-MediaBrowser-Token': server?.credential }, + prefixUrl: server?.url, + searchParams: parseSearchParams(searchParams), + signal, + }) + .json(); + + const playlistData = data.Items.filter((item) => item.MediaType === 'Audio'); + + return { + Items: playlistData, + StartIndex: 0, + TotalRecordCount: playlistData.length, + }; +}; + +const createPlaylist = async (args: CreatePlaylistArgs): Promise => { + const { query, server } = args; + + const body = { + MediaType: 'Audio', + Name: query.name, + UserId: server?.userId, + }; + + const data = await api + .post('playlists', { + headers: { 'X-MediaBrowser-Token': server?.credential }, + json: body, + prefixUrl: server?.url, + }) + .json(); + + return { + id: data.Id, + name: query.name, + }; +}; + +const deletePlaylist = async (args: DeletePlaylistArgs): Promise => { + const { query, server } = args; + + await api.delete(`items/${query.id}`, { + headers: { 'X-MediaBrowser-Token': server?.credential }, + prefixUrl: server?.url, + }); + + return null; +}; + +const createFavorite = async (args: FavoriteArgs): Promise => { + const { query, server } = args; + + await api.post(`users/${server?.userId}/favoriteitems/${query.id}`, { + headers: { 'X-MediaBrowser-Token': server?.credential }, + prefixUrl: server?.url, + }); + + return { + id: query.id, + }; +}; + +const deleteFavorite = async (args: FavoriteArgs): Promise => { + const { query, server } = args; + + await api.delete(`users/${server?.userId}/favoriteitems/${query.id}`, { + headers: { 'X-MediaBrowser-Token': server?.credential }, + prefixUrl: server?.url, + }); + + return { + id: query.id, + }; +}; + +const getStreamUrl = (args: { + container?: string; + deviceId: string; + eTag?: string; + id: string; + mediaSourceId?: string; + server: ServerListItem; +}) => { + const { id, server, deviceId } = args; + + return ( + `${server?.url}/audio` + + `/${id}/universal` + + `?userId=${server.userId}` + + `&deviceId=${deviceId}` + + '&audioCodec=aac' + + `&api_key=${server.credential}` + + `&playSessionId=${deviceId}` + + '&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg' + + '&transcodingContainer=ts' + + '&transcodingProtocol=hls' + ); +}; + +const getAlbumCoverArtUrl = (args: { baseUrl: string; item: JFAlbum; size: number }) => { + const size = args.size ? args.size : 300; + + if (!args.item.ImageTags?.Primary && !args.item?.AlbumPrimaryImageTag) { + return null; + } + + return ( + `${args.baseUrl}/Items` + + `/${args.item.Id}` + + '/Images/Primary' + + `?width=${size}&height=${size}` + + '&quality=96' + ); +}; + +const getSongCoverArtUrl = (args: { baseUrl: string; item: JFSong; size: number }) => { + const size = args.size ? args.size : 300; + + if (!args.item.ImageTags?.Primary) { + return null; + } + + if (args.item.ImageTags.Primary) { + return ( + `${args.baseUrl}/Items` + + `/${args.item.Id}` + + '/Images/Primary' + + `?width=${size}&height=${size}` + + '&quality=96' + ); + } + + if (!args.item?.AlbumPrimaryImageTag) { + return null; + } + + // Fall back to album art if no image embedded + return ( + `${args.baseUrl}/Items` + + `/${args.item?.AlbumId}` + + '/Images/Primary' + + `?width=${size}&height=${size}` + + '&quality=96' + ); +}; + +const normalizeAlbum = (item: JFAlbum, server: ServerListItem, imageSize?: number): Album => { + return { + albumArtists: + item.AlbumArtists?.map((entry) => ({ + id: entry.Id, + name: entry.Name, + })) || [], + artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, name: entry.Name })), + backdropImageUrl: null, + createdAt: item.DateCreated, + duration: item.RunTimeTicks / 10000000, + genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })), + id: item.Id, + imagePlaceholderUrl: null, + imageUrl: getAlbumCoverArtUrl({ + baseUrl: server.url, + item, + size: imageSize || 300, + }), + isCompilation: null, + isFavorite: item.UserData?.IsFavorite || false, + name: item.Name, + playCount: item.UserData?.PlayCount || 0, + rating: null, + releaseDate: item.PremiereDate || null, + releaseYear: item.ProductionYear, + serverType: ServerType.JELLYFIN, + size: null, + songCount: item?.ChildCount || null, + uniqueId: nanoid(), + updatedAt: item?.DateLastMediaAdded || item.DateCreated, + }; +}; + +const normalizeSong = ( + item: JFSong, + server: ServerListItem, + deviceId: string, + imageSize?: number, +): Song => { + return { + album: item.Album, + albumArtists: item.AlbumArtists?.map((entry) => ({ id: entry.Id, name: entry.Name })), + albumId: item.AlbumId, + artistName: item.ArtistItems[0]?.Name, + artists: item.ArtistItems.map((entry) => ({ id: entry.Id, name: entry.Name })), + bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)), + compilation: null, + container: (item.MediaSources && item.MediaSources[0]?.Container) || null, + createdAt: item.DateCreated, + discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1, + duration: item.RunTimeTicks / 10000000, + genres: item.GenreItems.map((entry: any) => ({ id: entry.Id, name: entry.Name })), + id: item.Id, + imageUrl: getSongCoverArtUrl({ baseUrl: server.url, item, size: imageSize || 300 }), + isFavorite: (item.UserData && item.UserData.IsFavorite) || false, + name: item.Name, + path: (item.MediaSources && item.MediaSources[0]?.Path) || null, + playCount: (item.UserData && item.UserData.PlayCount) || 0, + releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null, + releaseYear: (item.ProductionYear && String(item.ProductionYear)) || null, + serverId: server.id, + size: item.MediaSources && item.MediaSources[0]?.Size, + streamUrl: getStreamUrl({ + container: item.MediaSources[0]?.Container, + deviceId, + eTag: item.MediaSources[0]?.ETag, + id: item.Id, + mediaSourceId: item.MediaSources[0]?.Id, + server, + }), + trackNumber: item.IndexNumber, + type: ServerType.JELLYFIN, + uniqueId: nanoid(), + updatedAt: item.DateCreated, + }; +}; + +// const normalizeArtist = (item: any) => { +// return { +// album: (item.album || []).map((entry: any) => normalizeAlbum(entry)), +// albumCount: item.AlbumCount, +// duration: item.RunTimeTicks / 10000000, +// genre: item.GenreItems && item.GenreItems.map((entry: any) => normalizeItem(entry)), +// id: item.Id, +// image: getCoverArtUrl(item), +// info: { +// biography: item.Overview, +// externalUrl: (item.ExternalUrls || []).map((entry: any) => normalizeItem(entry)), +// imageUrl: undefined, +// similarArtist: (item.similarArtist || []).map((entry: any) => normalizeArtist(entry)), +// }, +// starred: item.UserData && item.UserData?.IsFavorite ? 'true' : undefined, +// title: item.Name, +// uniqueId: nanoid(), +// }; +// }; + +// const normalizePlaylist = (item: any) => { +// return { +// changed: item.DateLastMediaAdded, +// comment: item.Overview, +// created: item.DateCreated, +// duration: item.RunTimeTicks / 10000000, +// genre: item.GenreItems && item.GenreItems.map((entry: any) => normalizeItem(entry)), +// id: item.Id, +// image: getCoverArtUrl(item, 350), +// owner: undefined, +// public: undefined, +// song: [], +// songCount: item.ChildCount, +// title: item.Name, +// uniqueId: nanoid(), +// }; +// }; + +// const normalizeGenre = (item: any) => { +// return { +// albumCount: undefined, +// id: item.Id, +// songCount: undefined, +// title: item.Name, +// type: Item.Genre, +// uniqueId: nanoid(), +// }; +// }; + +// const normalizeFolder = (item: any) => { +// return { +// created: item.DateCreated, +// id: item.Id, +// image: getCoverArtUrl(item, 150), +// isDir: true, +// title: item.Name, +// type: Item.Folder, +// uniqueId: nanoid(), +// }; +// }; + +// const normalizeScanStatus = () => { +// return { +// count: 'N/a', +// scanning: false, +// }; +// }; + +export const jellyfinApi = { + authenticate, + createFavorite, + createPlaylist, + deleteFavorite, + deletePlaylist, + getAlbumArtistDetail, + getAlbumArtistList, + getAlbumDetail, + getAlbumList, + getArtistList, + getGenreList, + getMusicFolderList, + getPlaylistDetail, + getPlaylistList, + getPlaylistSongList, + getSongList, +}; + +export const jfNormalize = { + album: normalizeAlbum, + song: normalizeSong, +}; diff --git a/packages/renderer/src/api/jellyfin.types.ts b/packages/renderer/src/api/jellyfin.types.ts index 5af58a9c8..1f0817509 100644 --- a/packages/renderer/src/api/jellyfin.types.ts +++ b/packages/renderer/src/api/jellyfin.types.ts @@ -1,32 +1,90 @@ -export type JFBaseResponse = { +export type JFBasePaginatedResponse = { StartIndex: number; TotalRecordCount: number; }; -export interface JFMusicFoldersResponse extends JFBaseResponse { +export interface JFMusicFolderListResponse extends JFBasePaginatedResponse { Items: JFMusicFolder[]; } -export interface JFGenreResponse extends JFBaseResponse { +export type JFMusicFolderList = { + items: JFMusicFolder[]; + startIndex: number; + totalRecordCount: number; +}; + +export interface JFGenreListResponse extends JFBasePaginatedResponse { Items: JFGenre[]; } -export interface JFAlbumArtistsResponse extends JFBaseResponse { +export type JFGenreList = JFGenreListResponse; + +export type JFAlbumArtistDetailResponse = JFAlbumArtist; + +export type JFAlbumArtistDetail = JFAlbumArtistDetailResponse; + +export interface JFAlbumArtistListResponse extends JFBasePaginatedResponse { Items: JFAlbumArtist[]; } -export interface JFArtistsResponse extends JFBaseResponse { +export type JFAlbumArtistList = JFAlbumArtistListResponse; + +export interface JFArtistListResponse extends JFBasePaginatedResponse { Items: JFAlbumArtist[]; } -export interface JFAlbumsResponse extends JFBaseResponse { +export type JFArtistList = JFArtistListResponse; + +export interface JFAlbumListResponse extends JFBasePaginatedResponse { Items: JFAlbum[]; } -export interface JFSongsResponse extends JFBaseResponse { +export type JFAlbumList = { + items: JFAlbum[]; + startIndex: number; + totalRecordCount: number; +}; + +export type JFAlbumDetailResponse = JFAlbum; + +export type JFAlbumDetail = JFAlbum & { songs?: JFSong[] }; + +export interface JFSongListResponse extends JFBasePaginatedResponse { Items: JFSong[]; } +export type JFSongList = JFSongListResponse; + +export interface JFPlaylistListResponse extends JFBasePaginatedResponse { + Items: JFPlaylist[]; +} + +export type JFPlaylistList = JFPlaylistListResponse; + +export type JFPlaylistDetailResponse = JFPlaylist; + +export type JFPlaylistDetail = JFPlaylist & { songs?: JFSong[] }; + +export type JFPlaylist = { + BackdropImageTags: string[]; + ChannelId: null; + ChildCount?: number; + DateCreated: string; + GenreItems: GenreItem[]; + Genres: string[]; + Id: string; + ImageBlurHashes: ImageBlurHashes; + ImageTags: ImageTags; + IsFolder: boolean; + LocationType: string; + MediaType: string; + Name: string; + RunTimeTicks: number; + ServerId: string; + Type: string; + UserData: UserData; +}; + export type JFRequestParams = { albumArtistIds?: string; artistIds?: string; @@ -114,10 +172,13 @@ export type JFArtist = { export type JFAlbum = { AlbumArtist: string; AlbumArtists: JFGenericItem[]; + AlbumPrimaryImageTag: string; ArtistItems: JFGenericItem[]; Artists: string[]; ChannelId: null; + ChildCount?: number; DateCreated: string; + DateLastMediaAdded?: string; ExternalUrls: ExternalURL[]; GenreItems: JFGenericItem[]; Genres: string[]; @@ -134,6 +195,7 @@ export type JFAlbum = { RunTimeTicks: number; ServerId: string; Type: string; + UserData?: UserData; } & { songs?: JFSong[]; }; @@ -168,6 +230,7 @@ export type JFSong = { ServerId: string; SortName: string; Type: string; + UserData?: UserData; }; type ImageBlurHashes = { @@ -405,6 +468,21 @@ type Policy = { SyncPlayAccess: string; }; +type JFBaseParams = { + enableImageTypes?: JFImageType[]; + fields?: string; + imageTypeLimit?: number; + parentId?: string; + recursive?: boolean; +}; + +type JFPaginationParams = { + limit?: number; + nameStartsWith?: string; + sortOrder?: JFSortOrder; + startIndex?: number; +}; + export enum JFSortOrder { ASC = 'Ascending', DESC = 'Descending', @@ -412,22 +490,81 @@ export enum JFSortOrder { export enum JFAlbumListSort { ALBUM_ARTIST = 'AlbumArtist,SortName', + COMMUNITY_RATING = 'CommunityRating,SortName', CRITIC_RATING = 'CriticRating,SortName', NAME = 'SortName', RANDOM = 'Random,SortName', - RATING = 'CommunityRating,SortName', RECENTLY_ADDED = 'DateCreated,SortName', RELEASE_DATE = 'ProductionYear,PremiereDate,SortName', } export type JFAlbumListParams = { - enableImageTypes: JFImageType[]; - imageTypeLimit: number; + filters?: string; + genres?: string; includeItemTypes: 'MusicAlbum'; - limit?: number; - parentId: string; - recursive: boolean; - sortBy: JFAlbumListSort; - sortOrder: JFSortOrder; - startIndex: number; + sortBy?: JFAlbumListSort; + years?: string; +} & JFBaseParams & + JFPaginationParams; + +export enum JFSongListSort { + ALBUM = 'Album,SortName', + ALBUM_ARTIST = 'AlbumArtist,Album,SortName', + ARTIST = 'Artist,Album,SortName', + DURATION = 'Runtime,AlbumArtist,Album,SortName', + NAME = 'Name,SortName', + PLAY_COUNT = 'PlayCount,SortName', + RANDOM = 'Random,SortName', + RECENTLY_ADDED = 'DateCreated,SortName', + RECENTLY_PLAYED = 'DatePlayed,SortName', + RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName', +} + +export type JFSongListParams = { + filters?: string; + genres?: string; + includeItemTypes: 'Audio'; + sortBy?: JFSongListSort; + years?: string; +} & JFBaseParams & + JFPaginationParams; + +export enum JFAlbumArtistListSort { + ALBUM = 'Album,SortName', + DURATION = 'Runtime,AlbumArtist,Album,SortName', + NAME = 'Name,SortName', + RANDOM = 'Random,SortName', + RECENTLY_ADDED = 'DateCreated,SortName', + RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName', +} + +export type JFAlbumArtistListParams = { + filters?: string; + genres?: string; + sortBy?: JFAlbumArtistListSort; + years?: string; +} & JFBaseParams & + JFPaginationParams; + +export enum JFArtistListSort { + ALBUM = 'Album,SortName', + DURATION = 'Runtime,AlbumArtist,Album,SortName', + NAME = 'Name,SortName', + RANDOM = 'Random,SortName', + RECENTLY_ADDED = 'DateCreated,SortName', + RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName', +} + +export type JFArtistListParams = { + filters?: string; + genres?: string; + sortBy?: JFArtistListSort; + years?: string; +} & JFBaseParams & + JFPaginationParams; + +export type JFCreatePlaylistResponse = { + Id: string; }; + +export type JFCreatePlaylist = JFCreatePlaylistResponse; diff --git a/packages/renderer/src/api/navidrome.api.ts b/packages/renderer/src/api/navidrome.api.ts index 630b3a8af..ed26d289a 100644 --- a/packages/renderer/src/api/navidrome.api.ts +++ b/packages/renderer/src/api/navidrome.api.ts @@ -4,7 +4,6 @@ import type { NDGenreListResponse, NDArtistListResponse, NDAlbumDetail, - NDSongListResponse, NDAlbumListParams, NDAlbumList, NDSongDetailResponse, @@ -12,18 +11,14 @@ import type { NDSong, NDAuthenticationResponse, NDAlbumDetailResponse, - NDSongList, NDSongDetail, NDGenreList, NDAlbumArtistListParams, - NDAlbumArtistListSort, NDAlbumArtistDetail, NDAlbumListResponse, NDAlbumArtistDetailResponse, NDAlbumArtistList, NDSongListParams, - NDSongListSort, - NDCreatePlaylist, NDCreatePlaylistParams, NDCreatePlaylistResponse, NDDeletePlaylist, @@ -31,11 +26,13 @@ import type { NDPlaylistListParams, NDPlaylistDetail, NDPlaylistList, - NDPlaylistListSort, NDPlaylistListResponse, NDPlaylistDetailResponse, + NDSongList, + NDSongListResponse, } from '/@/api/navidrome.types'; -import { NDAlbumListSort } from '/@/api/navidrome.types'; +import { NDPlaylistListSort } from '/@/api/navidrome.types'; +import { NDSongListSort } from '/@/api/navidrome.types'; import { NDSortOrder } from '/@/api/navidrome.types'; import type { Album, @@ -52,12 +49,18 @@ import type { DeletePlaylistArgs, PlaylistListArgs, PlaylistDetailArgs, + CreatePlaylistResponse, + PlaylistSongListArgs, } from '/@/api/types'; -import { SortOrder } from '/@/api/types'; +import { playlistListSortMap } from '/@/api/types'; +import { albumArtistListSortMap } from '/@/api/types'; +import { songListSortMap } from '/@/api/types'; +import { albumListSortMap, sortOrderMap } from '/@/api/types'; import { toast } from '/@/components'; import type { ServerListItem } from '/@/store'; import { useAuthStore } from '/@/store'; import { ServerType } from '/@/types'; +import { parseSearchParams } from '/@/utils'; const api = ky.create({ hooks: { @@ -95,56 +98,6 @@ const api = ky.create({ }, }); -// api.interceptors.request.use( -// (config) => { -// const server = useAuthStore.getState().currentServer; - -// config.baseURL = server?.url; -// config.headers = { -// 'Content-Type': 'application/json', -// 'x-nd-authorization': `Bearer ${server?.ndCredential}`, -// }; - -// return config; -// }, -// (err) => { -// return Promise.reject(err); -// }, -// ); - -// api.interceptors.response.use( -// (res) => { -// const serverId = useAuthStore.getState().currentServer?.id; - -// if (serverId) { -// useAuthStore.getState().actions.updateServer(serverId, { -// ndCredential: res.headers['x-nd-authorization'] as string, -// }); -// } - -// return res; -// }, -// async (err) => { -// if (!err.response) { -// return Promise.reject(err); -// } - -// if (err.response && err.response.status === 401) { -// toast.error({ -// message: 'Your session has expired.', -// }); - -// const serverId = useAuthStore.getState().currentServer?.id; - -// if (serverId) { -// useAuthStore.getState().actions.setCurrentServer(null); -// useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined }); -// } -// } -// return Promise.reject(err); -// }, -// ); - const authenticate = async ( url: string, body: { password: string; username: string }, @@ -193,22 +146,7 @@ const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise(); - const albumsData = await api - .get('api/album', { - headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` }, - prefixUrl: server?.url, - searchParams: { - _end: 0, - _order: NDSortOrder.ASC, - _sort: NDAlbumListSort.YEAR, - _start: 0, - artist_id: query.id, - } as NDAlbumListParams, - signal, - }) - .json(); - - return { ...data, albums: albumsData }; + return { ...data }; }; const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise => { @@ -216,8 +154,8 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise => { const searchParams: NDAlbumListParams = { _end: query.startIndex + (query.limit || 0), - _order: query.sortOrder === SortOrder.ASC ? NDSortOrder.ASC : NDSortOrder.DESC, - _sort: query.sortBy as NDAlbumListSort, + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: albumListSortMap.navidrome[query.sortBy], _start: query.startIndex, ...query.ndParams, }; @@ -295,8 +233,8 @@ const getSongList = async (args: SongListArgs): Promise => { const searchParams: NDSongListParams = { _end: query.startIndex + (query.limit || 0), - _order: query.sortOrder === SortOrder.ASC ? NDSortOrder.ASC : NDSortOrder.DESC, - _sort: query.sortBy as NDSongListSort, + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: songListSortMap.navidrome[query.sortBy], _start: query.startIndex, ...query.ndParams, }; @@ -324,6 +262,7 @@ const getSongDetail = async (args: SongDetailArgs): Promise => { const data = await api .get(`api/song/${query.id}`, { headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` }, + prefixUrl: server?.url, signal, }) .json(); @@ -331,7 +270,7 @@ const getSongDetail = async (args: SongDetailArgs): Promise => { return data; }; -const createPlaylist = async (args: CreatePlaylistArgs): Promise => { +const createPlaylist = async (args: CreatePlaylistArgs): Promise => { const { query, server, signal } = args; const json: NDCreatePlaylistParams = { @@ -344,11 +283,15 @@ const createPlaylist = async (args: CreatePlaylistArgs): Promise(); - return data; + return { + id: data.id, + name: query.name, + }; }; const deletePlaylist = async (args: DeletePlaylistArgs): Promise => { @@ -357,6 +300,7 @@ const deletePlaylist = async (args: DeletePlaylistArgs): Promise(); @@ -369,12 +313,12 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise const searchParams: NDPlaylistListParams = { _end: query.startIndex + (query.limit || 0), - _order: query.sortOrder === SortOrder.ASC ? NDSortOrder.ASC : NDSortOrder.DESC, - _sort: query.sortBy as NDPlaylistListSort, + _order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : NDSortOrder.ASC, + _sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : NDPlaylistListSort.NAME, _start: query.startIndex, }; - const res = await api.get('api/song', { + const res = await api.get('api/playlist', { headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` }, prefixUrl: server?.url, searchParams, @@ -395,8 +339,9 @@ const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise(); @@ -404,6 +349,33 @@ const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise => { + const { query, server, signal } = args; + + const searchParams: NDSongListParams & { playlist_id: string } = { + _end: query.startIndex + (query.limit || 0), + _order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : NDSortOrder.ASC, + _sort: query.sortBy ? songListSortMap.navidrome[query.sortBy] : NDSongListSort.ID, + _start: query.startIndex, + playlist_id: query.id, + }; + + const data = await api + .get(`api/playlist/${query.id}/tracks`, { + headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` }, + prefixUrl: server?.url, + searchParams: parseSearchParams(searchParams), + signal, + }) + .json(); + + return { + items: data, + startIndex: query?.startIndex || 0, + totalRecordCount: data.length, + }; +}; + const getCoverArtUrl = (args: { baseUrl: string; coverArtId: string; @@ -426,7 +398,7 @@ const getCoverArtUrl = (args: { ); }; -const normalizeAlbum = (item: NDAlbum, server: ServerListItem, imageSize?: number) => { +const normalizeAlbum = (item: NDAlbum, server: ServerListItem, imageSize?: number): Album => { const imageUrl = getCoverArtUrl({ baseUrl: server.url, coverArtId: item.coverArtId, @@ -434,13 +406,14 @@ const normalizeAlbum = (item: NDAlbum, server: ServerListItem, imageSize?: numbe size: imageSize || 300, }); - const imagePlaceholderUrl = imageUrl?.replace(/size=\d+/, 'size=50'); + const imagePlaceholderUrl = imageUrl?.replace(/size=\d+/, 'size=50') || null; return { albumArtists: [{ id: item.albumArtistId, name: item.albumArtist }], artists: [{ id: item.artistId, name: item.artist }], backdropImageUrl: null, createdAt: item.createdAt, + duration: null, genres: item.genres, id: item.id, imagePlaceholderUrl, @@ -457,7 +430,7 @@ const normalizeAlbum = (item: NDAlbum, server: ServerListItem, imageSize?: numbe songCount: item.songCount, uniqueId: nanoid(), updatedAt: item.updatedAt, - } as Album; + }; }; const normalizeSong = ( @@ -465,7 +438,7 @@ const normalizeSong = ( server: ServerListItem, deviceId: string, imageSize?: number, -) => { +): Song => { const imageUrl = getCoverArtUrl({ baseUrl: server.url, coverArtId: item.albumId, @@ -490,6 +463,8 @@ const normalizeSong = ( imageUrl, isFavorite: item.starred, name: item.title, + path: item.path, + playCount: item.playCount, releaseDate: new Date(item.year, 0, 1).toISOString(), releaseYear: String(item.year), serverId: server.id, @@ -499,7 +474,7 @@ const normalizeSong = ( type: ServerType.NAVIDROME, uniqueId: nanoid(), updatedAt: item.updatedAt, - } as Song; + }; }; export const navidromeApi = { @@ -513,6 +488,7 @@ export const navidromeApi = { getGenreList, getPlaylistDetail, getPlaylistList, + getPlaylistSongList, getSongDetail, getSongList, }; diff --git a/packages/renderer/src/api/navidrome.types.ts b/packages/renderer/src/api/navidrome.types.ts index 07f923479..60414cdf0 100644 --- a/packages/renderer/src/api/navidrome.types.ts +++ b/packages/renderer/src/api/navidrome.types.ts @@ -45,8 +45,6 @@ export type NDAlbum = { starred: boolean; starredAt: string; updatedAt: string; -} & { - songs?: NDSong[]; }; export type NDSong = { @@ -119,7 +117,7 @@ export type NDAuthenticationResponse = NDAuthenticate; export type NDAlbumArtistList = NDAlbumArtist[]; -export type NDAlbumArtistDetail = NDAlbumArtist & { albums: NDAlbumListResponse }; +export type NDAlbumArtistDetail = NDAlbumArtist; export type NDAlbumArtistDetailResponse = NDAlbumArtist; @@ -129,7 +127,7 @@ export type NDGenreListResponse = NDGenre[]; export type NDAlbumDetailResponse = NDAlbum; -export type NDAlbumDetail = NDAlbum & { songs: NDSongListResponse }; +export type NDAlbumDetail = NDAlbum & { songs?: NDSongListResponse }; export type NDAlbumListResponse = NDAlbum[]; @@ -183,6 +181,7 @@ export enum NDAlbumListSort { DURATION = 'duration', NAME = 'name', PLAY_COUNT = 'playCount', + PLAY_DATE = 'play_date', RANDOM = 'random', RATING = 'rating', RECENTLY_ADDED = 'recently_added', @@ -193,6 +192,7 @@ export enum NDAlbumListSort { export type NDAlbumListParams = { _sort?: NDAlbumListSort; + album_id?: string; artist_id?: string; compilation?: boolean; genre_id?: string; @@ -213,7 +213,9 @@ export enum NDSongListSort { CHANNELS = 'channels', COMMENT = 'comment', DURATION = 'duration', + FAVORITED = 'starred ASC, starredAt ASC', GENRE = 'genre', + ID = 'id', NAME = 'name', PLAY_COUNT = 'playCount', PLAY_DATE = 'playDate', @@ -231,11 +233,11 @@ export type NDSongListParams = { export enum NDAlbumArtistListSort { ALBUM_COUNT = 'albumCount', + FAVORITED = 'starred ASC, starredAt ASC', NAME = 'name', PLAY_COUNT = 'playCount', RATING = 'rating', SONG_COUNT = 'songCount', - STARRED = 'starred ASC, starredAt ASC', } export type NDAlbumArtistListParams = { diff --git a/packages/renderer/src/api/query-keys.ts b/packages/renderer/src/api/query-keys.ts index 5fd62156a..520031c90 100644 --- a/packages/renderer/src/api/query-keys.ts +++ b/packages/renderer/src/api/query-keys.ts @@ -1,33 +1,22 @@ -import type { AlbumListQuery } from './types'; +import type { AlbumListQuery, SongListQuery } from './types'; import type { AlbumDetailQuery } from './types'; export const queryKeys = { albums: { - detail: (serverId: string, query: AlbumDetailQuery) => ['albums', serverId, query] as const, - list: (serverId: string, params: AlbumListQuery) => - [serverId, 'albums', 'list', serverId, params] as const, + detail: (serverId: string, query: AlbumDetailQuery) => + [serverId, 'albums', 'detail', query] as const, + list: (serverId: string, query: AlbumListQuery) => + [serverId, 'albums', 'list', serverId, query] as const, root: ['albums'], + serverRoot: (serverId: string) => [serverId, 'albums'], + songs: (serverId: string, query: SongListQuery) => + [serverId, 'albums', 'songs', query] as const, }, genres: { list: (serverId: string) => [serverId, 'genres', 'list'] as const, root: (serverId: string) => [serverId, 'genres'] as const, }, - ping: (url: string) => ['ping', url] as const, server: { root: (serverId: string) => [serverId] as const, }, - servers: { - list: (params?: any) => ['servers', 'list', params] as const, - map: () => ['servers', 'map'] as const, - root: ['servers'], - }, - tasks: { - list: () => ['tasks', 'list'] as const, - root: ['tasks'], - }, - users: { - detail: (userId: string) => ['users', userId] as const, - list: (params?: any) => ['users', 'list', params] as const, - root: ['users'], - }, }; diff --git a/packages/renderer/src/api/subsonic.api.ts b/packages/renderer/src/api/subsonic.api.ts index 86fccda63..5db9b9ae0 100644 --- a/packages/renderer/src/api/subsonic.api.ts +++ b/packages/renderer/src/api/subsonic.api.ts @@ -16,7 +16,6 @@ import type { SSAlbumArtistDetail, SSAlbumArtistDetailResponse, SSFavoriteParams, - SSFavorite, SSFavoriteResponse, SSRatingParams, SSRatingResponse, @@ -30,6 +29,7 @@ import type { AlbumListArgs, AuthenticationResponse, FavoriteArgs, + FavoriteResponse, GenreListArgs, RatingArgs, } from '/@/api/types'; @@ -222,7 +222,7 @@ const getAlbumList = async (args: AlbumListArgs): Promise => { }; }; -const createFavorite = async (args: FavoriteArgs): Promise => { +const createFavorite = async (args: FavoriteArgs): Promise => { const { query, signal } = args; const searchParams: SSFavoriteParams = { @@ -231,17 +231,19 @@ const createFavorite = async (args: FavoriteArgs): Promise => { id: query.type === 'song' ? query.id : undefined, }; - const data = await api + await api .get('/rest/star.view', { searchParams, signal, }) .json(); - return data; + return { + id: query.id, + }; }; -const deleteFavorite = async (args: FavoriteArgs) => { +const deleteFavorite = async (args: FavoriteArgs): Promise => { const { query, signal } = args; const searchParams: SSFavoriteParams = { @@ -250,14 +252,16 @@ const deleteFavorite = async (args: FavoriteArgs) => { id: query.type === 'song' ? query.id : undefined, }; - const data = await api + await api .get('/rest/unstar.view', { searchParams, signal, }) .json(); - return data; + return { + id: query.id, + }; }; const updateRating = async (args: RatingArgs) => { diff --git a/packages/renderer/src/api/types.ts b/packages/renderer/src/api/types.ts index 5997d1a7a..03808515e 100644 --- a/packages/renderer/src/api/types.ts +++ b/packages/renderer/src/api/types.ts @@ -1,29 +1,43 @@ import type { ServerListItem } from '/@/store'; import type { ServerType } from '/@//types'; -import type { JFAlbumListSort, JFSortOrder } from '/@/api/jellyfin.types'; +import type { + JFAlbumArtistDetail, + JFAlbumArtistList, + JFAlbumDetail, + JFAlbumList, + JFArtistList, + JFGenreList, + JFMusicFolderList, + JFPlaylistDetail, + JFPlaylistList, + JFSongList, +} from '/@/api/jellyfin.types'; +import { JFAlbumArtistListSort, JFArtistListSort } from '/@/api/jellyfin.types'; +import { JFAlbumListSort, JFSongListSort } from '/@/api/jellyfin.types'; +import { JFSortOrder } from '/@/api/jellyfin.types'; import type { NDAlbumArtistDetail, NDAlbumArtistList, - NDAlbumArtistListSort, NDAlbumDetail, NDAlbumList, - NDAlbumListSort, - NDCreatePlaylist, NDDeletePlaylist, NDGenreList, NDOrder, NDPlaylistDetail, NDPlaylistList, - NDPlaylistListSort, NDSongDetail, NDSongList, - NDSongListSort, } from '/@/api/navidrome.types'; +import { NDPlaylistListSort } from '/@/api/navidrome.types'; +import { NDAlbumArtistListSort } from '/@/api/navidrome.types'; +import { NDSongListSort } from '/@/api/navidrome.types'; +import { NDAlbumListSort, NDSortOrder } from '/@/api/navidrome.types'; import type { SSAlbumArtistDetail, SSAlbumArtistList, SSAlbumDetail, SSAlbumList, + SSMusicFolderList, } from '/@/api/subsonic.types'; export enum SortOrder { @@ -31,6 +45,27 @@ export enum SortOrder { DESC = 'DESC', } +type SortOrderMap = { + jellyfin: Record; + navidrome: Record; + subsonic: Record; +}; + +export const sortOrderMap: SortOrderMap = { + jellyfin: { + ASC: JFSortOrder.ASC, + DESC: JFSortOrder.DESC, + }, + navidrome: { + ASC: NDSortOrder.ASC, + DESC: NDSortOrder.DESC, + }, + subsonic: { + ASC: undefined, + DESC: undefined, + }, +}; + export enum ExternalSource { LASTFM = 'LASTFM', MUSICBRAINZ = 'MUSICBRAINZ', @@ -88,6 +123,7 @@ export type Album = { artists: RelatedArtist[]; backdropImageUrl: string | null; createdAt: string; + duration: number | null; genres: Genre[]; id: string; imagePlaceholderUrl: string | null; @@ -115,17 +151,19 @@ export type Song = { artists: RelatedArtist[]; bitRate: number; compilation: boolean | null; - container: string; + container: string | null; createdAt: string; discNumber: number; duration: number; genres: Genre[]; id: string; - imageUrl: string; + imageUrl: string | null; isFavorite: boolean; name: string; - releaseDate: string; - releaseYear: string; + path: string | null; + playCount: number; + releaseDate: string | null; + releaseYear: string | null; serverId: string; size: number; streamUrl: string; @@ -192,8 +230,8 @@ type BaseEndpointArgs = { signal?: AbortSignal; }; -// Genre List --------------------------------------------------------------------------- -export type RawGenreListResponse = NDGenreList | undefined; +// Genre List +export type RawGenreListResponse = NDGenreList | JFGenreList | undefined; export type GenreListResponse = BasePaginatedResponse | null | undefined; @@ -201,14 +239,35 @@ export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs; export type GenreListQuery = null; -// Album List --------------------------------------------------------------------------- -export type RawAlbumListResponse = NDAlbumList | SSAlbumList | undefined; +// Album List +export type RawAlbumListResponse = NDAlbumList | SSAlbumList | JFAlbumList | undefined; export type AlbumListResponse = BasePaginatedResponse | null | undefined; -export type AlbumListSort = NDAlbumListSort | JFAlbumListSort; +export enum AlbumListSort { + ALBUM_ARTIST = 'albumArtist', + ARTIST = 'artist', + COMMUNITY_RATING = 'communityRating', + CRITIC_RATING = 'criticRating', + DURATION = 'duration', + FAVORITED = 'favorited', + NAME = 'name', + PLAY_COUNT = 'playCount', + RANDOM = 'random', + RATING = 'rating', + RECENTLY_ADDED = 'recentlyAdded', + RECENTLY_PLAYED = 'recentlyPlayed', + RELEASE_DATE = 'releaseDate', + SONG_COUNT = 'songCount', + YEAR = 'year', +} export type AlbumListQuery = { + jfParams?: { + filters?: string; + genres?: string; + years?: string; + }; limit?: number; musicFolderId?: string; ndParams?: { @@ -227,8 +286,68 @@ export type AlbumListQuery = { export type AlbumListArgs = { query: AlbumListQuery } & BaseEndpointArgs; -// Album Detail ------------------------------------------------------------------------- -export type RawAlbumDetailResponse = NDAlbumDetail | SSAlbumDetail | undefined; +type AlbumListSortMap = { + jellyfin: Record; + navidrome: Record; + subsonic: Record; +}; + +export const albumListSortMap: AlbumListSortMap = { + jellyfin: { + albumArtist: JFAlbumListSort.ALBUM_ARTIST, + artist: undefined, + communityRating: JFAlbumListSort.COMMUNITY_RATING, + criticRating: JFAlbumListSort.CRITIC_RATING, + duration: undefined, + favorited: undefined, + name: JFAlbumListSort.NAME, + playCount: undefined, + random: JFAlbumListSort.RANDOM, + rating: undefined, + recentlyAdded: JFAlbumListSort.RECENTLY_ADDED, + recentlyPlayed: undefined, + releaseDate: JFAlbumListSort.RELEASE_DATE, + songCount: undefined, + year: undefined, + }, + navidrome: { + albumArtist: NDAlbumListSort.ALBUM_ARTIST, + artist: NDAlbumListSort.ARTIST, + communityRating: undefined, + criticRating: undefined, + duration: NDAlbumListSort.DURATION, + favorited: NDAlbumListSort.STARRED, + name: NDAlbumListSort.NAME, + playCount: NDAlbumListSort.PLAY_COUNT, + random: NDAlbumListSort.RANDOM, + rating: NDAlbumListSort.RATING, + recentlyAdded: NDAlbumListSort.RECENTLY_ADDED, + recentlyPlayed: NDAlbumListSort.PLAY_DATE, + releaseDate: undefined, + songCount: NDAlbumListSort.SONG_COUNT, + year: NDAlbumListSort.YEAR, + }, + subsonic: { + albumArtist: undefined, + artist: undefined, + communityRating: undefined, + criticRating: undefined, + duration: undefined, + favorited: undefined, + name: undefined, + playCount: undefined, + random: undefined, + rating: undefined, + recentlyAdded: undefined, + recentlyPlayed: undefined, + releaseDate: undefined, + songCount: undefined, + year: undefined, + }, +}; + +// Album Detail +export type RawAlbumDetailResponse = NDAlbumDetail | SSAlbumDetail | JFAlbumDetail | undefined; export type AlbumDetailResponse = Album | null | undefined; @@ -236,14 +355,38 @@ export type AlbumDetailQuery = { id: string }; export type AlbumDetailArgs = { query: AlbumDetailQuery } & BaseEndpointArgs; -// Song List ---------------------------------------------------------------------------- -export type RawSongListResponse = NDSongList | undefined; +// Song List +export type RawSongListResponse = NDSongList | JFSongList | undefined; export type SongListResponse = BasePaginatedResponse; -export type SongListSort = NDSongListSort; +export enum SongListSort { + ALBUM_ARTIST = 'albumArtist', + ARTIST = 'artist', + BPM = 'bpm', + CHANNELS = 'channels', + COMMENT = 'comment', + DURATION = 'duration', + FAVORITED = 'favorited', + GENRE = 'genre', + NAME = 'name', + PLAY_COUNT = 'playCount', + RANDOM = 'random', + RATING = 'rating', + RECENTLY_ADDED = 'recentlyAdded', + RECENTLY_PLAYED = 'recentlyPlayed', + RELEASE_DATE = 'releaseDate', + YEAR = 'year', +} export type SongListQuery = { + jfParams?: { + filters?: string; + genres?: string; + includeItemTypes: 'Audio'; + sortBy?: JFSongListSort; + years?: string; + }; limit?: number; musicFolderId?: string; ndParams?: { @@ -262,7 +405,70 @@ export type SongListQuery = { export type SongListArgs = { query: SongListQuery } & BaseEndpointArgs; -// Song Detail ------------------------------------------------------------------------- +type SongListSortMap = { + jellyfin: Record; + navidrome: Record; + subsonic: Record; +}; + +export const songListSortMap: SongListSortMap = { + jellyfin: { + albumArtist: JFSongListSort.ALBUM_ARTIST, + artist: JFSongListSort.ARTIST, + bpm: undefined, + channels: undefined, + comment: undefined, + duration: JFSongListSort.DURATION, + favorited: undefined, + genre: undefined, + name: JFSongListSort.NAME, + playCount: JFSongListSort.PLAY_COUNT, + random: JFSongListSort.RANDOM, + rating: undefined, + recentlyAdded: JFSongListSort.RECENTLY_ADDED, + recentlyPlayed: JFSongListSort.RECENTLY_PLAYED, + releaseDate: JFSongListSort.RELEASE_DATE, + year: undefined, + }, + navidrome: { + albumArtist: NDSongListSort.ALBUM_ARTIST, + artist: NDSongListSort.ARTIST, + bpm: NDSongListSort.BPM, + channels: NDSongListSort.CHANNELS, + comment: NDSongListSort.COMMENT, + duration: NDSongListSort.DURATION, + favorited: NDSongListSort.FAVORITED, + genre: NDSongListSort.GENRE, + name: NDSongListSort.NAME, + playCount: NDSongListSort.PLAY_COUNT, + random: undefined, + rating: NDSongListSort.RATING, + recentlyAdded: NDSongListSort.PLAY_DATE, + recentlyPlayed: NDSongListSort.PLAY_DATE, + releaseDate: undefined, + year: NDSongListSort.YEAR, + }, + subsonic: { + albumArtist: undefined, + artist: undefined, + bpm: undefined, + channels: undefined, + comment: undefined, + duration: undefined, + favorited: undefined, + genre: undefined, + name: undefined, + playCount: undefined, + random: undefined, + rating: undefined, + recentlyAdded: undefined, + recentlyPlayed: undefined, + releaseDate: undefined, + year: undefined, + }, +}; + +// Song Detail export type RawSongDetailResponse = NDSongDetail | undefined; export type SongDetailResponse = Song | null | undefined; @@ -271,12 +477,28 @@ export type SongDetailQuery = { id: string }; export type SongDetailArgs = { query: SongDetailQuery } & BaseEndpointArgs; -// Album Artist List ------------------------------------------------------------------- -export type RawAlbumArtistListResponse = NDAlbumArtistList | SSAlbumArtistList | undefined; +// Album Artist List +export type RawAlbumArtistListResponse = + | NDAlbumArtistList + | SSAlbumArtistList + | JFAlbumArtistList + | undefined; export type AlbumArtistListResponse = BasePaginatedResponse; -export type AlbumArtistListSort = NDAlbumArtistListSort; +export enum AlbumArtistListSort { + ALBUM = 'album', + ALBUM_COUNT = 'albumCount', + DURATION = 'duration', + FAVORITED = 'favorited', + NAME = 'name', + PLAY_COUNT = 'playCount', + RANDOM = 'random', + RATING = 'rating', + RECENTLY_ADDED = 'recentlyAdded', + RELEASE_DATE = 'releaseDate', + SONG_COUNT = 'songCount', +} export type AlbumArtistListQuery = { limit?: number; @@ -293,8 +515,60 @@ export type AlbumArtistListQuery = { export type AlbumArtistListArgs = { query: AlbumArtistListQuery } & BaseEndpointArgs; -// Album Artist Detail ----------------------------------------------------------------- -export type RawAlbumArtistDetailResponse = NDAlbumArtistDetail | SSAlbumArtistDetail | undefined; +type AlbumArtistListSortMap = { + jellyfin: Record; + navidrome: Record; + subsonic: Record; +}; + +export const albumArtistListSortMap: AlbumArtistListSortMap = { + jellyfin: { + album: JFAlbumArtistListSort.ALBUM, + albumCount: undefined, + duration: JFAlbumArtistListSort.DURATION, + favorited: undefined, + name: JFAlbumArtistListSort.NAME, + playCount: undefined, + random: JFAlbumArtistListSort.RANDOM, + rating: undefined, + recentlyAdded: JFAlbumArtistListSort.RECENTLY_ADDED, + releaseDate: undefined, + songCount: undefined, + }, + navidrome: { + album: undefined, + albumCount: NDAlbumArtistListSort.ALBUM_COUNT, + duration: undefined, + favorited: NDAlbumArtistListSort.FAVORITED, + name: NDAlbumArtistListSort.NAME, + playCount: NDAlbumArtistListSort.PLAY_COUNT, + random: undefined, + rating: NDAlbumArtistListSort.RATING, + recentlyAdded: undefined, + releaseDate: undefined, + songCount: NDAlbumArtistListSort.SONG_COUNT, + }, + subsonic: { + album: undefined, + albumCount: undefined, + duration: undefined, + favorited: undefined, + name: undefined, + playCount: undefined, + random: undefined, + rating: undefined, + recentlyAdded: undefined, + releaseDate: undefined, + songCount: undefined, + }, +}; + +// Album Artist Detail +export type RawAlbumArtistDetailResponse = + | NDAlbumArtistDetail + | SSAlbumArtistDetail + | JFAlbumArtistDetail + | undefined; export type AlbumArtistDetailResponse = BasePaginatedResponse; @@ -302,20 +576,100 @@ export type AlbumArtistDetailQuery = { id: string }; export type AlbumArtistDetailArgs = { query: AlbumArtistDetailQuery } & BaseEndpointArgs; -// Artist List ------------------------------------------------------------------------- +// Artist List +export type RawArtistListResponse = JFArtistList | undefined; -// Artist Detail ----------------------------------------------------------------------- +export type ArtistListResponse = BasePaginatedResponse; -// Favorite ---------------------------------------------------------------------------- -export type RawFavoriteResponse = null | undefined; +export enum ArtistListSort { + ALBUM = 'album', + ALBUM_COUNT = 'albumCount', + DURATION = 'duration', + FAVORITED = 'favorited', + NAME = 'name', + PLAY_COUNT = 'playCount', + RANDOM = 'random', + RATING = 'rating', + RECENTLY_ADDED = 'recentlyAdded', + RELEASE_DATE = 'releaseDate', + SONG_COUNT = 'songCount', +} -export type FavoriteResponse = null; +export type ArtistListQuery = { + limit?: number; + musicFolderId?: string; + ndParams?: { + genre_id?: string; + name?: string; + starred?: boolean; + }; + sortBy: ArtistListSort; + sortOrder: SortOrder; + startIndex: number; +}; + +export type ArtistListArgs = { query: ArtistListQuery } & BaseEndpointArgs; + +type ArtistListSortMap = { + jellyfin: Record; + navidrome: Record; + subsonic: Record; +}; + +export const artistListSortMap: ArtistListSortMap = { + jellyfin: { + album: JFArtistListSort.ALBUM, + albumCount: undefined, + duration: JFArtistListSort.DURATION, + favorited: undefined, + name: JFArtistListSort.NAME, + playCount: undefined, + random: JFArtistListSort.RANDOM, + rating: undefined, + recentlyAdded: JFArtistListSort.RECENTLY_ADDED, + releaseDate: undefined, + songCount: undefined, + }, + navidrome: { + album: undefined, + albumCount: undefined, + duration: undefined, + favorited: undefined, + name: undefined, + playCount: undefined, + random: undefined, + rating: undefined, + recentlyAdded: undefined, + releaseDate: undefined, + songCount: undefined, + }, + subsonic: { + album: undefined, + albumCount: undefined, + duration: undefined, + favorited: undefined, + name: undefined, + playCount: undefined, + random: undefined, + rating: undefined, + recentlyAdded: undefined, + releaseDate: undefined, + songCount: undefined, + }, +}; + +// Artist Detail + +// Favorite +export type RawFavoriteResponse = FavoriteResponse | undefined; + +export type FavoriteResponse = { id: string }; export type FavoriteQuery = { id: string; type?: 'song' | 'album' | 'albumArtist' }; export type FavoriteArgs = { query: FavoriteQuery } & BaseEndpointArgs; -// Rating ------------------------------------------------------------------------------- +// Rating export type RawRatingResponse = null | undefined; export type RatingResponse = null; @@ -324,16 +678,16 @@ export type RatingQuery = { id: string; rating: number }; export type RatingArgs = { query: RatingQuery } & BaseEndpointArgs; -// Create Playlist ----------------------------------------------------------------------- -export type RawCreatePlaylistResponse = NDCreatePlaylist | undefined; +// Create Playlist +export type RawCreatePlaylistResponse = CreatePlaylistResponse | undefined; -export type CreatePlaylistResponse = null; +export type CreatePlaylistResponse = { id: string; name: string }; export type CreatePlaylistQuery = { comment?: string; name: string; public?: boolean }; export type CreatePlaylistArgs = { query: CreatePlaylistQuery } & BaseEndpointArgs; -// Delete Playlist ----------------------------------------------------------------------- +// Delete Playlist export type RawDeletePlaylistResponse = NDDeletePlaylist | undefined; export type DeletePlaylistResponse = null; @@ -342,8 +696,8 @@ export type DeletePlaylistQuery = { id: string }; export type DeletePlaylistArgs = { query: DeletePlaylistQuery } & BaseEndpointArgs; -// Playlist List ------------------------------------------------------------------------- -export type RawPlaylistListResponse = NDPlaylistList | undefined; +// Playlist List +export type RawPlaylistListResponse = NDPlaylistList | JFPlaylistList | undefined; export type PlaylistListResponse = BasePaginatedResponse; @@ -359,8 +713,41 @@ export type PlaylistListQuery = { export type PlaylistListArgs = { query: PlaylistListQuery } & BaseEndpointArgs; -// Playlist Detail ----------------------------------------------------------------------- -export type RawPlaylistDetailResponse = NDPlaylistDetail | undefined; +type PlaylistListSortMap = { + jellyfin: Record; + navidrome: Record; + subsonic: Record; +}; + +export const playlistListSortMap: PlaylistListSortMap = { + jellyfin: { + duration: undefined, + name: undefined, + owner: undefined, + public: undefined, + songCount: undefined, + updatedAt: undefined, + }, + navidrome: { + duration: NDPlaylistListSort.DURATION, + name: NDPlaylistListSort.NAME, + owner: NDPlaylistListSort.OWNER, + public: NDPlaylistListSort.PUBLIC, + songCount: NDPlaylistListSort.SONG_COUNT, + updatedAt: NDPlaylistListSort.UPDATED_AT, + }, + subsonic: { + duration: undefined, + name: undefined, + owner: undefined, + public: undefined, + songCount: undefined, + updatedAt: undefined, + }, +}; + +// Playlist Detail +export type RawPlaylistDetailResponse = NDPlaylistDetail | JFPlaylistDetail | undefined; export type PlaylistDetailResponse = BasePaginatedResponse; @@ -369,3 +756,38 @@ export type PlaylistDetailQuery = { }; export type PlaylistDetailArgs = { query: PlaylistDetailQuery } & BaseEndpointArgs; + +// Playlist Songs +export type RawPlaylistSongListResponse = JFSongList | undefined; + +export type PlaylistSongListResponse = BasePaginatedResponse; + +export type PlaylistSongListQuery = { + id: string; + limit?: number; + sortBy?: SongListSort; + sortOrder?: SortOrder; + startIndex: number; +}; + +export type PlaylistSongListArgs = { query: PlaylistSongListQuery } & BaseEndpointArgs; + +// Music Folder List +export type RawMusicFolderListResponse = SSMusicFolderList | JFMusicFolderList | undefined; + +export type MusicFolderListResponse = BasePaginatedResponse; + +export type MusicFolderListQuery = { + id: string; +}; + +export type MusicFolderListArgs = { query: MusicFolderListQuery } & BaseEndpointArgs; + +// Create Favorite +export type RawCreateFavoriteResponse = CreateFavoriteResponse | undefined; + +export type CreateFavoriteResponse = { id: string }; + +export type CreateFavoriteQuery = { comment?: string; name: string; public?: boolean }; + +export type CreateFavoriteArgs = { query: CreateFavoriteQuery } & BaseEndpointArgs;