diff --git a/src/screens/Downloads/index.tsx b/src/screens/Downloads/index.tsx index 6b5118b..1e74e02 100644 --- a/src/screens/Downloads/index.tsx +++ b/src/screens/Downloads/index.tsx @@ -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'; diff --git a/src/screens/Music/stacks/Album.tsx b/src/screens/Music/stacks/Album.tsx index c917583..66d2d1e 100644 --- a/src/screens/Music/stacks/Album.tsx +++ b/src/screens/Music/stacks/Album.tsx @@ -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'; diff --git a/src/screens/Music/stacks/Albums.tsx b/src/screens/Music/stacks/Albums.tsx index b54d448..277b6b0 100644 --- a/src/screens/Music/stacks/Albums.tsx +++ b/src/screens/Music/stacks/Albums.tsx @@ -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'; diff --git a/src/screens/Music/stacks/Artist.tsx b/src/screens/Music/stacks/Artist.tsx index 98182eb..14e436a 100644 --- a/src/screens/Music/stacks/Artist.tsx +++ b/src/screens/Music/stacks/Artist.tsx @@ -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'; diff --git a/src/screens/Music/stacks/Artists.tsx b/src/screens/Music/stacks/Artists.tsx index 7b4573c..fdbda37 100644 --- a/src/screens/Music/stacks/Artists.tsx +++ b/src/screens/Music/stacks/Artists.tsx @@ -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'; diff --git a/src/screens/Music/stacks/Playlists.tsx b/src/screens/Music/stacks/Playlists.tsx index 8a533c5..4171177 100644 --- a/src/screens/Music/stacks/Playlists.tsx +++ b/src/screens/Music/stacks/Playlists.tsx @@ -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'; diff --git a/src/screens/Music/stacks/RecentAlbums.tsx b/src/screens/Music/stacks/RecentAlbums.tsx index a41f702..fc1c6db 100644 --- a/src/screens/Music/stacks/RecentAlbums.tsx +++ b/src/screens/Music/stacks/RecentAlbums.tsx @@ -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'; diff --git a/src/screens/Music/stacks/components/TrackListView.tsx b/src/screens/Music/stacks/components/TrackListView.tsx index a9e5363..48d4cea 100644 --- a/src/screens/Music/stacks/components/TrackListView.tsx +++ b/src/screens/Music/stacks/components/TrackListView.tsx @@ -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'; diff --git a/src/screens/Search/stacks/Search/index.tsx b/src/screens/Search/stacks/Search/index.tsx index 7486811..6074e64 100644 --- a/src/screens/Search/stacks/Search/index.tsx +++ b/src/screens/Search/stacks/Search/index.tsx @@ -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'; diff --git a/src/screens/modals/TrackPopupMenu.tsx b/src/screens/modals/TrackPopupMenu.tsx index 4a78045..b5a0e12 100644 --- a/src/screens/modals/TrackPopupMenu.tsx +++ b/src/screens/modals/TrackPopupMenu.tsx @@ -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; diff --git a/src/store/downloads/actions.ts b/src/store/downloads/actions.ts index e0880f8..3952703 100644 --- a/src/store/downloads/actions.ts +++ b/src/store/downloads/actions.ts @@ -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(); @@ -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'); diff --git a/src/store/index.ts b/src/store/index.ts index 0fe34e7..2991864 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -87,6 +87,7 @@ const store = configureStore({ export type AppState = ReturnType & { _persist: PersistState }; export type AppDispatch = typeof store.dispatch; export type AsyncThunkAPI = { state: AppState, dispatch: AppDispatch }; +export type Store = typeof store; export const useTypedSelector: TypedUseSelectorHook = useSelector; export const useAppDispatch: () => AppDispatch = useDispatch; diff --git a/src/store/music/actions.ts b/src/store/music/actions.ts index 8fb23d0..aaa3762 100644 --- a/src/store/music/actions.ts +++ b/src/store/music/actions.ts @@ -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({ selectId: album => album.Id, @@ -13,10 +15,7 @@ export const albumAdapter = createEntityAdapter({ */ export const fetchAllAlbums = createAsyncThunk( '/albums/all', - async (empty, thunkAPI) => { - const credentials = thunkAPI.getState().settings.jellyfin; - return retrieveAllAlbums(credentials) as Promise; - } + retrieveAllAlbums, ); /** @@ -24,10 +23,7 @@ export const fetchAllAlbums = createAsyncThunk( '/albums/recent', - async (numberOfAlbums, thunkAPI) => { - const credentials = thunkAPI.getState().settings.jellyfin; - return retrieveRecentAlbums(credentials, numberOfAlbums) as Promise; - } + retrieveRecentAlbums, ); export const trackAdapter = createEntityAdapter({ @@ -40,18 +36,12 @@ export const trackAdapter = createEntityAdapter({ */ export const fetchTracksByAlbum = createAsyncThunk( '/tracks/byAlbum', - async (ItemId, thunkAPI) => { - const credentials = thunkAPI.getState().settings.jellyfin; - return retrieveAlbumTracks(ItemId, credentials) as Promise; - } + retrieveAlbumTracks, ); export const fetchAlbum = createAsyncThunk( '/albums/single', - async (ItemId, thunkAPI) => { - const credentials = thunkAPI.getState().settings.jellyfin; - return retrieveAlbum(credentials, ItemId) as Promise; - } + 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({ */ export const fetchAllPlaylists = createAsyncThunk( '/playlists/all', - async (empty, thunkAPI) => { - const credentials = thunkAPI.getState().settings.jellyfin; - return retrieveAllPlaylists(credentials) as Promise; - } + retrieveAllPlaylists, ); /** @@ -108,8 +95,5 @@ export const fetchAllPlaylists = createAsyncThunk( '/tracks/byPlaylist', - async (ItemId, thunkAPI) => { - const credentials = thunkAPI.getState().settings.jellyfin; - return retrievePlaylistTracks(ItemId, credentials) as Promise; - } + retrievePlaylistTracks, ); \ No newline at end of file diff --git a/src/utility/JellyfinApi.ts b/src/utility/JellyfinApi.ts deleted file mode 100644 index abb056e..0000000 --- a/src/utility/JellyfinApi.ts +++ /dev/null @@ -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> = { - 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 = { - 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 { - 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) - .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.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); - }); -} \ No newline at end of file diff --git a/src/utility/JellyfinApi/album.ts b/src/utility/JellyfinApi/album.ts new file mode 100644 index 0000000..6ad6057 --- /dev/null +++ b/src/utility/JellyfinApi/album.ts @@ -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 { + 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(({ 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(({ 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); +} \ No newline at end of file diff --git a/src/utility/JellyfinApi/lib.ts b/src/utility/JellyfinApi/lib.ts new file mode 100644 index 0000000..db2d9c7 --- /dev/null +++ b/src/utility/JellyfinApi/lib.ts @@ -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( + path: string | ((credentials: NonNullable) => 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; + + 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); +} \ No newline at end of file diff --git a/src/utility/JellyfinApi/playback.ts b/src/utility/JellyfinApi/playback.ts new file mode 100644 index 0000000..d00c7de --- /dev/null +++ b/src/utility/JellyfinApi/playback.ts @@ -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.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); + }); +} \ No newline at end of file diff --git a/src/utility/JellyfinApi/playlist.ts b/src/utility/JellyfinApi/playlist.ts new file mode 100644 index 0000000..bb0e458 --- /dev/null +++ b/src/utility/JellyfinApi/playlist.ts @@ -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); +} \ No newline at end of file diff --git a/src/utility/JellyfinApi/search.ts b/src/utility/JellyfinApi/search.ts new file mode 100644 index 0000000..13b3c85 --- /dev/null +++ b/src/utility/JellyfinApi/search.ts @@ -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) + )); +} \ No newline at end of file diff --git a/src/utility/JellyfinApi/track.ts b/src/utility/JellyfinApi/track.ts new file mode 100644 index 0000000..4afd224 --- /dev/null +++ b/src/utility/JellyfinApi/track.ts @@ -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> = { + 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 = { + 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); +} diff --git a/src/utility/PlaybackService.ts b/src/utility/PlaybackService.ts index e0c506e..1d2c56e 100644 --- a/src/utility/PlaybackService.ts +++ b/src/utility/PlaybackService.ts @@ -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'); } } }); diff --git a/src/utility/usePlayTracks.ts b/src/utility/usePlayTracks.ts index e7947b4..6139dd8 100644 --- a/src/utility/usePlayTracks.ts +++ b/src/utility/usePlayTracks.ts @@ -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]); } \ No newline at end of file