fix: refactor JellyfinApi to be less burdensome to implement
Also, automatically catch errors
This commit is contained in:
68
src/utility/JellyfinApi/album.ts
Normal file
68
src/utility/JellyfinApi/album.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Album, AlbumTrack, SimilarAlbum } from '@/store/music/types';
|
||||
import { fetchApi } from './lib';
|
||||
|
||||
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() {
|
||||
return fetchApi<{ Items: Album[] }>(({ user_id }) => `/Users/${user_id}/Items?${albumParams}`)
|
||||
.then((data) => data.Items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a single album
|
||||
*/
|
||||
export async function retrieveAlbum(id: string): Promise<Album> {
|
||||
const Similar = await fetchApi<{ Items: SimilarAlbum[] }>(({ user_id }) => `/Items/${id}/Similar?userId=${user_id}&limit=12`)
|
||||
.then((albums) => albums.Items.map((a) => a.Id));
|
||||
|
||||
return fetchApi<Album>(({ user_id }) => `/Users/${user_id}/Items/${id}`)
|
||||
.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(numberOfAlbums = 24) {
|
||||
// Generate custom config based on function input
|
||||
const options = {
|
||||
...latestAlbumsOptions,
|
||||
Limit: numberOfAlbums.toString(),
|
||||
};
|
||||
const params = new URLSearchParams(options).toString();
|
||||
|
||||
// Retrieve albums
|
||||
return fetchApi<Album[]>(({ user_id }) => `/Users/${user_id}/Items/Latest?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a single album from the Emby server
|
||||
*/
|
||||
export async function retrieveAlbumTracks(ItemId: string) {
|
||||
const singleAlbumOptions = {
|
||||
ParentId: ItemId,
|
||||
SortBy: 'SortName',
|
||||
};
|
||||
const singleAlbumParams = new URLSearchParams(singleAlbumOptions).toString();
|
||||
|
||||
return fetchApi<{ Items: AlbumTrack[] }>(({ user_id }) => `/Users/${user_id}/Items?${singleAlbumParams}`)
|
||||
.then((data) => data.Items);
|
||||
}
|
||||
87
src/utility/JellyfinApi/lib.ts
Normal file
87
src/utility/JellyfinApi/lib.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { AppState, Store } from '@/store';
|
||||
|
||||
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}"`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function asyncFetchStore() {
|
||||
return require('@/store').default as Store;
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenience function that accepts a request for fetch, injects it with the
|
||||
* proper Jellyfin credentials and attempts to catch any errors along the way.
|
||||
*/
|
||||
export async function fetchApi<T>(
|
||||
path: string | ((credentials: NonNullable<Credentials>) => string),
|
||||
config?: RequestInit
|
||||
) {
|
||||
// Retrieve the latest credentials from the Redux store
|
||||
const credentials = asyncFetchStore().getState().settings.jellyfin;
|
||||
|
||||
// GUARD: Check that the credentials are present
|
||||
if (!credentials) {
|
||||
throw new Error('Missing Jellyfin credentials when attempting API request');
|
||||
}
|
||||
|
||||
// Create the URL from the path and the credentials
|
||||
const resolvedPath = typeof path === 'function' ? path(credentials) : path;
|
||||
const url = `${credentials.uri}${resolvedPath.startsWith('/') ? '' : '/'}${resolvedPath}`;
|
||||
|
||||
// Actually perform the request
|
||||
const response = await fetch(url, {
|
||||
...config,
|
||||
headers: {
|
||||
...config?.headers,
|
||||
...generateConfig(credentials).headers,
|
||||
}
|
||||
});
|
||||
|
||||
// GUARD: Check if the response is as expected
|
||||
if (!response.ok) {
|
||||
if (response.status === 403 || response.status === 401) {
|
||||
throw new Error('AuthenticationFailed');
|
||||
} else if (response.status === 404) {
|
||||
throw new Error('ResourceNotFound');
|
||||
}
|
||||
|
||||
// Attempt to parse the error message
|
||||
try {
|
||||
const data = await response.json();
|
||||
throw data;
|
||||
} catch {
|
||||
throw response;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse body as JSON
|
||||
const data = await response.json() as Promise<T>;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve an image URL for a given ItemId
|
||||
*/
|
||||
export function getImage(ItemId: string): string {
|
||||
const credentials = asyncFetchStore().getState().settings.jellyfin;
|
||||
return encodeURI(`${credentials?.uri}/Items/${ItemId}/Images/Primary?format=jpeg`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a hook that can convert ItemIds to image URLs
|
||||
*/
|
||||
export function useGetImage() {
|
||||
return (ItemId: string) => getImage(ItemId);
|
||||
}
|
||||
60
src/utility/JellyfinApi/playback.ts
Normal file
60
src/utility/JellyfinApi/playback.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import TrackPlayer, { RepeatMode, State, Track } from 'react-native-track-player';
|
||||
import { fetchApi } from './lib';
|
||||
|
||||
/**
|
||||
* 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,
|
||||
track?: Track
|
||||
) {
|
||||
// Extract all data from react-native-track-player
|
||||
const [
|
||||
activeTrack, { position }, repeatMode, volume, { state },
|
||||
] = await Promise.all([
|
||||
track || TrackPlayer.getActiveTrack(),
|
||||
TrackPlayer.getProgress(),
|
||||
TrackPlayer.getRepeatMode(),
|
||||
TrackPlayer.getVolume(),
|
||||
TrackPlayer.getPlaybackState(),
|
||||
]);
|
||||
|
||||
// Generate a payload from the gathered data
|
||||
const payload = {
|
||||
VolumeLevel: volume * 100,
|
||||
IsMuted: false,
|
||||
IsPaused: state === State.Paused,
|
||||
RepeatMode: RepeatModeMap[repeatMode],
|
||||
ShuffleMode: 'Sorted',
|
||||
PositionTicks: Math.round(position * 10_000_000),
|
||||
PlaybackRate: 1,
|
||||
PlayMethod: 'transcode',
|
||||
MediaSourceId: activeTrack?.backendId || null,
|
||||
ItemId: activeTrack?.backendId || null,
|
||||
CanSeek: true,
|
||||
PlaybackStartTimeTicks: null,
|
||||
};
|
||||
|
||||
// Generate a config from the credentials and dispatch the request
|
||||
await fetchApi(path, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
// Swallow and errors from the request
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
38
src/utility/JellyfinApi/playlist.ts
Normal file
38
src/utility/JellyfinApi/playlist.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { AlbumTrack, Playlist } from '@/store/music/types';
|
||||
import { asyncFetchStore, fetchApi } from './lib';
|
||||
|
||||
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() {
|
||||
const playlistParams = new URLSearchParams(playlistOptions).toString();
|
||||
|
||||
return fetchApi<{ Items: Playlist[] }>(({ user_id }) => `/Users/${user_id}/Items?${playlistParams}`)
|
||||
.then((d) => d.Items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all albums that are available on the Jellyfin server
|
||||
*/
|
||||
export async function retrievePlaylistTracks(ItemId: string) {
|
||||
const credentials = asyncFetchStore().getState().settings.jellyfin;
|
||||
const singlePlaylistOptions = {
|
||||
SortBy: 'SortName',
|
||||
UserId: credentials?.user_id || '',
|
||||
};
|
||||
const singlePlaylistParams = new URLSearchParams(singlePlaylistOptions).toString();
|
||||
|
||||
return fetchApi<{ Items: AlbumTrack[] }>(`/Playlists/${ItemId}/Items?${singlePlaylistParams}`)
|
||||
.then((d) => d.Items);
|
||||
}
|
||||
30
src/utility/JellyfinApi/search.ts
Normal file
30
src/utility/JellyfinApi/search.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Album, AlbumTrack } from '@/store/music/types';
|
||||
import { fetchApi } from './lib';
|
||||
|
||||
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(
|
||||
term: string, limit = 24
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
...searchParams,
|
||||
SearchTerm: term,
|
||||
Limit: limit.toString(),
|
||||
}).toString();
|
||||
|
||||
const results = await fetchApi<{ Items: (Album | AlbumTrack)[]}>(({ user_id }) => `/Users/${user_id}/Items?${params}`);
|
||||
|
||||
return results.Items
|
||||
.filter((item) => (
|
||||
// GUARD: Ensure that we're either dealing with an album or a track from an album.
|
||||
item.Type === 'MusicAlbum' || (item.Type === 'Audio' && item.AlbumId)
|
||||
));
|
||||
}
|
||||
81
src/utility/JellyfinApi/track.ts
Normal file
81
src/utility/JellyfinApi/track.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { AlbumTrack } from '@/store/music/types';
|
||||
import { Platform } from 'react-native';
|
||||
import { Track } from 'react-native-track-player';
|
||||
import { fetchApi, getImage } from './lib';
|
||||
import store from '@/store';
|
||||
|
||||
const trackOptionsOsOverrides: Record<typeof Platform.OS, Record<string, string>> = {
|
||||
ios: {
|
||||
Container: 'mp3,aac,m4a|aac,m4b|aac,flac,alac,m4a|alac,m4b|alac,wav,m4a,aiff,aif',
|
||||
},
|
||||
android: {
|
||||
Container: 'mp3,aac,flac,wav,ogg,ogg|vorbis,ogg|opus,mka|mp3,mka|opus,mka|mp3',
|
||||
},
|
||||
macos: {},
|
||||
web: {},
|
||||
windows: {},
|
||||
};
|
||||
|
||||
const baseTrackOptions: Record<string, string> = {
|
||||
TranscodingProtocol: 'http',
|
||||
TranscodingContainer: 'aac',
|
||||
AudioCodec: 'aac',
|
||||
Container: 'mp3,aac',
|
||||
...trackOptionsOsOverrides[Platform.OS],
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate the track streaming url from the trackId
|
||||
*/
|
||||
export function generateTrackUrl(trackId: string) {
|
||||
const credentials = store.getState().settings.jellyfin;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a track object from a Jellyfin ItemId so that
|
||||
* react-native-track-player can easily consume it.
|
||||
*/
|
||||
export function generateTrack(track: AlbumTrack): Track {
|
||||
// Also construct the URL for the stream
|
||||
const url = generateTrackUrl(track.Id);
|
||||
|
||||
return {
|
||||
url,
|
||||
backendId: track.Id,
|
||||
title: track.Name,
|
||||
artist: track.Artists.join(', '),
|
||||
album: track.Album,
|
||||
duration: track.RunTimeTicks,
|
||||
artwork: track.AlbumId
|
||||
? getImage(track.AlbumId)
|
||||
: getImage(track.Id),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
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() {
|
||||
return fetchApi<{ Items: AlbumTrack[] }>(({ user_id }) => `/Users/${user_id}/Items?${trackParams}`)
|
||||
.then((d) => d.Items);
|
||||
}
|
||||
Reference in New Issue
Block a user