diff --git a/src/localisation/lang/en/locale.json b/src/localisation/lang/en/locale.json index 22e5fcd..550bbe0 100644 --- a/src/localisation/lang/en/locale.json +++ b/src/localisation/lang/en/locale.json @@ -38,5 +38,6 @@ "enable-error-reporting-description": "This helps improve the app experience by sending crash and error reports to us.", "enable": "Enable", "disable": "Disable", - "more-info": "More Info" + "more-info": "More Info", + "track": "Track" } \ No newline at end of file diff --git a/src/localisation/types.ts b/src/localisation/types.ts index e15afa1..bfe4771 100644 --- a/src/localisation/types.ts +++ b/src/localisation/types.ts @@ -37,4 +37,5 @@ export type LocaleKeys = 'play-next' | 'enable-error-reporting-description' | 'enable' | 'disable' -| 'more-info' \ No newline at end of file +| 'more-info' +| 'track' \ No newline at end of file diff --git a/src/screens/Music/stacks/Search.tsx b/src/screens/Music/stacks/Search.tsx index 2f5d951..e562231 100644 --- a/src/screens/Music/stacks/Search.tsx +++ b/src/screens/Music/stacks/Search.tsx @@ -2,9 +2,9 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import Input from 'components/Input'; import { Text, View } from 'react-native'; import styled from 'styled-components/native'; -import { useTypedSelector } from 'store'; +import { useAppDispatch, useTypedSelector } from 'store'; import Fuse from 'fuse.js'; -import { Album } from 'store/music/types'; +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'; @@ -13,6 +13,7 @@ import { NavigationProp } from '../types'; import FastImage from 'react-native-fast-image'; import { t } from '@localisation'; import useDefaultStyles from 'components/Colors'; +import { searchAndFetchAlbums } from 'store/music/actions'; const Container = styled.View` padding: 0 20px; @@ -46,18 +47,37 @@ const fuseOptions = { includeScore: true, }; +type AudioResult = { + type: 'Audio', + id: string; + album: string; + name: string; +}; + +type AlbumResult = { + type: 'AlbumArtist', + id: string; + album: undefined; + name: undefined; +} + +type CombinedResults = (AudioResult | AlbumResult)[]; + export default function Search() { const defaultStyles = useDefaultStyles(); // Prepare state const [searchTerm, setSearchTerm] = useState(''); const albums = useTypedSelector(state => state.music.albums.entities); - const [results, setResults] = useState[]>([]); - const fuse = useRef>(); + const [results, setResults] = useState([]); + // const [isLoading, setLoading] = useState(false); + const fuse = useRef>(); // Prepare helpers const navigation = useNavigation(); const getImage = useGetImage(); + const credentials = useTypedSelector(state => state.settings.jellyfin); + const dispatch = useAppDispatch(); /** * Since it is impractical to have a global fuse variable, we need to @@ -75,14 +95,52 @@ export default function Search() { * them to state */ useEffect(() => { - // GUARD: In some extraordinary cases, Fuse might not be presented since - // it is assigned via refs. In this case, we can't handle any searching. - if (!fuse.current) { - return; - } + const retrieveResults = async () => { + // GUARD: In some extraordinary cases, Fuse might not be presented since + // it is assigned via refs. In this case, we can't handle any searching. + if (!fuse.current) { + return; + } - setResults(fuse.current.search(searchTerm)); - }, [searchTerm, setResults, fuse]); + // First set the immediate results from fuse + const fuseResults = fuse.current.search(searchTerm); + const albums: AlbumResult[] = fuseResults + .map(({ item }) => ({ + id: item.Id, + type: 'AlbumArtist', + album: undefined, + name: undefined, + })); + const albumIds = fuseResults.map(({ item }) => item.Id); + + // Assign the preliminary results + setResults(albums); + + // Then query the Jellyfin API + const { payload } = await dispatch(searchAndFetchAlbums({ term: searchTerm })); + + const items = (payload as + { results: (Album | AlbumTrack)[] } + ).results.filter(item => ( + !(item.Type === 'MusicAlbum' && albumIds.includes(item.Id)) + )).map((item) => ({ + type: item.Type, + id: item.Id, + album: item.Type === 'Audio' + ? item.AlbumId + : undefined, + name: item.Type === 'Audio' + ? item.Name + : undefined, + })); + + // Then add those to the results + // console.log(results, items); + setResults([...albums, ...items] as CombinedResults); + }; + + retrieveResults(); + }, [searchTerm, setResults, fuse, credentials, dispatch]); // Handlers const selectAlbum = useCallback((id: string) => @@ -92,7 +150,9 @@ export default function Search() { const HeaderComponent = React.useMemo(() => ( - {(searchTerm.length && !results.length) ? {t('no-results')} : null} + {(searchTerm.length && !results.length) + ? {t('no-results')} + : null} ), [searchTerm, results, setSearchTerm, defaultStyles]); @@ -103,24 +163,37 @@ export default function Search() { } return ( - ( - - - - - - {album.Name} - {album.AlbumArtist} - - {t('album')} - - - - )} - keyExtractor={(item) => item.refIndex.toString()} - ListHeaderComponent={HeaderComponent} - extraData={searchTerm} - /> + <> + { + const album = albums[trackAlbum || id]; + + if (!album) { + console.log('Couldnt find ', trackAlbum, id); + return null; + } + + return ( + + + + + + {trackName || album.Name} - {album.AlbumArtist} + + + {type === 'AlbumArtist' ? t('album'): t('track')} + + + + + ); + }} + keyExtractor={(item) => item.id} + ListHeaderComponent={HeaderComponent} + extraData={[searchTerm, albums]} + /> + ); } \ No newline at end of file diff --git a/src/store/index.ts b/src/store/index.ts index ca712bd..b8a6846 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,5 +1,5 @@ import { configureStore, getDefaultMiddleware, combineReducers } from '@reduxjs/toolkit'; -import { useSelector, TypedUseSelectorHook } from 'react-redux'; +import { useSelector, TypedUseSelectorHook, useDispatch } from 'react-redux'; import AsyncStorage from '@react-native-community/async-storage'; import { persistStore, persistReducer, PersistConfig } from 'redux-persist'; import autoMergeLevel2 from 'redux-persist/es/stateReconciler/autoMergeLevel2'; @@ -34,6 +34,7 @@ export type AppState = ReturnType; export type AppDispatch = typeof store.dispatch; export type AsyncThunkAPI = { state: AppState, dispatch: AppDispatch }; export const useTypedSelector: TypedUseSelectorHook = useSelector; +export const useAppDispatch = () => useDispatch(); export const persistedStore = persistStore(store); diff --git a/src/store/music/actions.ts b/src/store/music/actions.ts index ba113bd..407a6a0 100644 --- a/src/store/music/actions.ts +++ b/src/store/music/actions.ts @@ -1,7 +1,7 @@ import { createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit'; import { Album, AlbumTrack } from './types'; import { AsyncThunkAPI } from '..'; -import { retrieveAlbums, retrieveAlbumTracks, retrieveRecentAlbums } from 'utility/JellyfinApi'; +import { retrieveAllAlbums, retrieveAlbumTracks, retrieveRecentAlbums, searchItem, retrieveAlbum } from 'utility/JellyfinApi'; export const albumAdapter = createEntityAdapter({ selectId: album => album.Id, @@ -15,7 +15,7 @@ export const fetchAllAlbums = createAsyncThunk { const credentials = thunkAPI.getState().settings.jellyfin; - return retrieveAlbums(credentials) as Promise; + return retrieveAllAlbums(credentials) as Promise; } ); @@ -44,4 +44,36 @@ export const fetchTracksByAlbum = createAsyncThunk; } +); + +type SearchAndFetchResults = { + albums: Album[]; + results: (Album | AlbumTrack)[]; +}; + +export const searchAndFetchAlbums = createAsyncThunk< +SearchAndFetchResults, +{ term: string, limit?: number }, +AsyncThunkAPI +>( + '/search', + async ({ term, limit = 24 }, thunkAPI) => { + const state = thunkAPI.getState(); + const results = await searchItem(state.settings.jellyfin, term, limit); + + const albums = await Promise.all(results.filter((item) => ( + !state.music.albums.ids.includes(item.Type === 'MusicAlbum' ? item.Id : item.AlbumId) + )).map(async (item) => { + if (item.Type === 'MusicAlbum') { + return item; + } + + return retrieveAlbum(state.settings.jellyfin, item.AlbumId); + })); + + return { + albums, + results + }; + } ); \ No newline at end of file diff --git a/src/store/music/index.ts b/src/store/music/index.ts index 936e4d6..4f5fcc8 100644 --- a/src/store/music/index.ts +++ b/src/store/music/index.ts @@ -1,4 +1,4 @@ -import { fetchAllAlbums, albumAdapter, fetchTracksByAlbum, trackAdapter, fetchRecentAlbums } from './actions'; +import { fetchAllAlbums, albumAdapter, fetchTracksByAlbum, trackAdapter, fetchRecentAlbums, searchAndFetchAlbums } from './actions'; import { createSlice, Dictionary, EntityId } from '@reduxjs/toolkit'; import { Album, AlbumTrack } from './types'; import { setJellyfinCredentials } from 'store/settings/actions'; @@ -77,6 +77,11 @@ const music = createSlice({ builder.addCase(fetchTracksByAlbum.pending, (state) => { state.tracks.isLoading = true; }); builder.addCase(fetchTracksByAlbum.rejected, (state) => { state.tracks.isLoading = false; }); + builder.addCase(searchAndFetchAlbums.fulfilled, (state, { payload }) => { + console.log('INSERTING', payload.albums); + albumAdapter.upsertMany(state.albums, payload.albums); + }); + // Reset any caches we have when a new server is set builder.addCase(setJellyfinCredentials, () => initialState); } diff --git a/src/store/music/types.ts b/src/store/music/types.ts index 7767a2c..362a2a3 100644 --- a/src/store/music/types.ts +++ b/src/store/music/types.ts @@ -30,7 +30,7 @@ export interface Album { RunTimeTicks: number; ProductionYear: number; IsFolder: boolean; - Type: string; + Type: 'MusicAlbum'; UserData: UserData; PrimaryImageAspectRatio: number; Artists: string[]; @@ -53,7 +53,7 @@ export interface AlbumTrack { ProductionYear: number; IndexNumber: number; IsFolder: boolean; - Type: string; + Type: 'Audio'; UserData: UserData; Artists: string[]; ArtistItems: ArtistItem[]; diff --git a/src/utility/JellyfinApi.ts b/src/utility/JellyfinApi.ts index 6716cd8..852af5c 100644 --- a/src/utility/JellyfinApi.ts +++ b/src/utility/JellyfinApi.ts @@ -1,6 +1,6 @@ import { Track } from 'react-native-track-player'; import { AppState, useTypedSelector } from 'store'; -import { AlbumTrack } from 'store/music/types'; +import { Album, AlbumTrack } from 'store/music/types'; type Credentials = AppState['settings']['jellyfin']; @@ -70,7 +70,7 @@ const albumParams = new URLSearchParams(albumOptions).toString(); /** * Retrieve all albums that are available on the Jellyfin server */ -export async function retrieveAlbums(credentials: Credentials) { +export async function retrieveAllAlbums(credentials: Credentials) { const config = generateConfig(credentials); const albums = await fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items?${albumParams}`, config) .then(response => response.json()); @@ -78,6 +78,15 @@ export async function retrieveAlbums(credentials: Credentials) { return albums.Items; } +/** + * Retrieve a single album + */ +export async function retrieveAlbum(credentials: Credentials, id: string): Promise { + const config = generateConfig(credentials); + return fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items/${id}`, config) + .then(response => response.json()); +} + const latestAlbumsOptions = { IncludeItemTypes: 'MusicAlbum', Fields: 'DateCreated', @@ -122,11 +131,66 @@ export async function retrieveAlbumTracks(ItemId: string, credentials: Credentia 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); -} \ No newline at end of file +} + +const trackParams = { + SortBy: 'AlbumArtist,SortName', + SortOrder: 'Ascending', + IncludeItemTypes: 'Audio', + Recursive: 'true', + Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated', +}; + +/** + * Retrieve all possible tracks that can be found in Jellyfin + */ +export async function retrieveAllTracks(credentials: Credentials) { + const config = generateConfig(credentials); + const tracks = await fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items?${trackParams}`, config) + .then(response => response.json()); + + return tracks.Items; +} + +const searchParams = { + IncludeItemTypes: 'Audio,MusicAlbum', + SortBy: 'Album,SortName', + SortOrder: 'Ascending', + Recursive: 'true', +}; + +/** + * Remotely search the Jellyfin library for a particular search term + */ +export async function searchItem( + credentials: Credentials, + term: string, limit = 24 +): Promise<(Album | AlbumTrack)[]> { + const config = generateConfig(credentials); + + const params = new URLSearchParams({ + ...searchParams, + SearchTerm: term, + Limit: limit.toString(), + }).toString(); + + const results = await fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items?${params}`, config) + .then(response => response.json()); + + return results.Items; +} + +