import ky from 'ky'; import { nanoid } from 'nanoid/non-secure'; import type { JFAlbum, JFAlbumArtistDetail, JFAlbumArtistDetailResponse, JFAlbumArtistList, JFAlbumArtistListParams, JFAlbumArtistListResponse, JFAlbumDetail, JFAlbumDetailResponse, JFAlbumList, JFAlbumListParams, JFAlbumListResponse, JFArtistList, JFArtistListParams, JFArtistListResponse, JFAuthenticate, JFCreatePlaylistResponse, JFGenreList, JFGenreListResponse, JFMusicFolderList, JFMusicFolderListResponse, JFPlaylistDetail, JFPlaylistDetailResponse, JFPlaylistList, JFPlaylistListResponse, JFSong, JFSongList, JFSongListParams, JFSongListResponse, } from '/@/renderer/api/jellyfin.types'; import { JFCollectionType } from '/@/renderer/api/jellyfin.types'; import type { Album, AlbumArtistDetailArgs, AlbumArtistListArgs, AlbumDetailArgs, AlbumListArgs, ArtistListArgs, AuthenticationResponse, CreatePlaylistArgs, CreatePlaylistResponse, DeletePlaylistArgs, FavoriteArgs, FavoriteResponse, GenreListArgs, MusicFolderListArgs, PlaylistDetailArgs, PlaylistListArgs, PlaylistSongListArgs, Song, SongListArgs, } from '/@/renderer/api/types'; import { songListSortMap, albumListSortMap, artistListSortMap, sortOrderMap, albumArtistListSortMap, } from '/@/renderer/api/types'; import { useAuthStore } from '/@/renderer/store'; import { ServerListItem, ServerType } from '/@/renderer/types'; import { parseSearchParams } from '/@/renderer/utils'; const api = ky.create({}); const authenticate = async ( url: string, body: { password: string; username: string; }, ): Promise => { const cleanServerUrl = url.replace(/\/$/, ''); const data = await ky .post(`${cleanServerUrl}/users/authenticatebyname`, { headers: { 'X-Emby-Authorization': 'MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="0.0.1-alpha1"', }, json: { pw: body.password, username: body.username, }, }) .json(); return { credential: data.AccessToken, userId: data.User.Id, username: data.User.Name, }; }; const getMusicFolderList = async (args: MusicFolderListArgs): Promise => { const { signal } = args; const userId = useAuthStore.getState().currentServer?.userId; const data = await api .get(`users/${userId}/items`, { signal, }) .json(); const musicFolders = data.Items.filter( (folder) => folder.CollectionType === JFCollectionType.MUSIC, ); return { items: musicFolders, startIndex: data.StartIndex, totalRecordCount: data.TotalRecordCount, }; }; const getGenreList = async (args: GenreListArgs): Promise => { const { signal, server } = args; const data = await api .get('genres', { headers: { 'X-MediaBrowser-Token': server?.credential }, prefixUrl: server?.url, signal, }) .json(); return data; }; const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise => { const { query, server, signal } = args; const searchParams = { fields: 'Genres', }; const data = await api .get(`/users/${server?.userId}/items/${query.id}`, { headers: { 'X-MediaBrowser-Token': server?.credential }, prefixUrl: server?.url, searchParams: parseSearchParams(searchParams), signal, }) .json(); return data; }; // const getAlbumArtistAlbums = () => { // const { data: albumData } = await api.get(`/users/${auth.username}/items`, { // params: { // artistIds: options.id, // fields: 'AudioInfo, ParentId, Genres, DateCreated, ChildCount, ParentId', // includeItemTypes: 'MusicAlbum', // parentId: options.musicFolderId, // recursive: true, // sortBy: 'SortName', // }, // }); // const { data: similarData } = await api.get(`/artists/${options.id}/similar`, { // params: { limit: 15, parentId: options.musicFolderId, userId: auth.username }, // }); // }; const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise => { const { query, server, signal } = args; const searchParams: JFAlbumArtistListParams = { limit: query.limit, parentId: query.musicFolderId, recursive: true, sortBy: albumArtistListSortMap.jellyfin[query.sortBy], sortOrder: sortOrderMap.jellyfin[query.sortOrder], startIndex: query.startIndex, }; const data = await api .get('artists/albumArtists', { headers: { 'X-MediaBrowser-Token': server?.credential }, prefixUrl: server?.url, searchParams: parseSearchParams(searchParams), signal, }) .json(); return data; }; const getArtistList = async (args: ArtistListArgs): Promise => { const { query, server, signal } = args; const searchParams: JFArtistListParams = { limit: query.limit, parentId: query.musicFolderId, recursive: true, sortBy: artistListSortMap.jellyfin[query.sortBy], sortOrder: sortOrderMap.jellyfin[query.sortOrder], startIndex: query.startIndex, }; const data = await api .get('artists', { headers: { 'X-MediaBrowser-Token': server?.credential }, prefixUrl: server?.url, searchParams: parseSearchParams(searchParams), signal, }) .json(); return data; }; const getAlbumDetail = async (args: AlbumDetailArgs): Promise => { const { query, server, signal } = args; const searchParams = { fields: 'Genres, DateCreated, ChildCount', }; const data = await api .get(`users/${server?.userId}/items/${query.id}`, { headers: { 'X-MediaBrowser-Token': server?.credential }, prefixUrl: server?.url, searchParams, signal, }) .json(); const songsSearchParams = { fields: 'Genres, DateCreated, MediaSources, ParentId', parentId: query.id, sortBy: 'SortName', }; const songsData = await api .get(`users/${server?.userId}/items`, { headers: { 'X-MediaBrowser-Token': server?.credential }, prefixUrl: server?.url, searchParams: songsSearchParams, signal, }) .json(); return { ...data, songs: songsData.Items }; }; const getAlbumList = async (args: AlbumListArgs): Promise => { const { query, server, signal } = args; const searchParams: JFAlbumListParams = { includeItemTypes: 'MusicAlbum', limit: query.limit, parentId: query.musicFolderId, recursive: true, sortBy: albumListSortMap.jellyfin[query.sortBy], sortOrder: sortOrderMap.jellyfin[query.sortOrder], startIndex: query.startIndex, }; const data = await api .get(`users/${server?.userId}/items`, { headers: { 'X-MediaBrowser-Token': server?.credential }, prefixUrl: server?.url, searchParams: parseSearchParams(searchParams), signal, }) .json(); return { items: data.Items, startIndex: query.startIndex, totalRecordCount: data.TotalRecordCount, }; }; const getSongList = async (args: SongListArgs): Promise => { const { query, server, signal } = args; const searchParams: JFSongListParams = { fields: 'Genres, DateCreated, MediaSources, ParentId', includeItemTypes: 'Audio', limit: query.limit, parentId: query.musicFolderId, recursive: true, sortBy: songListSortMap.jellyfin[query.sortBy], sortOrder: sortOrderMap.jellyfin[query.sortOrder], startIndex: query.startIndex, ...query.jfParams, }; const data = await api .get(`users/${server?.userId}/items`, { headers: { 'X-MediaBrowser-Token': server?.credential }, prefixUrl: server?.url, searchParams: parseSearchParams(searchParams), signal, }) .json(); return { items: data.Items, startIndex: query.startIndex, totalRecordCount: data.TotalRecordCount, }; }; const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise => { const { query, server, signal } = args; const searchParams = { fields: 'Genres, DateCreated, MediaSources, ChildCount, ParentId', ids: query.id, }; const data = await api .get(`users/${server?.userId}/items/${query.id}`, { headers: { 'X-MediaBrowser-Token': server?.credential }, prefixUrl: server?.url, searchParams, signal, }) .json(); return data; }; const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise => { const { query, server, signal } = args; const searchParams: JFSongListParams = { fields: 'Genres, DateCreated, MediaSources, UserData, ParentId', includeItemTypes: 'Audio', sortOrder: query.sortOrder ? sortOrderMap.jellyfin[query.sortOrder] : undefined, startIndex: 0, }; const data = await api .get(`playlists/${query.id}/items`, { headers: { 'X-MediaBrowser-Token': server?.credential }, prefixUrl: server?.url, searchParams: parseSearchParams(searchParams), signal, }) .json(); return { items: data.Items, startIndex: query.startIndex, totalRecordCount: data.TotalRecordCount, }; }; const getPlaylistList = async (args: PlaylistListArgs): Promise => { const { server, signal } = args; const searchParams = { fields: 'ChildCount, Genres, DateCreated, ParentId, Overview', includeItemTypes: 'Playlist', recursive: true, sortBy: 'SortName', sortOrder: 'Ascending', }; const data = await api .get(`/users/${server?.userId}/items`, { headers: { 'X-MediaBrowser-Token': server?.credential }, prefixUrl: server?.url, searchParams: parseSearchParams(searchParams), signal, }) .json(); const playlistData = data.Items.filter((item) => item.MediaType === 'Audio'); return { Items: playlistData, StartIndex: 0, TotalRecordCount: playlistData.length, }; }; const createPlaylist = async (args: CreatePlaylistArgs): Promise => { const { query, server } = args; const body = { MediaType: 'Audio', Name: query.name, UserId: server?.userId, }; const data = await api .post('playlists', { headers: { 'X-MediaBrowser-Token': server?.credential }, json: body, prefixUrl: server?.url, }) .json(); return { id: data.Id, name: query.name, }; }; const deletePlaylist = async (args: DeletePlaylistArgs): Promise => { const { query, server } = args; await api.delete(`items/${query.id}`, { headers: { 'X-MediaBrowser-Token': server?.credential }, prefixUrl: server?.url, }); return null; }; const createFavorite = async (args: FavoriteArgs): Promise => { const { query, server } = args; await api.post(`users/${server?.userId}/favoriteitems/${query.id}`, { headers: { 'X-MediaBrowser-Token': server?.credential }, prefixUrl: server?.url, }); return { id: query.id, }; }; const deleteFavorite = async (args: FavoriteArgs): Promise => { const { query, server } = args; await api.delete(`users/${server?.userId}/favoriteitems/${query.id}`, { headers: { 'X-MediaBrowser-Token': server?.credential }, prefixUrl: server?.url, }); return { id: query.id, }; }; const getStreamUrl = (args: { container?: string; deviceId: string; eTag?: string; id: string; mediaSourceId?: string; server: ServerListItem; }) => { const { id, server, deviceId } = args; return ( `${server?.url}/audio` + `/${id}/universal` + `?userId=${server.userId}` + `&deviceId=${deviceId}` + '&audioCodec=aac' + `&api_key=${server.credential}` + `&playSessionId=${deviceId}` + '&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg' + '&transcodingContainer=ts' + '&transcodingProtocol=hls' ); }; const getAlbumCoverArtUrl = (args: { baseUrl: string; item: JFAlbum; size: number }) => { const size = args.size ? args.size : 300; if (!args.item.ImageTags?.Primary && !args.item?.AlbumPrimaryImageTag) { return null; } return ( `${args.baseUrl}/Items` + `/${args.item.Id}` + '/Images/Primary' + `?width=${size}&height=${size}` + '&quality=96' ); }; const getSongCoverArtUrl = (args: { baseUrl: string; item: JFSong; size: number }) => { const size = args.size ? args.size : 300; if (!args.item.ImageTags?.Primary) { return null; } if (args.item.ImageTags.Primary) { return ( `${args.baseUrl}/Items` + `/${args.item.Id}` + '/Images/Primary' + `?width=${size}&height=${size}` + '&quality=96' ); } if (!args.item?.AlbumPrimaryImageTag) { return null; } // Fall back to album art if no image embedded return ( `${args.baseUrl}/Items` + `/${args.item?.AlbumId}` + '/Images/Primary' + `?width=${size}&height=${size}` + '&quality=96' ); }; const normalizeAlbum = (item: JFAlbum, server: ServerListItem, imageSize?: number): Album => { return { albumArtists: item.AlbumArtists?.map((entry) => ({ id: entry.Id, name: entry.Name, })) || [], artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, name: entry.Name })), backdropImageUrl: null, createdAt: item.DateCreated, duration: item.RunTimeTicks / 10000000, genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })), id: item.Id, imagePlaceholderUrl: null, imageUrl: getAlbumCoverArtUrl({ baseUrl: server.url, item, size: imageSize || 300, }), isCompilation: null, isFavorite: item.UserData?.IsFavorite || false, name: item.Name, playCount: item.UserData?.PlayCount || 0, rating: null, releaseDate: item.PremiereDate || null, releaseYear: item.ProductionYear, serverType: ServerType.JELLYFIN, size: null, songCount: item?.ChildCount || null, uniqueId: nanoid(), updatedAt: item?.DateLastMediaAdded || item.DateCreated, }; }; const normalizeSong = ( item: JFSong, server: ServerListItem, deviceId: string, imageSize?: number, ): Song => { return { album: item.Album, albumArtists: item.AlbumArtists?.map((entry) => ({ id: entry.Id, name: entry.Name })), albumId: item.AlbumId, artistName: item.ArtistItems[0]?.Name, artists: item.ArtistItems.map((entry) => ({ id: entry.Id, name: entry.Name })), bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)), compilation: null, container: (item.MediaSources && item.MediaSources[0]?.Container) || null, createdAt: item.DateCreated, discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1, duration: item.RunTimeTicks / 10000000, genres: item.GenreItems.map((entry: any) => ({ id: entry.Id, name: entry.Name })), id: item.Id, imageUrl: getSongCoverArtUrl({ baseUrl: server.url, item, size: imageSize || 300 }), isFavorite: (item.UserData && item.UserData.IsFavorite) || false, name: item.Name, path: (item.MediaSources && item.MediaSources[0]?.Path) || null, playCount: (item.UserData && item.UserData.PlayCount) || 0, releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null, releaseYear: (item.ProductionYear && String(item.ProductionYear)) || null, serverId: server.id, size: item.MediaSources && item.MediaSources[0]?.Size, streamUrl: getStreamUrl({ container: item.MediaSources[0]?.Container, deviceId, eTag: item.MediaSources[0]?.ETag, id: item.Id, mediaSourceId: item.MediaSources[0]?.Id, server, }), trackNumber: item.IndexNumber, type: ServerType.JELLYFIN, uniqueId: nanoid(), updatedAt: item.DateCreated, }; }; // const normalizeArtist = (item: any) => { // return { // album: (item.album || []).map((entry: any) => normalizeAlbum(entry)), // albumCount: item.AlbumCount, // duration: item.RunTimeTicks / 10000000, // genre: item.GenreItems && item.GenreItems.map((entry: any) => normalizeItem(entry)), // id: item.Id, // image: getCoverArtUrl(item), // info: { // biography: item.Overview, // externalUrl: (item.ExternalUrls || []).map((entry: any) => normalizeItem(entry)), // imageUrl: undefined, // similarArtist: (item.similarArtist || []).map((entry: any) => normalizeArtist(entry)), // }, // starred: item.UserData && item.UserData?.IsFavorite ? 'true' : undefined, // title: item.Name, // uniqueId: nanoid(), // }; // }; // const normalizePlaylist = (item: any) => { // return { // changed: item.DateLastMediaAdded, // comment: item.Overview, // created: item.DateCreated, // duration: item.RunTimeTicks / 10000000, // genre: item.GenreItems && item.GenreItems.map((entry: any) => normalizeItem(entry)), // id: item.Id, // image: getCoverArtUrl(item, 350), // owner: undefined, // public: undefined, // song: [], // songCount: item.ChildCount, // title: item.Name, // uniqueId: nanoid(), // }; // }; // const normalizeGenre = (item: any) => { // return { // albumCount: undefined, // id: item.Id, // songCount: undefined, // title: item.Name, // type: Item.Genre, // uniqueId: nanoid(), // }; // }; // const normalizeFolder = (item: any) => { // return { // created: item.DateCreated, // id: item.Id, // image: getCoverArtUrl(item, 150), // isDir: true, // title: item.Name, // type: Item.Folder, // uniqueId: nanoid(), // }; // }; // const normalizeScanStatus = () => { // return { // count: 'N/a', // scanning: false, // }; // }; export const jellyfinApi = { authenticate, createFavorite, createPlaylist, deleteFavorite, deletePlaylist, getAlbumArtistDetail, getAlbumArtistList, getAlbumDetail, getAlbumList, getArtistList, getGenreList, getMusicFolderList, getPlaylistDetail, getPlaylistList, getPlaylistSongList, getSongList, }; export const jfNormalize = { album: normalizeAlbum, song: normalizeSong, };