Files
jellyfin-audio-player/src/utility/JellyfinApi.ts
2023-06-18 22:04:20 +02:00

309 lines
9.8 KiB
TypeScript

import TrackPlayer, { RepeatMode, State, Track } from 'react-native-track-player';
import { AppState, useTypedSelector } from 'store';
import { Album, AlbumTrack, SimilarAlbum } from 'store/music/types';
type Credentials = AppState['settings']['jellyfin'];
/**
* This is a convenience function that converts a set of Jellyfin credentials
* from the Redux store to a HTTP Header that authenticates the user against the
* Jellyfin server.
*/
function generateConfig(credentials: Credentials): RequestInit {
return {
headers: {
'X-Emby-Authorization': `MediaBrowser Client="", Device="", DeviceId="", Version="", Token="${credentials?.access_token}"`
}
};
}
const baseTrackOptions: Record<string, string> = {
// This must be set to support client seeking
TranscodingProtocol: 'http',
TranscodingContainer: 'aac',
Container: 'mp3,aac,m4a,m4b|aac,flac,alac,m4a,m4b|alac',
static: 'true',
};
/**
* Generate a track object from a Jellyfin ItemId so that
* react-native-track-player can easily consume it.
*/
export function generateTrack(track: AlbumTrack, credentials: Credentials): Track {
// Also construct the URL for the stream
const url = generateTrackUrl(track.Id, credentials);
return {
url,
backendId: track.Id,
title: track.Name,
artist: track.Artists.join(', '),
album: track.Album,
duration: track.RunTimeTicks,
artwork: track.AlbumId
? getImage(track.AlbumId, credentials)
: getImage(track.Id, credentials),
};
}
/**
* Generate the track streaming url from the trackId
*/
export function generateTrackUrl(trackId: string, credentials: Credentials) {
const trackOptions = {
...baseTrackOptions,
UserId: credentials?.user_id || '',
api_key: credentials?.access_token || '',
DeviceId: credentials?.device_id || '',
};
const trackParams = new URLSearchParams(trackOptions).toString();
const url = encodeURI(`${credentials?.uri}/Audio/${trackId}/universal?`) + trackParams;
return url;
}
const albumOptions = {
SortBy: 'AlbumArtist,SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'MusicAlbum',
Recursive: 'true',
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated',
ImageTypeLimit: '1',
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb',
};
const albumParams = new URLSearchParams(albumOptions).toString();
/**
* Retrieve all albums that are available on the Jellyfin server
*/
export async function retrieveAllAlbums(credentials: Credentials) {
const config = generateConfig(credentials);
const albums = await fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items?${albumParams}`, config)
.then(response => response.json());
return albums.Items;
}
/**
* Retrieve a single album
*/
export async function retrieveAlbum(credentials: Credentials, id: string): Promise<Album> {
const config = generateConfig(credentials);
const Similar = await fetch(`${credentials?.uri}/Items/${id}/Similar?userId=${credentials?.user_id}&limit=12`, config)
.then(response => response.json() as Promise<{ Items: SimilarAlbum[] }>)
.then((albums) => albums.Items.map((a) => a.Id));
return fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items/${id}`, config)
.then(response => response.json() as Promise<Album>)
.then(album => ({ ...album, Similar }));
}
const latestAlbumsOptions = {
IncludeItemTypes: 'MusicAlbum',
Fields: 'DateCreated',
SortOrder: 'Ascending',
};
/**
* Retrieve the most recently added albums on the Jellyfin server
*/
export async function retrieveRecentAlbums(credentials: Credentials, numberOfAlbums = 24) {
const config = generateConfig(credentials);
// Generate custom config based on function input
const options = {
...latestAlbumsOptions,
Limit: numberOfAlbums.toString(),
};
const params = new URLSearchParams(options).toString();
// Retrieve albums
const albums = await fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items/Latest?${params}`, config)
.then(response => response.json());
return albums;
}
/**
* Retrieve a single album from the Emby server
*/
export async function retrieveAlbumTracks(ItemId: string, credentials: Credentials) {
const singleAlbumOptions = {
ParentId: ItemId,
SortBy: 'SortName',
};
const singleAlbumParams = new URLSearchParams(singleAlbumOptions).toString();
const config = generateConfig(credentials);
const album = await fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items?${singleAlbumParams}`, config)
.then(response => response.json());
return album.Items;
}
/**
* Retrieve an image URL for a given ItemId
*/
export function getImage(ItemId: string, credentials: Credentials): string {
return encodeURI(`${credentials?.uri}/Items/${ItemId}/Images/Primary?format=jpeg`);
}
/**
* Create a hook that can convert ItemIds to image URLs
*/
export function useGetImage() {
const credentials = useTypedSelector((state) => state.settings.jellyfin);
return (ItemId: string) => getImage(ItemId, credentials);
}
const trackParams = {
SortBy: 'AlbumArtist,SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'Audio',
Recursive: 'true',
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated',
};
/**
* Retrieve all possible tracks that can be found in Jellyfin
*/
export async function retrieveAllTracks(credentials: Credentials) {
const config = generateConfig(credentials);
const tracks = await fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items?${trackParams}`, config)
.then(response => response.json());
return tracks.Items;
}
const searchParams = {
IncludeItemTypes: 'Audio,MusicAlbum',
SortBy: 'Album,SortName',
SortOrder: 'Ascending',
Recursive: 'true',
};
/**
* Remotely search the Jellyfin library for a particular search term
*/
export async function searchItem(
credentials: Credentials,
term: string, limit = 24
): Promise<(Album | AlbumTrack)[]> {
const config = generateConfig(credentials);
const params = new URLSearchParams({
...searchParams,
SearchTerm: term,
Limit: limit.toString(),
}).toString();
const results = await fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items?${params}`, config)
.then(response => response.json());
return results.Items;
}
const playlistOptions = {
SortBy: 'SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'Playlist',
Recursive: 'true',
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated',
ImageTypeLimit: '1',
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb',
MediaTypes: 'Audio',
};
/**
* Retrieve all albums that are available on the Jellyfin server
*/
export async function retrieveAllPlaylists(credentials: Credentials) {
const config = generateConfig(credentials);
const playlistParams = new URLSearchParams(playlistOptions).toString();
const albums = await fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items?${playlistParams}`, config)
.then(response => response.json());
return albums.Items;
}
/**
* Retrieve all albums that are available on the Jellyfin server
*/
export async function retrievePlaylistTracks(ItemId: string, credentials: Credentials) {
const singlePlaylistOptions = {
SortBy: 'SortName',
UserId: credentials?.user_id || '',
};
const singlePlaylistParams = new URLSearchParams(singlePlaylistOptions).toString();
const config = generateConfig(credentials);
const playlists = await fetch(`${credentials?.uri}/Playlists/${ItemId}/Items?${singlePlaylistParams}`, config)
.then(response => response.json());
return playlists.Items;
}
/**
* This maps the react-native-track-player RepeatMode to a RepeatMode that is
* expected by Jellyfin when reporting playback events.
*/
const RepeatModeMap: Record<RepeatMode, string> = {
[RepeatMode.Off]: 'RepeatNone',
[RepeatMode.Track]: 'RepeatOne',
[RepeatMode.Queue]: 'RepeatAll',
};
/**
* This will generate the payload that is required for playback events and send
* it to the supplied path.
*/
export async function sendPlaybackEvent(path: string, credentials: Credentials, trackIndex?: number) {
// Extract all data from react-native-track-player
const [
currentTrack, position, repeatMode, volume, queue, state,
] = await Promise.all([
TrackPlayer.getCurrentTrack(),
TrackPlayer.getPosition(),
TrackPlayer.getRepeatMode(),
TrackPlayer.getVolume(),
TrackPlayer.getQueue(),
TrackPlayer.getState(),
]);
// Switch between overriden track index and current track
const track = trackIndex !== undefined ? trackIndex : currentTrack;
// Generate a payload from the gathered data
const payload = {
VolumeLevel: volume * 100,
IsMuted: false,
IsPaused: state === State.Paused,
RepeatMode: RepeatModeMap[repeatMode],
ShuffleMode: 'Sorted',
PositionTicks: position * 1_000_000,
PlaybackRate: 1,
PlayMethod: 'transcode',
MediaSourceId: track !== null ? queue[track].backendId : null,
ItemId: track !== null ? queue[track].backendId : null,
CanSeek: true,
PlaybackStartTimeTicks: null,
};
// Generate a config from the credentials and dispatch the request
const config = generateConfig(credentials);
await fetch(`${credentials?.uri}${path}`, {
method: 'POST',
headers: {
...config.headers,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload),
// Swallow and errors from the request
}).catch((err) => {
console.error(err);
});
}