From 8a0a8e4d546343116de66801ffbf4c63785e11ef Mon Sep 17 00:00:00 2001 From: jeffvli Date: Mon, 8 May 2023 03:34:15 -0700 Subject: [PATCH] Refactor jellyfin api with ts-rest/axios --- src/renderer/api/controller.ts | 44 +- src/renderer/api/jellyfin/jellyfin-api.ts | 336 ++++++++ .../api/jellyfin/jellyfin-controller.ts | 724 ++++++++++++++++++ .../api/jellyfin/jellyfin-normalize.ts | 368 +++++++++ src/renderer/api/jellyfin/jellyfin-types.ts | 667 ++++++++++++++++ 5 files changed, 2117 insertions(+), 22 deletions(-) create mode 100644 src/renderer/api/jellyfin/jellyfin-api.ts create mode 100644 src/renderer/api/jellyfin/jellyfin-controller.ts create mode 100644 src/renderer/api/jellyfin/jellyfin-normalize.ts create mode 100644 src/renderer/api/jellyfin/jellyfin-types.ts diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index cc99b567..2629d6b7 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -1,5 +1,5 @@ import { useAuthStore } from '/@/renderer/store'; -import { toast } from '/@/renderer/components/toast'; +import { toast } from '/@/renderer/components/toast/index'; import type { AlbumDetailArgs, AlbumListArgs, @@ -44,11 +44,11 @@ import type { UpdatePlaylistResponse, UserListResponse, } from '/@/renderer/api/types'; -import { jellyfinApi } from '/@/renderer/api/jellyfin.api'; import { ServerListItem } from '/@/renderer/types'; import { DeletePlaylistResponse } from './types'; import { ndController } from '/@/renderer/api/navidrome/navidrome-controller'; import { ssController } from '/@/renderer/api/subsonic/subsonic-controller'; +import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller'; export type ControllerEndpoint = Partial<{ addToPlaylist: (args: AddToPlaylistArgs) => Promise; @@ -91,36 +91,36 @@ type ApiController = { const endpoints: ApiController = { jellyfin: { - addToPlaylist: jellyfinApi.addToPlaylist, + addToPlaylist: jfController.addToPlaylist, clearPlaylist: 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, + createFavorite: jfController.createFavorite, + createPlaylist: jfController.createPlaylist, + deleteFavorite: jfController.deleteFavorite, + deletePlaylist: jfController.deletePlaylist, + getAlbumArtistDetail: jfController.getAlbumArtistDetail, + getAlbumArtistList: jfController.getAlbumArtistList, + getAlbumDetail: jfController.getAlbumDetail, + getAlbumList: jfController.getAlbumList, getArtistDetail: undefined, getArtistInfo: undefined, - getArtistList: jellyfinApi.getArtistList, + getArtistList: undefined, getFavoritesList: undefined, getFolderItemList: undefined, getFolderList: undefined, getFolderSongs: undefined, - getGenreList: jellyfinApi.getGenreList, - getMusicFolderList: jellyfinApi.getMusicFolderList, - getPlaylistDetail: jellyfinApi.getPlaylistDetail, - getPlaylistList: jellyfinApi.getPlaylistList, - getPlaylistSongList: jellyfinApi.getPlaylistSongList, + getGenreList: jfController.getGenreList, + getMusicFolderList: jfController.getMusicFolderList, + getPlaylistDetail: jfController.getPlaylistDetail, + getPlaylistList: jfController.getPlaylistList, + getPlaylistSongList: jfController.getPlaylistSongList, getSongDetail: undefined, - getSongList: jellyfinApi.getSongList, - getTopSongs: jellyfinApi.getTopSongList, + getSongList: jfController.getSongList, + getTopSongs: jfController.getTopSongList, getUserList: undefined, - removeFromPlaylist: jellyfinApi.removeFromPlaylist, - scrobble: jellyfinApi.scrobble, + removeFromPlaylist: jfController.removeFromPlaylist, + scrobble: jfController.scrobble, setRating: undefined, - updatePlaylist: jellyfinApi.updatePlaylist, + updatePlaylist: jfController.updatePlaylist, }, navidrome: { addToPlaylist: ndController.addToPlaylist, diff --git a/src/renderer/api/jellyfin/jellyfin-api.ts b/src/renderer/api/jellyfin/jellyfin-api.ts new file mode 100644 index 00000000..c824b9fa --- /dev/null +++ b/src/renderer/api/jellyfin/jellyfin-api.ts @@ -0,0 +1,336 @@ +import { useAuthStore } from '/@/renderer/store'; +import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types'; +import { initClient, initContract } from '@ts-rest/core'; +import axios, { AxiosError, AxiosResponse, isAxiosError, Method } from 'axios'; +import qs from 'qs'; +import { toast } from '/@/renderer/components'; +import { ServerListItem } from '/@/renderer/types'; +import omitBy from 'lodash/omitBy'; + +const c = initContract(); + +export const contract = c.router({ + addToPlaylist: { + body: jfType._parameters.addToPlaylist, + method: 'POST', + path: 'playlists/:id/items', + responses: { + 200: jfType._response.addToPlaylist, + 400: jfType._response.error, + }, + }, + authenticate: { + body: jfType._parameters.authenticate, + method: 'POST', + path: 'auth/login', + responses: { + 200: jfType._response.authenticate, + 400: jfType._response.error, + }, + }, + createFavorite: { + body: jfType._parameters.favorite, + method: 'POST', + path: 'users/:userId/favoriteitems/:id', + responses: { + 200: jfType._response.favorite, + 400: jfType._response.error, + }, + }, + createPlaylist: { + body: jfType._parameters.createPlaylist, + method: 'POST', + path: 'playlists', + responses: { + 200: jfType._response.createPlaylist, + 400: jfType._response.error, + }, + }, + deletePlaylist: { + body: null, + method: 'DELETE', + path: 'items/:id', + responses: { + 204: jfType._response.deletePlaylist, + 400: jfType._response.error, + }, + }, + getAlbumArtistDetail: { + method: 'GET', + path: 'users/:userId/items/:id', + query: jfType._parameters.albumArtistDetail, + responses: { + 200: jfType._response.albumArtist, + 400: jfType._response.error, + }, + }, + getAlbumArtistList: { + method: 'GET', + path: 'artists/albumArtists', + query: jfType._parameters.albumArtistList, + responses: { + 200: jfType._response.albumArtistList, + 400: jfType._response.error, + }, + }, + getAlbumDetail: { + method: 'GET', + path: 'users/:userId/items/:id', + query: jfType._parameters.albumDetail, + responses: { + 200: jfType._response.album, + 400: jfType._response.error, + }, + }, + getAlbumList: { + method: 'GET', + path: 'users/:userId/items', + query: jfType._parameters.albumList, + responses: { + 200: jfType._response.albumList, + 400: jfType._response.error, + }, + }, + getArtistList: { + method: 'GET', + path: 'artists', + query: jfType._parameters.albumArtistList, + responses: { + 200: jfType._response.albumArtistList, + 400: jfType._response.error, + }, + }, + getGenreList: { + method: 'GET', + path: 'genres', + responses: { + 200: jfType._response.genreList, + 400: jfType._response.error, + }, + }, + getMusicFolderList: { + method: 'GET', + path: 'users/:userId/items', + responses: { + 200: jfType._response.musicFolderList, + 400: jfType._response.error, + }, + }, + getPlaylistDetail: { + method: 'GET', + path: 'users/:userId/items/:id', + query: jfType._parameters.playlistDetail, + responses: { + 200: jfType._response.playlist, + 400: jfType._response.error, + }, + }, + getPlaylistList: { + method: 'GET', + path: 'users/:userId/items', + query: jfType._parameters.playlistList, + responses: { + 200: jfType._response.playlistList, + 400: jfType._response.error, + }, + }, + getPlaylistSongList: { + method: 'GET', + path: 'playlists/:id/items', + query: jfType._parameters.songList, + responses: { + 200: jfType._response.playlistSongList, + 400: jfType._response.error, + }, + }, + getSimilarArtistList: { + method: 'GET', + path: 'artists/:id/similar', + query: jfType._parameters.similarArtistList, + responses: { + 200: jfType._response.albumArtistList, + 400: jfType._response.error, + }, + }, + getSongDetail: { + method: 'GET', + path: 'song/:id', + responses: { + 200: jfType._response.song, + 400: jfType._response.error, + }, + }, + getSongList: { + method: 'GET', + path: 'users/:userId/items', + query: jfType._parameters.songList, + responses: { + 200: jfType._response.songList, + 400: jfType._response.error, + }, + }, + getTopSongsList: { + method: 'GET', + path: 'users/:userId/items', + query: jfType._parameters.songList, + responses: { + 200: jfType._response.topSongsList, + 400: jfType._response.error, + }, + }, + removeFavorite: { + body: jfType._parameters.favorite, + method: 'DELETE', + path: 'users/:userId/favoriteitems/:id', + responses: { + 200: jfType._response.favorite, + 400: jfType._response.error, + }, + }, + removeFromPlaylist: { + body: null, + method: 'DELETE', + path: 'items/:id', + query: jfType._parameters.removeFromPlaylist, + responses: { + 200: jfType._response.removeFromPlaylist, + 400: jfType._response.error, + }, + }, + scrobblePlaying: { + body: jfType._parameters.scrobble, + method: 'POST', + path: 'sessions/playing', + responses: { + 200: jfType._response.scrobble, + 400: jfType._response.error, + }, + }, + scrobbleProgress: { + body: jfType._parameters.scrobble, + method: 'POST', + path: 'sessions/playing/progress', + responses: { + 200: jfType._response.scrobble, + 400: jfType._response.error, + }, + }, + scrobbleStopped: { + body: jfType._parameters.scrobble, + method: 'POST', + path: 'sessions/playing/stopped', + responses: { + 200: jfType._response.scrobble, + 400: jfType._response.error, + }, + }, + updatePlaylist: { + body: jfType._parameters.updatePlaylist, + method: 'PUT', + path: 'items/:id', + responses: { + 200: jfType._response.updatePlaylist, + 400: jfType._response.error, + }, + }, +}); + +const axiosClient = axios.create({}); + +axiosClient.defaults.paramsSerializer = (params) => { + return qs.stringify(params, { arrayFormat: 'repeat' }); +}; + +axiosClient.interceptors.response.use( + (response) => { + return response; + }, + (error) => { + if (error.response && error.response.status === 401) { + toast.error({ + message: 'Your session has expired.', + }); + + const currentServer = useAuthStore.getState().currentServer; + + if (currentServer) { + const serverId = currentServer.id; + const token = currentServer.credential; + console.log(`token is expired: ${token}`); + useAuthStore.getState().actions.setCurrentServer(null); + useAuthStore.getState().actions.updateServer(serverId, { credential: undefined }); + } + } + + return Promise.reject(error); + }, +); + +const parsePath = (fullPath: string) => { + const [path, params] = fullPath.split('?'); + + const parsedParams = qs.parse(params); + const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null'); + + return { + params: notNilParams, + path, + }; +}; + +export const jfApiClient = (args: { + server: ServerListItem | null; + signal?: AbortSignal; + url?: string; +}) => { + const { server, url, signal } = args; + + return initClient(contract, { + api: async ({ path, method, headers, body }) => { + let baseUrl: string | undefined; + let token: string | undefined; + + const { params, path: api } = parsePath(path); + + if (server) { + baseUrl = `${server?.url}`; + token = server?.credential; + } else { + baseUrl = url; + } + + try { + const result = await axiosClient.request({ + data: body, + headers: { + ...headers, + ...(token && { 'X-MediaBrowser-Token': token }), + }, + method: method as Method, + params, + signal, + url: `${baseUrl}/${api}`, + }); + return { + body: result.data, + status: result.status, + }; + } catch (e: Error | AxiosError | any) { + if (isAxiosError(e)) { + const error = e as AxiosError; + const response = error.response as AxiosResponse; + return { + body: response.data, + status: response.status, + }; + } + throw e; + } + }, + baseHeaders: { + 'Content-Type': 'application/json', + }, + baseUrl: '', + jsonQuery: false, + }); +}; diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts new file mode 100644 index 00000000..27cc7397 --- /dev/null +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -0,0 +1,724 @@ +import { + AuthenticationResponse, + MusicFolderListArgs, + MusicFolderListResponse, + GenreListArgs, + AlbumArtistDetailArgs, + AlbumArtistListArgs, + albumArtistListSortMap, + sortOrderMap, + ArtistListArgs, + artistListSortMap, + AlbumDetailArgs, + AlbumListArgs, + albumListSortMap, + TopSongListArgs, + SongListArgs, + songListSortMap, + AddToPlaylistArgs, + RemoveFromPlaylistArgs, + PlaylistDetailArgs, + PlaylistSongListArgs, + PlaylistListArgs, + playlistListSortMap, + CreatePlaylistArgs, + CreatePlaylistResponse, + UpdatePlaylistArgs, + UpdatePlaylistResponse, + DeletePlaylistArgs, + FavoriteArgs, + FavoriteResponse, + ScrobbleArgs, + ScrobbleResponse, + GenreListResponse, + AlbumArtistDetailResponse, + AlbumArtistListResponse, + AlbumDetailResponse, + AlbumListResponse, + SongListResponse, + AddToPlaylistResponse, + RemoveFromPlaylistResponse, + PlaylistDetailResponse, + PlaylistListResponse, +} from '/@/renderer/api/types'; +import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; +import { jfNormalize } from './jellyfin-normalize'; +import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types'; + +const formatCommaDelimitedString = (value: string[]) => { + return value.join(','); +}; + +const authenticate = async ( + url: string, + body: { + password: string; + username: string; + }, +): Promise => { + const cleanServerUrl = url.replace(/\/$/, ''); + + const res = await jfApiClient({ server: null, url: cleanServerUrl }).authenticate({ + body: { + Password: body.password, + Username: body.username, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to authenticate'); + } + + return { + credential: res.body.AccessToken, + userId: res.body.User.Id, + username: res.body.User.Name, + }; +}; + +const getMusicFolderList = async (args: MusicFolderListArgs): Promise => { + const { apiClientProps } = args; + const userId = apiClientProps.server?.userId; + + if (!userId) throw new Error('No userId found'); + + const res = await jfApiClient(apiClientProps).getMusicFolderList({ + params: { + userId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get genre list'); + } + + const musicFolders = res.body.Items.filter( + (folder) => folder.CollectionType === jfType._enum.collection.MUSIC, + ); + + return { + items: musicFolders.map(jfNormalize.musicFolder), + startIndex: 0, + totalRecordCount: musicFolders?.length || 0, + }; +}; + +const getGenreList = async (args: GenreListArgs): Promise => { + const { apiClientProps } = args; + + const res = await jfApiClient(apiClientProps).getGenreList(); + + if (res.status !== 200) { + throw new Error('Failed to get genre list'); + } + + return { + items: res.body.Items.map(jfNormalize.genre), + startIndex: 0, + totalRecordCount: res.body?.Items?.length || 0, + }; +}; + +const getAlbumArtistDetail = async ( + args: AlbumArtistDetailArgs, +): Promise => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).getAlbumArtistDetail({ + params: { + id: query.id, + userId: apiClientProps.server?.userId, + }, + query: { + Fields: 'Genres, Overview', + }, + }); + + const similarArtistsRes = await jfApiClient(apiClientProps).getSimilarArtistList({ + params: { + id: query.id, + }, + query: { + Limit: 10, + }, + }); + + if (res.status !== 200 || similarArtistsRes.status !== 200) { + throw new Error('Failed to get album artist detail'); + } + + return jfNormalize.albumArtist( + { ...res.body, similarArtists: similarArtistsRes.body }, + apiClientProps.server, + ); +}; + +const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise => { + const { query, apiClientProps } = args; + + const res = await jfApiClient(apiClientProps).getAlbumArtistList({ + query: { + Fields: 'Genres, DateCreated, ExternalUrls, Overview', + ImageTypeLimit: 1, + Limit: query.limit, + ParentId: query.musicFolderId, + Recursive: true, + SearchTerm: query.searchTerm, + SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'Name,SortName', + SortOrder: sortOrderMap.jellyfin[query.sortOrder], + StartIndex: query.startIndex, + UserId: apiClientProps.server?.userId || undefined, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album artist list'); + } + + return { + items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)), + startIndex: query.startIndex, + totalRecordCount: res.body.TotalRecordCount, + }; +}; + +const getArtistList = async (args: ArtistListArgs): Promise => { + const { query, apiClientProps } = args; + + const res = await jfApiClient(apiClientProps).getAlbumArtistList({ + query: { + Limit: query.limit, + ParentId: query.musicFolderId, + Recursive: true, + SortBy: artistListSortMap.jellyfin[query.sortBy] || 'Name,SortName', + SortOrder: sortOrderMap.jellyfin[query.sortOrder], + StartIndex: query.startIndex, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get artist list'); + } + + return { + items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)), + startIndex: query.startIndex, + totalRecordCount: res.body.TotalRecordCount, + }; +}; + +const getAlbumDetail = async (args: AlbumDetailArgs): Promise => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).getAlbumDetail({ + params: { + id: query.id, + userId: apiClientProps.server.userId, + }, + query: { + Fields: 'Genres, DateCreated, ChildCount', + }, + }); + + const songsRes = await jfApiClient(apiClientProps).getSongList({ + params: { + userId: apiClientProps.server.userId, + }, + query: { + Fields: 'Genres, DateCreated, MediaSources, ParentId', + IncludeItemTypes: 'Audio', + ParentId: query.id, + SortBy: 'Album,SortName', + }, + }); + + if (res.status !== 200 || songsRes.status !== 200) { + throw new Error('Failed to get album detail'); + } + + return jfNormalize.album({ ...res.body, Songs: songsRes.body.Items }, apiClientProps.server); +}; + +const getAlbumList = async (args: AlbumListArgs): Promise => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const yearsGroup = []; + if (query._custom?.jellyfin?.minYear && query._custom?.jellyfin?.maxYear) { + for ( + let i = Number(query._custom?.jellyfin?.minYear); + i <= Number(query._custom?.jellyfin?.maxYear); + i += 1 + ) { + yearsGroup.push(String(i)); + } + } + + const yearsFilter = yearsGroup.length ? yearsGroup.join(',') : undefined; + + const res = await jfApiClient(apiClientProps).getAlbumList({ + params: { + userId: apiClientProps.server?.userId, + }, + query: { + IncludeItemTypes: 'MusicAlbum', + Limit: query.limit, + ParentId: query.musicFolderId, + Recursive: true, + SearchTerm: query.searchTerm, + SortBy: albumListSortMap.jellyfin[query.sortBy] || 'SortName', + SortOrder: sortOrderMap.jellyfin[query.sortOrder], + StartIndex: query.startIndex, + ...query._custom?.jellyfin, + Years: yearsFilter, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album list'); + } + + return { + items: res.body.Items.map((item) => jfNormalize.album(item, apiClientProps.server)), + startIndex: query.startIndex, + totalRecordCount: res.body.TotalRecordCount, + }; +}; + +const getTopSongList = async (args: TopSongListArgs): Promise => { + const { apiClientProps, query } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).getTopSongsList({ + params: { + userId: apiClientProps.server?.userId, + }, + query: { + ArtistIds: query.artistId, + Fields: 'Genres, DateCreated, MediaSources, ParentId', + IncludeItemTypes: 'Audio', + Limit: query.limit, + Recursive: true, + SortBy: 'CommunityRating,SortName', + SortOrder: 'Descending', + UserId: apiClientProps.server?.userId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get top song list'); + } + + return { + items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), + startIndex: 0, + totalRecordCount: res.body.TotalRecordCount, + }; +}; + +const getSongList = async (args: SongListArgs): Promise => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const yearsGroup = []; + if (query._custom?.jellyfin?.minYear && query._custom?.jellyfin?.maxYear) { + for ( + let i = Number(query._custom?.jellyfin?.minYear); + i <= Number(query._custom?.jellyfin?.maxYear); + i += 1 + ) { + yearsGroup.push(String(i)); + } + } + + const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined; + const albumIdsFilter = query.albumIds ? formatCommaDelimitedString(query.albumIds) : undefined; + const artistIdsFilter = query.artistIds ? formatCommaDelimitedString(query.artistIds) : undefined; + + const res = await jfApiClient(apiClientProps).getSongList({ + params: { + userId: apiClientProps.server?.userId, + }, + query: { + AlbumIds: albumIdsFilter, + ArtistIds: artistIdsFilter, + Fields: 'Genres, DateCreated, MediaSources, ParentId', + IncludeItemTypes: 'Audio', + Limit: query.limit, + ParentId: query.musicFolderId, + Recursive: true, + SearchTerm: query.searchTerm, + SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName', + SortOrder: sortOrderMap.jellyfin[query.sortOrder], + StartIndex: query.startIndex, + ...query._custom?.jellyfin, + Years: yearsFilter, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get song list'); + } + + return { + items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), + startIndex: query.startIndex, + totalRecordCount: res.body.TotalRecordCount, + }; +}; + +const addToPlaylist = async (args: AddToPlaylistArgs): Promise => { + const { query, body, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).addToPlaylist({ + body: { + Ids: body.songId, + UserId: apiClientProps?.server?.userId, + }, + params: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to add to playlist'); + } + + return null; +}; + +const removeFromPlaylist = async ( + args: RemoveFromPlaylistArgs, +): Promise => { + const { query, apiClientProps } = args; + + const res = await jfApiClient(apiClientProps).removeFromPlaylist({ + body: null, + params: { + id: query.id, + }, + query: { + EntryIds: query.songId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to remove from playlist'); + } + + return null; +}; + +const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).getPlaylistDetail({ + params: { + id: query.id, + userId: apiClientProps.server?.userId, + }, + query: { + Fields: 'Genres, DateCreated, MediaSources, ChildCount, ParentId', + Ids: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get playlist detail'); + } + + return jfNormalize.playlist(res.body, apiClientProps.server); +}; + +const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).getPlaylistSongList({ + params: { + id: query.id, + }, + query: { + Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId', + IncludeItemTypes: 'Audio', + Limit: query.limit, + SortBy: query.sortBy ? songListSortMap.jellyfin[query.sortBy] : undefined, + SortOrder: query.sortOrder ? sortOrderMap.jellyfin[query.sortOrder] : undefined, + StartIndex: 0, + UserId: apiClientProps.server?.userId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get playlist song list'); + } + + return { + items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), + startIndex: query.startIndex, + totalRecordCount: res.body.TotalRecordCount, + }; +}; + +const getPlaylistList = async (args: PlaylistListArgs): Promise => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).getPlaylistList({ + params: { + userId: apiClientProps.server?.userId, + }, + query: { + Fields: 'ChildCount, Genres, DateCreated, ParentId, Overview', + IncludeItemTypes: 'Playlist', + Limit: query.limit, + MediaTypes: 'Audio', + Recursive: true, + SortBy: playlistListSortMap.jellyfin[query.sortBy], + SortOrder: sortOrderMap.jellyfin[query.sortOrder], + StartIndex: query.startIndex, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get playlist list'); + } + + return { + items: res.body.Items.map((item) => jfNormalize.playlist(item, apiClientProps.server)), + startIndex: 0, + totalRecordCount: res.body.TotalRecordCount, + }; +}; + +const createPlaylist = async (args: CreatePlaylistArgs): Promise => { + const { body, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).createPlaylist({ + body: { + MediaType: 'Audio', + Name: body.name, + Overview: body.comment || '', + UserId: apiClientProps.server.userId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to create playlist'); + } + + return { + id: res.body.Id, + }; +}; + +const updatePlaylist = async (args: UpdatePlaylistArgs): Promise => { + const { query, body, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).updatePlaylist({ + body: { + Genres: body.genres?.map((item) => ({ Id: item.id, Name: item.name })) || [], + MediaType: 'Audio', + Name: body.name, + Overview: body.comment || '', + PremiereDate: null, + ProviderIds: {}, + Tags: [], + UserId: apiClientProps.server?.userId, // Required + }, + params: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to update playlist'); + } + + return null; +}; + +const deletePlaylist = async (args: DeletePlaylistArgs): Promise => { + const { query, apiClientProps } = args; + + const res = await jfApiClient(apiClientProps).deletePlaylist({ + body: null, + params: { + id: query.id, + }, + }); + + if (res.status !== 204) { + throw new Error('Failed to delete playlist'); + } + + return null; +}; + +const createFavorite = async (args: FavoriteArgs): Promise => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + for (const id of query.id) { + await jfApiClient(apiClientProps).createFavorite({ + body: {}, + params: { + id, + userId: apiClientProps.server?.userId, + }, + }); + } + + return null; +}; + +const deleteFavorite = async (args: FavoriteArgs): Promise => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + for (const id of query.id) { + await jfApiClient(apiClientProps).removeFavorite({ + body: {}, + params: { + id, + userId: apiClientProps.server?.userId, + }, + }); + } + + return null; +}; + +const scrobble = async (args: ScrobbleArgs): Promise => { + const { query, apiClientProps } = args; + + const position = query.position && Math.round(query.position); + + if (query.submission) { + // Checked by jellyfin-plugin-lastfm for whether or not to send the "finished" scrobble (uses PositionTicks) + jfApiClient(apiClientProps).scrobbleStopped({ + body: { + IsPaused: true, + ItemId: query.id, + PositionTicks: position, + }, + }); + + return null; + } + + if (query.event === 'start') { + jfApiClient(apiClientProps).scrobblePlaying({ + body: { + ItemId: query.id, + PositionTicks: position, + }, + }); + + return null; + } + + if (query.event === 'pause') { + jfApiClient(apiClientProps).scrobbleProgress({ + body: { + EventName: query.event, + IsPaused: true, + ItemId: query.id, + PositionTicks: position, + }, + }); + + return null; + } + + if (query.event === 'unpause') { + jfApiClient(apiClientProps).scrobbleProgress({ + body: { + EventName: query.event, + IsPaused: false, + ItemId: query.id, + PositionTicks: position, + }, + }); + + return null; + } + + jfApiClient(apiClientProps).scrobbleProgress({ + body: { + ItemId: query.id, + PositionTicks: position, + }, + }); + + return null; +}; + +export const jfController = { + addToPlaylist, + authenticate, + createFavorite, + createPlaylist, + deleteFavorite, + deletePlaylist, + getAlbumArtistDetail, + getAlbumArtistList, + getAlbumDetail, + getAlbumList, + getArtistList, + getGenreList, + getMusicFolderList, + getPlaylistDetail, + getPlaylistList, + getPlaylistSongList, + getSongList, + getTopSongList, + removeFromPlaylist, + scrobble, + updatePlaylist, +}; diff --git a/src/renderer/api/jellyfin/jellyfin-normalize.ts b/src/renderer/api/jellyfin/jellyfin-normalize.ts new file mode 100644 index 00000000..d146fc92 --- /dev/null +++ b/src/renderer/api/jellyfin/jellyfin-normalize.ts @@ -0,0 +1,368 @@ +import { nanoid } from 'nanoid'; +import { z } from 'zod'; +import { JFAlbum, JFPlaylist, JFMusicFolder, JFGenre } from '/@/renderer/api/jellyfin.types'; +import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types'; +import { + Song, + LibraryItem, + Album, + AlbumArtist, + Playlist, + MusicFolder, + Genre, +} from '/@/renderer/api/types'; +import { ServerListItem, ServerType } from '/@/renderer/types'; + +const getStreamUrl = (args: { + container?: string; + deviceId: string; + eTag?: string; + id: string; + mediaSourceId?: string; + server: ServerListItem | null; +}) => { + 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 getAlbumArtistCoverArtUrl = (args: { + baseUrl: string; + item: z.infer; + size: number; +}) => { + const size = args.size ? args.size : 300; + + if (!args.item.ImageTags?.Primary) { + return null; + } + + return ( + `${args.baseUrl}/Items` + + `/${args.item.Id}` + + '/Images/Primary' + + `?width=${size}&height=${size}` + + '&quality=96' + ); +}; + +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: z.infer; + size: number; +}) => { + const size = args.size ? args.size : 100; + + 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 getPlaylistCoverArtUrl = (args: { baseUrl: string; item: JFPlaylist; size: number }) => { + const size = args.size ? args.size : 300; + + if (!args.item.ImageTags?.Primary) { + return null; + } + + return ( + `${args.baseUrl}/Items` + + `/${args.item.Id}` + + '/Images/Primary' + + `?width=${size}&height=${size}` + + '&quality=96' + ); +}; + +const normalizeSong = ( + item: z.infer, + server: ServerListItem | null, + deviceId: string, + imageSize?: number, +): Song => { + return { + album: item.Album, + albumArtists: item.AlbumArtists?.map((entry) => ({ + id: entry.Id, + imageUrl: null, + name: entry.Name, + })), + albumId: item.AlbumId, + artistName: item.ArtistItems[0]?.Name, + artists: item.ArtistItems.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })), + bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)), + bpm: null, + channels: null, + comment: null, + 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, + imagePlaceholderUrl: null, + imageUrl: getSongCoverArtUrl({ baseUrl: server?.url || '', item, size: imageSize || 100 }), + itemType: LibraryItem.SONG, + lastPlayedAt: null, + name: item.Name, + path: (item.MediaSources && item.MediaSources[0]?.Path) || null, + playCount: (item.UserData && item.UserData.PlayCount) || 0, + playlistItemId: item.PlaylistItemId, + // releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null, + releaseDate: null, + releaseYear: item.ProductionYear ? String(item.ProductionYear) : null, + serverId: server?.id || '', + serverType: ServerType.JELLYFIN, + 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, + uniqueId: nanoid(), + updatedAt: item.DateCreated, + userFavorite: (item.UserData && item.UserData.IsFavorite) || false, + userRating: null, + }; +}; + +const normalizeAlbum = ( + item: z.infer, + server: ServerListItem | null, + imageSize?: number, +): Album => { + return { + albumArtists: + item.AlbumArtists.map((entry) => ({ + id: entry.Id, + imageUrl: null, + name: entry.Name, + })) || [], + artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })), + backdropImageUrl: null, + createdAt: item.DateCreated, + duration: item.RunTimeTicks / 10000, + 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, + itemType: LibraryItem.ALBUM, + lastPlayedAt: null, + name: item.Name, + playCount: item.UserData?.PlayCount || 0, + releaseDate: item.PremiereDate?.split('T')[0] || null, + releaseYear: item.ProductionYear || null, + serverId: server?.id || '', + serverType: ServerType.JELLYFIN, + size: null, + songCount: item?.ChildCount || null, + songs: item.Songs?.map((song) => normalizeSong(song, server, '', imageSize)), + uniqueId: nanoid(), + updatedAt: item?.DateLastMediaAdded || item.DateCreated, + userFavorite: item.UserData?.IsFavorite || false, + userRating: null, + }; +}; + +const normalizeAlbumArtist = ( + item: z.infer & { + similarArtists?: z.infer; + }, + server: ServerListItem | null, + imageSize?: number, +): AlbumArtist => { + const similarArtists = + item.similarArtists?.Items?.filter((entry) => entry.Name !== 'Various Artists').map( + (entry) => ({ + id: entry.Id, + imageUrl: getAlbumArtistCoverArtUrl({ + baseUrl: server?.url || '', + item: entry, + size: imageSize || 300, + }), + name: entry.Name, + }), + ) || []; + + return { + albumCount: null, + backgroundImageUrl: null, + biography: item.Overview || null, + duration: item.RunTimeTicks / 10000, + genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })), + id: item.Id, + imageUrl: getAlbumArtistCoverArtUrl({ + baseUrl: server?.url || '', + item, + size: imageSize || 300, + }), + itemType: LibraryItem.ALBUM_ARTIST, + lastPlayedAt: null, + name: item.Name, + playCount: item.UserData?.PlayCount || 0, + serverId: server?.id || '', + serverType: ServerType.JELLYFIN, + similarArtists, + songCount: null, + userFavorite: item.UserData?.IsFavorite || false, + userRating: null, + }; +}; + +const normalizePlaylist = ( + item: z.infer, + server: ServerListItem | null, + imageSize?: number, +): Playlist => { + const imageUrl = getPlaylistCoverArtUrl({ + baseUrl: server?.url || '', + item, + size: imageSize || 300, + }); + + const imagePlaceholderUrl = null; + + return { + description: item.Overview || null, + duration: item.RunTimeTicks / 10000, + genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })), + id: item.Id, + imagePlaceholderUrl, + imageUrl: imageUrl || null, + itemType: LibraryItem.PLAYLIST, + name: item.Name, + owner: null, + ownerId: null, + public: null, + rules: null, + serverId: server?.id || '', + serverType: ServerType.JELLYFIN, + size: null, + songCount: item?.ChildCount || null, + sync: null, + }; +}; + +const normalizeMusicFolder = (item: JFMusicFolder): MusicFolder => { + return { + id: item.Id, + name: item.Name, + }; +}; + +// 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 normalizeGenre = (item: JFGenre): Genre => { + return { + albumCount: undefined, + id: item.Id, + name: item.Name, + songCount: undefined, + }; +}; + +// 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 jfNormalize = { + album: normalizeAlbum, + albumArtist: normalizeAlbumArtist, + genre: normalizeGenre, + musicFolder: normalizeMusicFolder, + playlist: normalizePlaylist, + song: normalizeSong, +}; diff --git a/src/renderer/api/jellyfin/jellyfin-types.ts b/src/renderer/api/jellyfin/jellyfin-types.ts new file mode 100644 index 00000000..f8e9d0cd --- /dev/null +++ b/src/renderer/api/jellyfin/jellyfin-types.ts @@ -0,0 +1,667 @@ +import { z } from 'zod'; + +const sortOrderValues = ['Ascending', 'Descending'] as const; + +const jfExternal = { + IMDB: 'Imdb', + MUSIC_BRAINZ: 'MusicBrainz', + THE_AUDIO_DB: 'TheAudioDb', + THE_MOVIE_DB: 'TheMovieDb', + TVDB: 'Tvdb', +}; + +const jfImage = { + BACKDROP: 'Backdrop', + BANNER: 'Banner', + BOX: 'Box', + CHAPTER: 'Chapter', + DISC: 'Disc', + LOGO: 'Logo', + PRIMARY: 'Primary', + THUMB: 'Thumb', +} as const; + +const jfCollection = { + MUSIC: 'music', + PLAYLISTS: 'playlists', +} as const; + +const error = z.object({ + errors: z.object({ + recursive: z.array(z.string()), + }), + status: z.number(), + title: z.string(), + traceId: z.string(), + type: z.string(), +}); + +const baseParameters = z.object({ + AlbumArtistIds: z.string().optional(), + ArtistIds: z.string().optional(), + EnableImageTypes: z.string().optional(), + EnableTotalRecordCount: z.boolean().optional(), + EnableUserData: z.boolean().optional(), + ExcludeItemTypes: z.string().optional(), + Fields: z.string().optional(), + ImageTypeLimit: z.number().optional(), + IncludeItemTypes: z.string().optional(), + IsFavorite: z.boolean().optional(), + Limit: z.number().optional(), + MediaTypes: z.string().optional(), + ParentId: z.string().optional(), + Recursive: z.boolean().optional(), + SearchTerm: z.string().optional(), + SortBy: z.string().optional(), + SortOrder: z.enum(sortOrderValues).optional(), + StartIndex: z.number().optional(), + UserId: z.string().optional(), +}); + +const paginationParameters = z.object({ + Limit: z.number().optional(), + NameStartsWith: z.string().optional(), + SortOrder: z.enum(sortOrderValues).optional(), + StartIndex: z.number().optional(), +}); + +const pagination = z.object({ + StartIndex: z.number(), + TotalRecordCount: z.number(), +}); + +const imageTags = z.object({ + Logo: z.string().optional(), + Primary: z.string().optional(), +}); + +const imageBlurHashes = z.object({ + Backdrop: z.string().optional(), + Logo: z.string().optional(), + Primary: z.string().optional(), +}); + +const userData = z.object({ + IsFavorite: z.boolean(), + Key: z.string(), + PlayCount: z.number(), + PlaybackPositionTicks: z.number(), + Played: z.boolean(), +}); + +const externalUrl = z.object({ + Name: z.string(), + Url: z.string(), +}); + +const mediaStream = z.object({ + AspectRatio: z.string().optional(), + BitDepth: z.number().optional(), + BitRate: z.number().optional(), + ChannelLayout: z.string().optional(), + Channels: z.number().optional(), + Codec: z.string(), + CodecTimeBase: z.string(), + ColorSpace: z.string().optional(), + Comment: z.string().optional(), + DisplayTitle: z.string().optional(), + Height: z.number().optional(), + Index: z.number(), + IsDefault: z.boolean(), + IsExternal: z.boolean(), + IsForced: z.boolean(), + IsInterlaced: z.boolean(), + IsTextSubtitleStream: z.boolean(), + Level: z.number(), + PixelFormat: z.string().optional(), + Profile: z.string().optional(), + RealFrameRate: z.number().optional(), + RefFrames: z.number().optional(), + SampleRate: z.number().optional(), + SupportsExternalStream: z.boolean(), + TimeBase: z.string(), + Type: z.string(), + Width: z.number().optional(), +}); + +const mediaSources = z.object({ + Bitrate: z.number(), + Container: z.string(), + DefaultAudioStreamIndex: z.number(), + ETag: z.string(), + Formats: z.array(z.any()), + GenPtsInput: z.boolean(), + Id: z.string(), + IgnoreDts: z.boolean(), + IgnoreIndex: z.boolean(), + IsInfiniteStream: z.boolean(), + IsRemote: z.boolean(), + MediaAttachments: z.array(z.any()), + MediaStreams: z.array(mediaStream), + Name: z.string(), + Path: z.string(), + Protocol: z.string(), + ReadAtNativeFramerate: z.boolean(), + RequiredHttpHeaders: z.any(), + RequiresClosing: z.boolean(), + RequiresLooping: z.boolean(), + RequiresOpening: z.boolean(), + RunTimeTicks: z.number(), + Size: z.number(), + SupportsDirectPlay: z.boolean(), + SupportsDirectStream: z.boolean(), + SupportsProbing: z.boolean(), + SupportsTranscoding: z.boolean(), + Type: z.string(), +}); + +const sessionInfo = z.object({ + AdditionalUsers: z.array(z.any()), + ApplicationVersion: z.string(), + Capabilities: z.object({ + PlayableMediaTypes: z.array(z.any()), + SupportedCommands: z.array(z.any()), + SupportsContentUploading: z.boolean(), + SupportsMediaControl: z.boolean(), + SupportsPersistentIdentifier: z.boolean(), + SupportsSync: z.boolean(), + }), + Client: z.string(), + DeviceId: z.string(), + DeviceName: z.string(), + HasCustomDeviceName: z.boolean(), + Id: z.string(), + IsActive: z.boolean(), + LastActivityDate: z.string(), + LastPlaybackCheckIn: z.string(), + NowPlayingQueue: z.array(z.any()), + NowPlayingQueueFullItems: z.array(z.any()), + PlayState: z.object({ + CanSeek: z.boolean(), + IsMuted: z.boolean(), + IsPaused: z.boolean(), + RepeatMode: z.string(), + }), + PlayableMediaTypes: z.array(z.any()), + RemoteEndPoint: z.string(), + ServerId: z.string(), + SupportedCommands: z.array(z.any()), + SupportsMediaControl: z.boolean(), + SupportsRemoteControl: z.boolean(), + UserId: z.string(), + UserName: z.string(), +}); + +const configuration = z.object({ + DisplayCollectionsView: z.boolean(), + DisplayMissingEpisodes: z.boolean(), + EnableLocalPassword: z.boolean(), + EnableNextEpisodeAutoPlay: z.boolean(), + GroupedFolders: z.array(z.any()), + HidePlayedInLatest: z.boolean(), + LatestItemsExcludes: z.array(z.any()), + MyMediaExcludes: z.array(z.any()), + OrderedViews: z.array(z.any()), + PlayDefaultAudioTrack: z.boolean(), + RememberAudioSelections: z.boolean(), + RememberSubtitleSelections: z.boolean(), + SubtitleLanguagePreference: z.string(), + SubtitleMode: z.string(), +}); + +const policy = z.object({ + AccessSchedules: z.array(z.any()), + AuthenticationProviderId: z.string(), + BlockUnratedItems: z.array(z.any()), + BlockedChannels: z.array(z.any()), + BlockedMediaFolders: z.array(z.any()), + BlockedTags: z.array(z.any()), + EnableAllChannels: z.boolean(), + EnableAllDevices: z.boolean(), + EnableAllFolders: z.boolean(), + EnableAudioPlaybackTranscoding: z.boolean(), + EnableContentDeletion: z.boolean(), + EnableContentDeletionFromFolders: z.array(z.any()), + EnableContentDownloading: z.boolean(), + EnableLiveTvAccess: z.boolean(), + EnableLiveTvManagement: z.boolean(), + EnableMediaConversion: z.boolean(), + EnableMediaPlayback: z.boolean(), + EnablePlaybackRemuxing: z.boolean(), + EnablePublicSharing: z.boolean(), + EnableRemoteAccess: z.boolean(), + EnableRemoteControlOfOtherUsers: z.boolean(), + EnableSharedDeviceControl: z.boolean(), + EnableSyncTranscoding: z.boolean(), + EnableUserPreferenceAccess: z.boolean(), + EnableVideoPlaybackTranscoding: z.boolean(), + EnabledChannels: z.array(z.any()), + EnabledDevices: z.array(z.any()), + EnabledFolders: z.array(z.any()), + ForceRemoteSourceTranscoding: z.boolean(), + InvalidLoginAttemptCount: z.number(), + IsAdministrator: z.boolean(), + IsDisabled: z.boolean(), + IsHidden: z.boolean(), + LoginAttemptsBeforeLockout: z.number(), + MaxActiveSessions: z.number(), + PasswordResetProviderId: z.string(), + RemoteClientBitrateLimit: z.number(), + SyncPlayAccess: z.string(), +}); + +const user = z.object({ + Configuration: configuration, + EnableAutoLogin: z.boolean(), + HasConfiguredEasyPassword: z.boolean(), + HasConfiguredPassword: z.boolean(), + HasPassword: z.boolean(), + Id: z.string(), + LastActivityDate: z.string(), + LastLoginDate: z.string(), + Name: z.string(), + Policy: policy, + ServerId: z.string(), +}); + +const authenticateParameters = z.object({ + Password: z.string(), + Username: z.string(), +}); + +const authenticate = z.object({ + AccessToken: z.string(), + ServerId: z.string(), + SessionInfo: sessionInfo, + User: user, +}); + +const genreItem = z.object({ + Id: z.string(), + Name: z.string(), +}); + +const genre = z.object({ + BackdropImageTags: z.array(z.any()), + ChannelId: z.null(), + Id: z.string(), + ImageBlurHashes: imageBlurHashes, + ImageTags: imageTags, + LocationType: z.string(), + Name: z.string(), + ServerId: z.string(), + Type: z.string(), +}); + +const genreList = z.object({ + Items: z.array(genre), +}); + +const musicFolder = z.object({ + BackdropImageTags: z.array(z.string()), + ChannelId: z.null(), + CollectionType: z.string(), + Id: z.string(), + ImageBlurHashes: imageBlurHashes, + ImageTags: imageTags, + IsFolder: z.boolean(), + LocationType: z.string(), + Name: z.string(), + ServerId: z.string(), + Type: z.string(), + UserData: userData, +}); + +const musicFolderListParameters = z.object({ + UserId: z.string(), +}); + +const musicFolderList = z.object({ + Items: z.array(musicFolder), +}); + +const playlist = z.object({ + BackdropImageTags: z.array(z.string()), + ChannelId: z.null(), + ChildCount: z.number().optional(), + DateCreated: z.string(), + GenreItems: z.array(genreItem), + Genres: z.array(z.string()), + Id: z.string(), + ImageBlurHashes: imageBlurHashes, + ImageTags: imageTags, + IsFolder: z.boolean(), + LocationType: z.string(), + MediaType: z.string(), + Name: z.string(), + Overview: z.string().optional(), + RunTimeTicks: z.number(), + ServerId: z.string(), + Type: z.string(), + UserData: userData, +}); + +const jfPlaylistListSort = { + ALBUM_ARTIST: 'AlbumArtist,SortName', + DURATION: 'Runtime', + NAME: 'SortName', + RECENTLY_ADDED: 'DateCreated,SortName', + SONG_COUNT: 'ChildCount', +} as const; + +const playlistListParameters = paginationParameters.merge( + baseParameters.extend({ + IncludeItemTypes: z.literal('Playlist'), + SortBy: z.nativeEnum(jfPlaylistListSort).optional(), + }), +); + +const playlistList = pagination.extend({ + Items: z.array(playlist), +}); + +const genericItem = z.object({ + Id: z.string(), + Name: z.string(), +}); + +const song = z.object({ + Album: z.string(), + AlbumArtist: z.string(), + AlbumArtists: z.array(genericItem), + AlbumId: z.string(), + AlbumPrimaryImageTag: z.string(), + ArtistItems: z.array(genericItem), + Artists: z.array(z.string()), + BackdropImageTags: z.array(z.string()), + ChannelId: z.null(), + DateCreated: z.string(), + ExternalUrls: z.array(externalUrl), + GenreItems: z.array(genericItem), + Genres: z.array(z.string()), + Id: z.string(), + ImageBlurHashes: imageBlurHashes, + ImageTags: imageTags, + IndexNumber: z.number(), + IsFolder: z.boolean(), + LocationType: z.string(), + MediaSources: z.array(mediaSources), + MediaType: z.string(), + Name: z.string(), + ParentIndexNumber: z.number(), + PlaylistItemId: z.string().optional(), + PremiereDate: z.string().optional(), + ProductionYear: z.number(), + RunTimeTicks: z.number(), + ServerId: z.string(), + SortName: z.string(), + Type: z.string(), + UserData: userData.optional(), +}); + +const albumArtist = z.object({ + BackdropImageTags: z.array(z.string()), + ChannelId: z.null(), + DateCreated: z.string(), + ExternalUrls: z.array(externalUrl), + GenreItems: z.array(genreItem), + Genres: z.array(z.string()), + Id: z.string(), + ImageBlurHashes: imageBlurHashes, + ImageTags: imageTags, + LocationType: z.string(), + Name: z.string(), + Overview: z.string(), + RunTimeTicks: z.number(), + ServerId: z.string(), + Type: z.string(), + UserData: userData.optional(), +}); + +const albumDetailParameters = baseParameters; + +const album = z.object({ + AlbumArtist: z.string(), + AlbumArtists: z.array(genericItem), + AlbumPrimaryImageTag: z.string(), + ArtistItems: z.array(genericItem), + Artists: z.array(z.string()), + ChannelId: z.null(), + ChildCount: z.number().optional(), + DateCreated: z.string(), + DateLastMediaAdded: z.string().optional(), + ExternalUrls: z.array(externalUrl), + GenreItems: z.array(genericItem), + Genres: z.array(z.string()), + Id: z.string(), + ImageBlurHashes: imageBlurHashes, + ImageTags: imageTags, + IsFolder: z.boolean(), + LocationType: z.string(), + Name: z.string(), + ParentLogoImageTag: z.string(), + ParentLogoItemId: z.string(), + PremiereDate: z.string().optional(), + ProductionYear: z.number(), + RunTimeTicks: z.number(), + ServerId: z.string(), + Songs: z.array(song).optional(), // This is not a native Jellyfin property -- this is used for combined album detail + Type: z.string(), + UserData: userData.optional(), +}); + +const jfAlbumListSort = { + ALBUM_ARTIST: 'AlbumArtist,SortName', + COMMUNITY_RATING: 'CommunityRating,SortName', + CRITIC_RATING: 'CriticRating,SortName', + NAME: 'SortName', + RANDOM: 'Random,SortName', + RECENTLY_ADDED: 'DateCreated,SortName', + RELEASE_DATE: 'ProductionYear,PremiereDate,SortName', +} as const; + +const albumListParameters = paginationParameters.merge( + baseParameters.extend({ + Filters: z.string().optional(), + GenreIds: z.string().optional(), + Genres: z.string().optional(), + IncludeItemTypes: z.literal('MusicAlbum'), + IsFavorite: z.boolean().optional(), + SearchTerm: z.string().optional(), + SortBy: z.nativeEnum(jfAlbumListSort).optional(), + Tags: z.string().optional(), + Years: z.string().optional(), + }), +); + +const albumList = pagination.extend({ + Items: z.array(album), +}); + +const 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', +} as const; + +const albumArtistListParameters = paginationParameters.merge( + baseParameters.extend({ + Filters: z.string().optional(), + Genres: z.string().optional(), + SortBy: z.nativeEnum(jfAlbumArtistListSort).optional(), + Years: z.string().optional(), + }), +); + +const albumArtistList = pagination.extend({ + Items: z.array(albumArtist), +}); + +const similarArtistListParameters = baseParameters.extend({ + Limit: z.number().optional(), +}); + +const jfSongListSort = { + ALBUM: 'Album,SortName', + ALBUM_ARTIST: 'AlbumArtist,Album,SortName', + ARTIST: 'Artist,Album,SortName', + COMMUNITY_RATING: 'CommunityRating,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', +} as const; + +const songListParameters = baseParameters.extend({ + AlbumArtistIds: z.string().optional(), + AlbumIds: z.string().optional(), + ArtistIds: z.string().optional(), + Filters: z.string().optional(), + GenreIds: z.string().optional(), + Genres: z.string().optional(), + IsFavorite: z.boolean().optional(), + SearchTerm: z.string().optional(), + SortBy: z.nativeEnum(jfSongListSort).optional(), + Tags: z.string().optional(), + Years: z.string().optional(), +}); + +const songList = pagination.extend({ + Items: z.array(song), +}); + +const playlistSongList = songList; + +const topSongsList = songList; + +const playlistDetailParameters = baseParameters.extend({ + Ids: z.string(), +}); + +const createPlaylistParameters = z.object({ + MediaType: z.literal('Audio'), + Name: z.string(), + Overview: z.string(), + UserId: z.string(), +}); + +const createPlaylist = z.object({ + Id: z.string(), +}); + +const updatePlaylist = z.null(); + +const updatePlaylistParameters = z.object({ + Genres: z.array(genreItem), + MediaType: z.literal('Audio'), + Name: z.string(), + Overview: z.string(), + PremiereDate: z.null(), + ProviderIds: z.object({}), + Tags: z.array(genericItem), + UserId: z.string(), +}); + +const addToPlaylist = z.object({ + Added: z.number(), +}); + +const addToPlaylistParameters = z.object({ + Ids: z.array(z.string()), + UserId: z.string(), +}); + +const removeFromPlaylist = z.null(); + +const removeFromPlaylistParameters = z.object({ + EntryIds: z.array(z.string()), +}); + +const deletePlaylist = z.null(); + +const deletePlaylistParameters = z.object({ + Id: z.string(), +}); + +const scrobbleParameters = z.object({ + EventName: z.string().optional(), + IsPaused: z.boolean().optional(), + ItemId: z.string(), + PositionTicks: z.number().optional(), +}); + +const scrobble = z.any(); + +const favorite = z.object({ + IsFavorite: z.boolean(), + ItemId: z.string(), + Key: z.string(), + LastPlayedDate: z.string(), + Likes: z.boolean(), + PlayCount: z.number(), + PlaybackPositionTicks: z.number(), + Played: z.boolean(), + PlayedPercentage: z.number(), + Rating: z.number(), + UnplayedItemCount: z.number(), +}); + +const favoriteParameters = z.object({}); + +export const jfType = { + _enum: { + collection: jfCollection, + external: jfExternal, + image: jfImage, + }, + _parameters: { + addToPlaylist: addToPlaylistParameters, + albumArtistDetail: baseParameters, + albumArtistList: albumArtistListParameters, + albumDetail: albumDetailParameters, + albumList: albumListParameters, + authenticate: authenticateParameters, + createPlaylist: createPlaylistParameters, + deletePlaylist: deletePlaylistParameters, + favorite: favoriteParameters, + musicFolderList: musicFolderListParameters, + playlistDetail: playlistDetailParameters, + playlistList: playlistListParameters, + removeFromPlaylist: removeFromPlaylistParameters, + scrobble: scrobbleParameters, + similarArtistList: similarArtistListParameters, + songList: songListParameters, + updatePlaylist: updatePlaylistParameters, + }, + _response: { + addToPlaylist, + album, + albumArtist, + albumArtistList, + albumList, + authenticate, + createPlaylist, + deletePlaylist, + error, + favorite, + genre, + genreList, + musicFolderList, + playlist, + playlistList, + playlistSongList, + removeFromPlaylist, + scrobble, + song, + songList, + topSongsList, + updatePlaylist, + user, + }, +};