Add track results to search queries

This commit is contained in:
Lei Nelissen
2021-04-24 14:50:43 +02:00
parent a7b24cf4eb
commit 24d484ca25
8 changed files with 219 additions and 42 deletions

View File

@@ -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"
}

View File

@@ -37,4 +37,5 @@ export type LocaleKeys = 'play-next'
| 'enable-error-reporting-description'
| 'enable'
| 'disable'
| 'more-info'
| 'more-info'
| 'track'

View File

@@ -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<Fuse.FuseResult<Album>[]>([]);
const fuse = useRef<Fuse<Album, typeof fuseOptions>>();
const [results, setResults] = useState<CombinedResults>([]);
// const [isLoading, setLoading] = useState(false);
const fuse = useRef<Fuse<Album>>();
// Prepare helpers
const navigation = useNavigation<NavigationProp>();
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(() => (
<Container>
<Input value={searchTerm} onChangeText={setSearchTerm} style={defaultStyles.input} placeholder={t('search') + '...'} />
{(searchTerm.length && !results.length) ? <Text style={{ textAlign: 'center' }}>{t('no-results')}</Text> : null}
{(searchTerm.length && !results.length)
? <Text style={{ textAlign: 'center' }}>{t('no-results')}</Text>
: null}
</Container>
), [searchTerm, results, setSearchTerm, defaultStyles]);
@@ -103,24 +163,37 @@ export default function Search() {
}
return (
<FlatList
data={results}
renderItem={({ item: { item: album } }) =>(
<TouchableHandler id={album.Id} onPress={selectAlbum}>
<SearchResult style={defaultStyles.border}>
<AlbumImage source={{ uri: getImage(album.Id) }} />
<View>
<Text numberOfLines={1} ellipsizeMode="tail" style={defaultStyles.text}>
{album.Name} - {album.AlbumArtist}
</Text>
<HalfOpacity style={defaultStyles.text}>{t('album')}</HalfOpacity>
</View>
</SearchResult>
</TouchableHandler>
)}
keyExtractor={(item) => item.refIndex.toString()}
ListHeaderComponent={HeaderComponent}
extraData={searchTerm}
/>
<>
<FlatList
data={results}
renderItem={({ item: { id, type, album: trackAlbum, name: trackName } }: { item: AlbumResult | AudioResult }) => {
const album = albums[trackAlbum || id];
if (!album) {
console.log('Couldnt find ', trackAlbum, id);
return null;
}
return (
<TouchableHandler id={album.Id} onPress={selectAlbum}>
<SearchResult style={defaultStyles.border}>
<AlbumImage source={{ uri: getImage(album.Id) }} />
<View>
<Text numberOfLines={1} ellipsizeMode="tail" style={defaultStyles.text}>
{trackName || album.Name} - {album.AlbumArtist}
</Text>
<HalfOpacity style={defaultStyles.text}>
{type === 'AlbumArtist' ? t('album'): t('track')}
</HalfOpacity>
</View>
</SearchResult>
</TouchableHandler>
);
}}
keyExtractor={(item) => item.id}
ListHeaderComponent={HeaderComponent}
extraData={[searchTerm, albums]}
/>
</>
);
}

View File

@@ -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<typeof reducers>;
export type AppDispatch = typeof store.dispatch;
export type AsyncThunkAPI = { state: AppState, dispatch: AppDispatch };
export const useTypedSelector: TypedUseSelectorHook<AppState> = useSelector;
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const persistedStore = persistStore(store);

View File

@@ -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<Album>({
selectId: album => album.Id,
@@ -15,7 +15,7 @@ export const fetchAllAlbums = createAsyncThunk<Album[], undefined, AsyncThunkAPI
'/albums/all',
async (empty, thunkAPI) => {
const credentials = thunkAPI.getState().settings.jellyfin;
return retrieveAlbums(credentials) as Promise<Album[]>;
return retrieveAllAlbums(credentials) as Promise<Album[]>;
}
);
@@ -44,4 +44,36 @@ export const fetchTracksByAlbum = createAsyncThunk<AlbumTrack[], string, AsyncTh
const credentials = thunkAPI.getState().settings.jellyfin;
return retrieveAlbumTracks(ItemId, credentials) as Promise<AlbumTrack[]>;
}
);
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
};
}
);

View File

@@ -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);
}

View File

@@ -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[];

View File

@@ -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<Album> {
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);
}
}
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;
}