fix: refactor JellyfinApi to be less burdensome to implement
Also, automatically catch errors
This commit is contained in:
@@ -12,7 +12,7 @@ import DownloadIcon from '@/components/DownloadIcon';
|
||||
import styled from 'styled-components/native';
|
||||
import { Text } from '@/components/Typography';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
import { useGetImage } from '@/utility/JellyfinApi';
|
||||
import { useGetImage } from '@/utility/JellyfinApi/lib';
|
||||
import { ShadowWrapper } from '@/components/Shadow';
|
||||
import { SafeFlatList } from '@/components/SafeNavigatorView';
|
||||
import { t } from '@/localisation';
|
||||
|
||||
@@ -9,7 +9,7 @@ import { t } from '@/localisation';
|
||||
import { NavigationProp, StackParams } from '@/screens/types';
|
||||
import { SubHeader, Text } from '@/components/Typography';
|
||||
import { ScrollView } from 'react-native-gesture-handler';
|
||||
import { useGetImage } from '@/utility/JellyfinApi';
|
||||
import { useGetImage } from '@/utility/JellyfinApi/lib';
|
||||
import styled from 'styled-components';
|
||||
import { Dimensions, Pressable } from 'react-native';
|
||||
import AlbumImage from './components/AlbumImage';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useRef, ReactText, useMemo } from 'react';
|
||||
import { useGetImage } from '@/utility/JellyfinApi';
|
||||
import { useGetImage } from '@/utility/JellyfinApi/lib';
|
||||
import { SectionList, View } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, ReactText } from 'react';
|
||||
import { useGetImage } from '@/utility/JellyfinApi';
|
||||
import { useGetImage } from '@/utility/JellyfinApi/lib';
|
||||
import { View } from 'react-native';
|
||||
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import { useGetImage } from '@/utility/JellyfinApi';
|
||||
import { useGetImage } from '@/utility/JellyfinApi/lib';
|
||||
import { SectionList, View } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useRef, ReactText } from 'react';
|
||||
import { useGetImage } from '@/utility/JellyfinApi';
|
||||
import { useGetImage } from '@/utility/JellyfinApi/lib';
|
||||
import { Text, View, FlatList, ListRenderItem, RefreshControl } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useGetImage } from '@/utility/JellyfinApi';
|
||||
import { useGetImage } from '@/utility/JellyfinApi/lib';
|
||||
import { Text, SafeAreaView, StyleSheet } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useAppDispatch, useTypedSelector } from '@/store';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { PropsWithChildren, useCallback, useMemo } from 'react';
|
||||
import { Platform, RefreshControl, StyleSheet, View } from 'react-native';
|
||||
import { useGetImage } from '@/utility/JellyfinApi';
|
||||
import { useGetImage } from '@/utility/JellyfinApi/lib';
|
||||
import styled, { css } from 'styled-components/native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useAppDispatch, useTypedSelector } from '@/store';
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Album, AlbumTrack } from '@/store/music/types';
|
||||
import { FlatList } from 'react-native-gesture-handler';
|
||||
import TouchableHandler from '@/components/TouchableHandler';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useGetImage } from '@/utility/JellyfinApi';
|
||||
import { useGetImage } from '@/utility/JellyfinApi/lib';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
import { t } from '@/localisation';
|
||||
import useDefaultStyles, { ColoredBlurView } from '@/components/Colors';
|
||||
|
||||
@@ -15,7 +15,7 @@ import CoverImage from '@/components/CoverImage';
|
||||
import { queueTrackForDownload, removeDownloadedTrack } from '@/store/downloads/actions';
|
||||
import usePlayTracks from '@/utility/usePlayTracks';
|
||||
import { selectIsDownloaded } from '@/store/downloads/selectors';
|
||||
import { useGetImage } from '@/utility/JellyfinApi';
|
||||
import { useGetImage } from '@/utility/JellyfinApi/lib';
|
||||
|
||||
type Route = RouteProp<StackParams, 'TrackPopupMenu'>;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createAction, createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit';
|
||||
import { AppState } from '@/store';
|
||||
import { generateTrackUrl } from '@/utility/JellyfinApi';
|
||||
import { downloadFile, unlink, DocumentDirectoryPath, exists } from 'react-native-fs';
|
||||
import { DownloadEntity } from './types';
|
||||
import MimeTypes from '@/utility/MimeTypes';
|
||||
import { generateTrackUrl } from '@/utility/JellyfinApi/track';
|
||||
|
||||
export const downloadAdapter = createEntityAdapter<DownloadEntity>();
|
||||
|
||||
@@ -15,12 +15,9 @@ export const failDownload = createAction<{ id: string }>('download/fail');
|
||||
|
||||
export const downloadTrack = createAsyncThunk(
|
||||
'/downloads/track',
|
||||
async (id: string, { dispatch, getState }) => {
|
||||
// Get the credentials from the store
|
||||
const { settings: { jellyfin: credentials } } = (getState() as AppState);
|
||||
|
||||
async (id: string, { dispatch }) => {
|
||||
// Generate the URL we can use to download the file
|
||||
const url = generateTrackUrl(id as string, credentials);
|
||||
const url = generateTrackUrl(id);
|
||||
|
||||
// Get the content-type from the URL by doing a HEAD-only request
|
||||
const contentType = (await fetch(url, { method: 'HEAD' })).headers.get('Content-Type');
|
||||
|
||||
@@ -87,6 +87,7 @@ const store = configureStore({
|
||||
export type AppState = ReturnType<typeof reducers> & { _persist: PersistState };
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export type AsyncThunkAPI = { state: AppState, dispatch: AppDispatch };
|
||||
export type Store = typeof store;
|
||||
export const useTypedSelector: TypedUseSelectorHook<AppState> = useSelector;
|
||||
export const useAppDispatch: () => AppDispatch = useDispatch;
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit';
|
||||
import { Album, AlbumTrack, Playlist } from './types';
|
||||
import { AsyncThunkAPI } from '..';
|
||||
import { retrieveAllAlbums, retrieveAlbumTracks, retrieveRecentAlbums, searchItem, retrieveAlbum, retrieveAllPlaylists, retrievePlaylistTracks } from '@/utility/JellyfinApi';
|
||||
import { retrieveAllAlbums, retrieveRecentAlbums, retrieveAlbumTracks, retrieveAlbum } from '@/utility/JellyfinApi/album';
|
||||
import { retrieveAllPlaylists, retrievePlaylistTracks } from '@/utility/JellyfinApi/playlist';
|
||||
import { searchItem } from '@/utility/JellyfinApi/search';
|
||||
|
||||
export const albumAdapter = createEntityAdapter<Album, string>({
|
||||
selectId: album => album.Id,
|
||||
@@ -13,10 +15,7 @@ export const albumAdapter = createEntityAdapter<Album, string>({
|
||||
*/
|
||||
export const fetchAllAlbums = createAsyncThunk<Album[], undefined, AsyncThunkAPI>(
|
||||
'/albums/all',
|
||||
async (empty, thunkAPI) => {
|
||||
const credentials = thunkAPI.getState().settings.jellyfin;
|
||||
return retrieveAllAlbums(credentials) as Promise<Album[]>;
|
||||
}
|
||||
retrieveAllAlbums,
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -24,10 +23,7 @@ export const fetchAllAlbums = createAsyncThunk<Album[], undefined, AsyncThunkAPI
|
||||
*/
|
||||
export const fetchRecentAlbums = createAsyncThunk<Album[], number | undefined, AsyncThunkAPI>(
|
||||
'/albums/recent',
|
||||
async (numberOfAlbums, thunkAPI) => {
|
||||
const credentials = thunkAPI.getState().settings.jellyfin;
|
||||
return retrieveRecentAlbums(credentials, numberOfAlbums) as Promise<Album[]>;
|
||||
}
|
||||
retrieveRecentAlbums,
|
||||
);
|
||||
|
||||
export const trackAdapter = createEntityAdapter<AlbumTrack, string>({
|
||||
@@ -40,18 +36,12 @@ export const trackAdapter = createEntityAdapter<AlbumTrack, string>({
|
||||
*/
|
||||
export const fetchTracksByAlbum = createAsyncThunk<AlbumTrack[], string, AsyncThunkAPI>(
|
||||
'/tracks/byAlbum',
|
||||
async (ItemId, thunkAPI) => {
|
||||
const credentials = thunkAPI.getState().settings.jellyfin;
|
||||
return retrieveAlbumTracks(ItemId, credentials) as Promise<AlbumTrack[]>;
|
||||
}
|
||||
retrieveAlbumTracks,
|
||||
);
|
||||
|
||||
export const fetchAlbum = createAsyncThunk<Album, string, AsyncThunkAPI>(
|
||||
'/albums/single',
|
||||
async (ItemId, thunkAPI) => {
|
||||
const credentials = thunkAPI.getState().settings.jellyfin;
|
||||
return retrieveAlbum(credentials, ItemId) as Promise<Album>;
|
||||
}
|
||||
retrieveAlbum,
|
||||
);
|
||||
|
||||
type SearchAndFetchResults = {
|
||||
@@ -67,7 +57,7 @@ AsyncThunkAPI
|
||||
'/search',
|
||||
async ({ term, limit = 24 }, thunkAPI) => {
|
||||
const state = thunkAPI.getState();
|
||||
const results = await searchItem(state.settings.jellyfin, term, limit);
|
||||
const results = await searchItem(term, limit);
|
||||
|
||||
const albums = await Promise.all(results.filter((item) => (
|
||||
!state.music.albums.ids.includes(item.Type === 'MusicAlbum' ? item.Id : item.AlbumId)
|
||||
@@ -77,7 +67,7 @@ AsyncThunkAPI
|
||||
return item;
|
||||
}
|
||||
|
||||
return retrieveAlbum(state.settings.jellyfin, item.AlbumId);
|
||||
return retrieveAlbum(item.AlbumId);
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -97,10 +87,7 @@ export const playlistAdapter = createEntityAdapter<Playlist, string>({
|
||||
*/
|
||||
export const fetchAllPlaylists = createAsyncThunk<Playlist[], undefined, AsyncThunkAPI>(
|
||||
'/playlists/all',
|
||||
async (empty, thunkAPI) => {
|
||||
const credentials = thunkAPI.getState().settings.jellyfin;
|
||||
return retrieveAllPlaylists(credentials) as Promise<Playlist[]>;
|
||||
}
|
||||
retrieveAllPlaylists,
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -108,8 +95,5 @@ export const fetchAllPlaylists = createAsyncThunk<Playlist[], undefined, AsyncTh
|
||||
*/
|
||||
export const fetchTracksByPlaylist = createAsyncThunk<AlbumTrack[], string, AsyncThunkAPI>(
|
||||
'/tracks/byPlaylist',
|
||||
async (ItemId, thunkAPI) => {
|
||||
const credentials = thunkAPI.getState().settings.jellyfin;
|
||||
return retrievePlaylistTracks(ItemId, credentials) as Promise<AlbumTrack[]>;
|
||||
}
|
||||
retrievePlaylistTracks,
|
||||
);
|
||||
@@ -1,355 +0,0 @@
|
||||
import TrackPlayer, { RepeatMode, State, Track } from 'react-native-track-player';
|
||||
import { AppState, useTypedSelector } from '@/store';
|
||||
import { Album, AlbumTrack, SimilarAlbum } from '@/store/music/types';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
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 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 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 => {
|
||||
if (response.ok) return response.json();
|
||||
throw response;
|
||||
});
|
||||
|
||||
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 => {
|
||||
if (response.ok) {
|
||||
return response.json() as Promise<{ Items: SimilarAlbum[] }>;
|
||||
}
|
||||
|
||||
throw response;
|
||||
}).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 => {
|
||||
if (response.ok) return response.json();
|
||||
throw response;
|
||||
});
|
||||
|
||||
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 => {
|
||||
if (response.ok) return response.json();
|
||||
throw response;
|
||||
});
|
||||
|
||||
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 => {
|
||||
if (response.ok) return response.json();
|
||||
throw response;
|
||||
});
|
||||
|
||||
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 => {
|
||||
if (response.ok) {
|
||||
return response.json() as Promise<{ Items: (Album | AlbumTrack)[] }>;
|
||||
}
|
||||
|
||||
throw response;
|
||||
});
|
||||
|
||||
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)
|
||||
));
|
||||
}
|
||||
|
||||
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 => {
|
||||
if (response.ok) return response.json();
|
||||
throw response;
|
||||
});
|
||||
|
||||
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 => {
|
||||
if (response.ok) return response.json();
|
||||
throw response;
|
||||
});
|
||||
|
||||
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,
|
||||
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
|
||||
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);
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -9,8 +9,8 @@
|
||||
|
||||
import TrackPlayer, { Event, State } from 'react-native-track-player';
|
||||
import store from '@/store';
|
||||
import { sendPlaybackEvent } from './JellyfinApi';
|
||||
import { setTimerDate } from '@/store/sleep-timer';
|
||||
import { sendPlaybackEvent } from './JellyfinApi/playback';
|
||||
|
||||
export default async function() {
|
||||
TrackPlayer.addEventListener(Event.RemotePlay, () => {
|
||||
@@ -45,10 +45,10 @@ export default async function() {
|
||||
if (settings.enablePlaybackReporting && 'track' in e) {
|
||||
// GUARD: End the previous track if it's about to end
|
||||
if (e.lastTrack) {
|
||||
await sendPlaybackEvent('/Sessions/Playing/Stopped', settings.jellyfin, e.lastTrack);
|
||||
await sendPlaybackEvent('/Sessions/Playing/Stopped', e.lastTrack);
|
||||
}
|
||||
|
||||
await sendPlaybackEvent('/Sessions/Playing', settings.jellyfin, e.track);
|
||||
await sendPlaybackEvent('/Sessions/Playing', e.track);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ export default async function() {
|
||||
|
||||
// GUARD: Only report playback when the settings is enabled
|
||||
if (settings.enablePlaybackReporting) {
|
||||
sendPlaybackEvent('/Sessions/Playing/Progress', settings.jellyfin);
|
||||
sendPlaybackEvent('/Sessions/Playing/Progress');
|
||||
}
|
||||
|
||||
// check if timerDate is undefined, otherwise start timer
|
||||
@@ -76,9 +76,9 @@ export default async function() {
|
||||
if (settings.enablePlaybackReporting) {
|
||||
// GUARD: Only respond to stopped events
|
||||
if (event.state === State.Stopped) {
|
||||
sendPlaybackEvent('/Sessions/Playing/Stopped', settings.jellyfin);
|
||||
sendPlaybackEvent('/Sessions/Playing/Stopped');
|
||||
} else if (event.state === State.Paused) {
|
||||
sendPlaybackEvent('/Sessions/Playing/Progress', settings.jellyfin);
|
||||
sendPlaybackEvent('/Sessions/Playing/Progress');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useTypedSelector } from '@/store';
|
||||
import { useCallback } from 'react';
|
||||
import TrackPlayer, { Track } from 'react-native-track-player';
|
||||
import { generateTrack } from './JellyfinApi';
|
||||
import { shuffle as shuffleArray } from 'lodash';
|
||||
import { generateTrack } from './JellyfinApi/track';
|
||||
|
||||
interface PlayOptions {
|
||||
play: boolean;
|
||||
@@ -21,7 +21,6 @@ const defaults: PlayOptions = {
|
||||
* supplied id.
|
||||
*/
|
||||
export default function usePlayTracks() {
|
||||
const credentials = useTypedSelector(state => state.settings.jellyfin);
|
||||
const tracks = useTypedSelector(state => state.music.tracks.entities);
|
||||
const downloads = useTypedSelector(state => state.downloads.entities);
|
||||
|
||||
@@ -51,7 +50,7 @@ export default function usePlayTracks() {
|
||||
}
|
||||
|
||||
// Retrieve the generated track from Jellyfin
|
||||
const generatedTrack = generateTrack(track, credentials);
|
||||
const generatedTrack = generateTrack(track);
|
||||
|
||||
// Check if a downloaded version exists, and if so rewrite the URL
|
||||
const download = downloads[trackId];
|
||||
@@ -114,5 +113,5 @@ export default function usePlayTracks() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [credentials, downloads, tracks]);
|
||||
}, [downloads, tracks]);
|
||||
}
|
||||
Reference in New Issue
Block a user