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