Refactor jellyfin api with ts-rest/axios

This commit is contained in:
jeffvli 2023-05-08 03:34:15 -07:00
parent a9ca3f9083
commit 8a0a8e4d54
5 changed files with 2117 additions and 22 deletions

View File

@ -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<AddToPlaylistResponse>;
@ -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,

View File

@ -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,
});
};

View File

@ -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<AuthenticationResponse> => {
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<MusicFolderListResponse> => {
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<GenreListResponse> => {
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<AlbumArtistDetailResponse> => {
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<AlbumArtistListResponse> => {
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<AlbumArtistListResponse> => {
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<AlbumDetailResponse> => {
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<AlbumListResponse> => {
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<SongListResponse> => {
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<SongListResponse> => {
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<AddToPlaylistResponse> => {
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<RemoveFromPlaylistResponse> => {
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<PlaylistDetailResponse> => {
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<SongListResponse> => {
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<PlaylistListResponse> => {
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<CreatePlaylistResponse> => {
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<UpdatePlaylistResponse> => {
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<null> => {
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<FavoriteResponse> => {
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<FavoriteResponse> => {
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<ScrobbleResponse> => {
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,
};

View File

@ -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<typeof jfType._response.albumArtist>;
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<typeof jfType._response.song>;
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<typeof jfType._response.song>,
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<typeof jfType._response.album>,
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<typeof jfType._response.albumArtist> & {
similarArtists?: z.infer<typeof jfType._response.albumArtistList>;
},
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<typeof jfType._response.playlist>,
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,
};

View File

@ -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,
},
};