React Query refactor (#91)

* initial react-query experiments

* use queries for item screens

send the data we do have over routing to prepopulate (album/playlist)
use number for starred because sending Date freaks out react-navigation

* add in equiv. song cover art fix

* reorg, switch artistview over

start mapping song cover art when any are available

* refactor useStar to queries

fix caching for starred items and album cover art

* add hook to reset queries on server change

* refactor search to use query

* fix song cover art setting

* use query for artistInfo

* remove last bits of library state

* cleanup

* use query key factory

already fixed one wrong key...

* require coverart size

* let's try no promise queues on these for now

* image cache uses query

* perf fix for playlist parsing

also use placeholder data so we don't have to deal with staleness

* drill that disabled

also list controls doesn't need its own songs hook/copy

* switch to react-native-blob-util for downloads

slightly slower but allows us to use DownloadManager, which backgrounds downloads so they are no longer corrupted when the app suspends

* add a fake "top songs" based on artist search

then sorted by play count/ratings
artistview should load now even if topSongs fails

* try not to swap between topSongs/search on refetch

set queueContext by song list so the index isn't off if the list changes

* add content type validation for file fetching

also try to speed up existing file return by limiting fs ops

* if the HEAD fails, don't queue the download

* clean up params

* reimpl clear image cache

* precompute contextId

prevents wrong "is playing" when any mismatch between queue and list

* clear images from all servers

use external files dir instead of cache

* fix pressable disabled flicker

don't retry topsongs on failure
try to optimize setqueue and fixcoverart a bit

* wait for queries during clear

* break out fetchExistingFile from fetchFile

allows to tell if file is coming from disk or not
only show placeholder/loading spinner if actually fetching image

* forgot these wouldn't do anything with objects

* remove query cache when switching servers

* add content-disposition extension gathering

add support for progress hook (needs native support still)

* added custom RNBU pkg with progress changes

* fully unmount tabs when server changes

prevents unwanted requests, gives fresh start on switch
fix fixCoverArt not re-rendering in certain cases on search

* use serverId from fetch deps

* fix lint

* update licenses

* just use the whole lodash package

* make using cache buster optional
This commit is contained in:
austinried
2022-04-11 09:40:51 +09:00
committed by GitHub
parent cbd88d0f13
commit 8196704ccd
48 changed files with 2206 additions and 1801 deletions

View File

@@ -1,81 +0,0 @@
import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache'
import { Store, useStore, useStoreDeep } from '@app/state/store'
import { useCallback, useEffect } from 'react'
const useFileRequest = (key: CacheItemTypeKey, id: string) => {
const file = useStore(
useCallback(
(store: Store) => {
const activeServerId = store.settings.activeServerId
if (!activeServerId) {
return
}
return store.cacheFiles[activeServerId][key][id]
},
[key, id],
),
)
const request = useStore(
useCallback(
(store: Store) => {
const activeServerId = store.settings.activeServerId
if (!activeServerId) {
return
}
return store.cacheRequests[activeServerId][key][id]
},
[key, id],
),
)
return { file, request }
}
export const useCoverArtFile = (coverArt = '-1', size: CacheImageSize = 'thumbnail') => {
const type: CacheItemTypeKey = size === 'original' ? 'coverArt' : 'coverArtThumb'
const { file, request } = useFileRequest(type, coverArt)
const client = useStore(store => store.client)
const cacheItem = useStore(store => store.cacheItem)
useEffect(() => {
if (!file && client) {
cacheItem(type, coverArt, () =>
client.getCoverArtUri({
id: coverArt,
size: type === 'coverArtThumb' ? '256' : undefined,
}),
)
}
// intentionally leaving file out so it doesn't re-render if the request fails
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cacheItem, client, coverArt, type])
return { file, request }
}
export const useArtistArtFile = (artistId: string, size: CacheImageSize = 'thumbnail') => {
const type: CacheItemTypeKey = size === 'original' ? 'artistArt' : 'artistArtThumb'
const fetchArtistInfo = useStore(store => store.fetchArtistInfo)
const artistInfo = useStoreDeep(store => store.library.artistInfo[artistId])
const { file, request } = useFileRequest(type, artistId)
const cacheItem = useStore(store => store.cacheItem)
useEffect(() => {
if (!artistInfo) {
fetchArtistInfo(artistId)
return
}
if (!file && artistInfo) {
cacheItem(type, artistId, async () => {
return type === 'artistArtThumb' ? artistInfo?.smallImageUrl : artistInfo?.largeImageUrl
})
}
// intentionally leaving file out so it doesn't re-render if the request fails
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artistId, cacheItem, fetchArtistInfo, type, artistInfo])
return { file, request }
}

305
app/hooks/fetch.ts Normal file
View File

@@ -0,0 +1,305 @@
import { CacheItemTypeKey } from '@app/models/cache'
import { Album, AlbumCoverArt, Playlist, Song } from '@app/models/library'
import { mapAlbum, mapArtist, mapArtistInfo, mapPlaylist, mapSong } from '@app/models/map'
import queryClient from '@app/queryClient'
import { useStore } from '@app/state/store'
import { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params'
import { cacheDir } from '@app/util/fs'
import { mapCollectionById } from '@app/util/state'
import userAgent from '@app/util/userAgent'
import cd from 'content-disposition'
import mime from 'mime-types'
import path from 'path'
import ReactNativeBlobUtil, { FetchBlobResponse } from 'react-native-blob-util'
import RNFS from 'react-native-fs'
import qk from './queryKeys'
export const useClient = () => {
const client = useStore(store => store.client)
return () => {
if (!client) {
throw new Error('no client!')
}
return client
}
}
function cacheStarredData<T extends { id: string; starred?: undefined | any }>(item: T) {
queryClient.setQueryData<boolean>(qk.starredItems(item.id), !!item.starred)
}
function cacheAlbumCoverArtData<T extends { id: string; coverArt?: string }>(item: T) {
queryClient.setQueryData<AlbumCoverArt>(qk.albumCoverArt(item.id), { albumId: item.id, coverArt: item.coverArt })
}
export const useFetchArtists = () => {
const client = useClient()
return async () => {
const res = await client().getArtists()
res.data.artists.forEach(cacheStarredData)
return mapCollectionById(res.data.artists, mapArtist)
}
}
export const useFetchArtist = () => {
const client = useClient()
return async (id: string) => {
const res = await client().getArtist({ id })
cacheStarredData(res.data.artist)
res.data.albums.forEach(cacheStarredData)
res.data.albums.forEach(cacheAlbumCoverArtData)
return {
artist: mapArtist(res.data.artist),
albums: res.data.albums.map(mapAlbum),
}
}
}
export const useFetchArtistInfo = () => {
const client = useClient()
return async (id: string) => {
const res = await client().getArtistInfo2({ id })
return mapArtistInfo(id, res.data.artistInfo)
}
}
export const useFetchArtistTopSongs = () => {
const client = useClient()
return async (artistName: string) => {
const res = await client().getTopSongs({ artist: artistName })
res.data.songs.forEach(cacheStarredData)
return res.data.songs.map(mapSong)
}
}
export const useFetchPlaylists = () => {
const client = useClient()
return async () => {
const res = await client().getPlaylists()
return mapCollectionById(res.data.playlists, mapPlaylist)
}
}
export const useFetchPlaylist = () => {
const client = useClient()
return async (id: string): Promise<{ playlist: Playlist; songs?: Song[] }> => {
const res = await client().getPlaylist({ id })
res.data.playlist.songs.forEach(cacheStarredData)
return {
playlist: mapPlaylist(res.data.playlist),
songs: res.data.playlist.songs.map(mapSong),
}
}
}
export const useFetchAlbum = () => {
const client = useClient()
return async (id: string): Promise<{ album: Album; songs?: Song[] }> => {
const res = await client().getAlbum({ id })
cacheStarredData(res.data.album)
res.data.songs.forEach(cacheStarredData)
cacheAlbumCoverArtData(res.data.album)
return {
album: mapAlbum(res.data.album),
songs: res.data.songs.map(mapSong),
}
}
}
export const useFetchAlbumList = () => {
const client = useClient()
return async (size: number, offset: number, type: GetAlbumList2TypeBase) => {
const res = await client().getAlbumList2({ size, offset, type })
res.data.albums.forEach(cacheStarredData)
res.data.albums.forEach(cacheAlbumCoverArtData)
return res.data.albums.map(mapAlbum)
}
}
export const useFetchSong = () => {
const client = useClient()
return async (id: string) => {
const res = await client().getSong({ id })
cacheStarredData(res.data.song)
return mapSong(res.data.song)
}
}
export const useFetchSearchResults = () => {
const client = useClient()
return async (params: Search3Params) => {
const res = await client().search3(params)
res.data.artists.forEach(cacheStarredData)
res.data.albums.forEach(cacheStarredData)
res.data.songs.forEach(cacheStarredData)
res.data.albums.forEach(cacheAlbumCoverArtData)
return {
artists: res.data.artists.map(mapArtist),
albums: res.data.albums.map(mapAlbum),
songs: res.data.songs.map(mapSong),
}
}
}
export const useFetchStar = () => {
const client = useClient()
return async (params: StarParams) => {
await client().star(params)
return
}
}
export const useFetchUnstar = () => {
const client = useClient()
return async (params: StarParams) => {
await client().unstar(params)
return
}
}
export type FetchExisingFileOptions = {
itemType: CacheItemTypeKey
itemId: string
}
export const useFetchExistingFile: () => (options: FetchExisingFileOptions) => Promise<string | undefined> = () => {
const serverId = useStore(store => store.settings.activeServerId)
return async ({ itemType, itemId }) => {
const fileDir = cacheDir(serverId, itemType, itemId)
try {
const dir = await RNFS.readDir(fileDir)
console.log('existing file:', dir[0].path)
return dir[0].path
} catch {}
}
}
function assertMimeType(expected?: string, actual?: string) {
expected = expected?.toLowerCase()
actual = actual?.toLowerCase()
if (!expected || expected === actual) {
return
}
if (!expected.includes(';')) {
actual = actual?.split(';')[0]
}
if (!expected.includes('/')) {
actual = actual?.split('/')[0]
}
if (expected !== actual) {
throw new Error(`Request does not satisfy expected content type. Expected: ${expected} Actual: ${actual}`)
}
}
export type FetchFileOptions = FetchExisingFileOptions & {
fromUrl: string
useCacheBuster?: boolean
expectedContentType?: string
progress?: (received: number, total: number) => void
}
export const useFetchFile: () => (options: FetchFileOptions) => Promise<string> = () => {
const serverId = useStore(store => store.settings.activeServerId)
return async ({ itemType, itemId, fromUrl, useCacheBuster, expectedContentType, progress }) => {
useCacheBuster = useCacheBuster === undefined ? true : useCacheBuster
const fileDir = cacheDir(serverId, itemType, itemId)
const filePathNoExt = path.join(fileDir, useCacheBuster ? useStore.getState().settings.cacheBuster : itemType)
try {
await RNFS.unlink(fileDir)
} catch {}
const headers = { 'User-Agent': userAgent }
// we send a HEAD first for two reasons:
// 1. to follow any redirects and get the actual URL (DownloadManager does not support redirects)
// 2. to obtain the mime-type up front so we can use it for the file extension/validation
const headRes = await fetch(fromUrl, { method: 'HEAD', headers })
if (headRes.status > 399) {
throw new Error(`HTTP status error ${headRes.status}. File: ${itemType} ID: ${itemId}`)
}
const contentType = headRes.headers.get('content-type') || undefined
assertMimeType(expectedContentType, contentType)
const contentDisposition = headRes.headers.get('content-disposition') || undefined
const filename = contentDisposition ? cd.parse(contentDisposition).parameters.filename : undefined
let extension: string | undefined
if (filename) {
extension = path.extname(filename) || undefined
if (extension) {
extension = extension.substring(1)
}
} else if (contentType) {
extension = mime.extension(contentType) || undefined
}
const config = ReactNativeBlobUtil.config({
addAndroidDownloads: {
useDownloadManager: true,
notification: false,
mime: contentType,
description: 'subtracks',
path: extension ? `${filePathNoExt}.${extension}` : filePathNoExt,
},
})
const fetchParams: Parameters<typeof config['fetch']> = ['GET', headRes.url, headers]
let res: FetchBlobResponse
if (progress) {
res = await config.fetch(...fetchParams).progress(progress)
} else {
res = await config.fetch(...fetchParams)
}
const downloadPath = res.path()
queryClient.setQueryData<string>(qk.existingFiles(itemType, itemId), downloadPath)
console.log('downloaded file:', downloadPath)
return downloadPath
}
}

View File

@@ -1,63 +0,0 @@
import { useStore } from '@app/state/store'
import { StarParams } from '@app/subsonic/params'
import { useCallback, useEffect } from 'react'
type StarrableItem = 'album' | 'artist' | 'song'
function starParams(id: string, type: StarrableItem): StarParams {
const params: StarParams = {}
if (type === 'album') {
params.albumId = id
} else if (type === 'artist') {
params.artistId = id
} else {
params.id = id
}
return params
}
export const useStar = (id: string, type: StarrableItem) => {
const fetchAlbum = useStore(store => store.fetchAlbum)
const fetchArtist = useStore(store => store.fetchArtist)
const fetchSong = useStore(store => store.fetchSong)
const _starred = useStore(
useCallback(
store => {
if (type === 'album') {
return store.library.albums[id] ? !!store.library.albums[id].starred : null
} else if (type === 'artist') {
return store.library.artists[id] ? !!store.library.artists[id].starred : null
} else {
return store.library.songs[id] ? !!store.library.songs[id].starred : null
}
},
[id, type],
),
)
useEffect(() => {
if (_starred === null) {
if (type === 'album') {
fetchAlbum(id)
} else if (type === 'artist') {
fetchArtist(id)
} else {
fetchSong(id)
}
}
}, [fetchAlbum, fetchArtist, fetchSong, id, _starred, type])
const starred = !!_starred
const _star = useStore(store => store.star)
const _unstar = useStore(store => store.unstar)
const star = useCallback(() => _star(starParams(id, type)), [_star, id, type])
const unstar = useCallback(() => _unstar(starParams(id, type)), [_unstar, id, type])
const toggleStar = useCallback(() => (starred ? unstar() : star()), [star, starred, unstar])
return { star, unstar, toggleStar, starred }
}

View File

@@ -1,95 +0,0 @@
import { useState, useCallback } from 'react'
import { useActiveServerRefresh } from './settings'
export const useFetchList = <T>(fetchList: () => Promise<T[]>) => {
const [list, setList] = useState<T[]>([])
const [refreshing, setRefreshing] = useState(false)
const refresh = useCallback(() => {
setRefreshing(true)
fetchList().then(items => {
setList(items)
setRefreshing(false)
})
}, [fetchList])
const reset = useCallback(() => {
setList([])
refresh()
}, [refresh])
useActiveServerRefresh(
useCallback(() => {
reset()
}, [reset]),
)
return { list, refreshing, refresh, reset }
}
export const useFetchList2 = (fetchList: () => Promise<void>) => {
const [refreshing, setRefreshing] = useState(false)
const refresh = useCallback(async () => {
setRefreshing(true)
await fetchList()
setRefreshing(false)
}, [fetchList])
useActiveServerRefresh(
useCallback(async () => {
await refresh()
}, [refresh]),
)
return { refreshing, refresh }
}
export const useFetchPaginatedList = <T>(
fetchList: (size: number, offset: number) => Promise<T[]>,
pageSize: number,
) => {
const [list, setList] = useState<T[]>([])
const [refreshing, setRefreshing] = useState(false)
const [offset, setOffset] = useState(0)
const refresh = useCallback(() => {
setOffset(0)
setRefreshing(true)
fetchList(pageSize, 0).then(firstPage => {
setList(firstPage)
setRefreshing(false)
})
}, [fetchList, pageSize])
const reset = useCallback(() => {
setList([])
refresh()
}, [refresh])
useActiveServerRefresh(
useCallback(() => {
refresh()
}, [refresh]),
)
const fetchNextPage = useCallback(() => {
const newOffset = offset + pageSize
setRefreshing(true)
fetchList(pageSize, newOffset).then(nextPage => {
setRefreshing(false)
if (nextPage.length === 0) {
return
}
setList([...list, ...nextPage])
setOffset(newOffset)
})
}, [offset, pageSize, fetchList, list])
return { list, refreshing, refresh, reset, fetchNextPage }
}

397
app/hooks/query.ts Normal file
View File

@@ -0,0 +1,397 @@
import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache'
import { Album, AlbumCoverArt, Artist, Playlist, Song, StarrableItemType } from '@app/models/library'
import { CollectionById } from '@app/models/state'
import queryClient from '@app/queryClient'
import { useStore } from '@app/state/store'
import { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params'
import _ from 'lodash'
import {
InfiniteData,
useInfiniteQuery,
UseInfiniteQueryResult,
useMutation,
useQueries,
useQuery,
UseQueryResult,
} from 'react-query'
import {
useFetchAlbum,
useFetchAlbumList,
useFetchArtist,
useFetchArtistInfo,
useFetchArtists,
useFetchArtistTopSongs,
useFetchExistingFile,
useFetchFile,
useFetchPlaylist,
useFetchPlaylists,
useFetchSearchResults,
useFetchSong,
useFetchStar,
useFetchUnstar,
} from './fetch'
import qk from './queryKeys'
export const useQueryArtists = () => useQuery(qk.artists, useFetchArtists())
export const useQueryArtist = (id: string) => {
const fetchArtist = useFetchArtist()
return useQuery(qk.artist(id), () => fetchArtist(id), {
placeholderData: () => {
const artist = queryClient.getQueryData<CollectionById<Artist>>(qk.artists)?.byId[id]
if (artist) {
return { artist, albums: [] }
}
},
})
}
export const useQueryArtistInfo = (id: string) => {
const fetchArtistInfo = useFetchArtistInfo()
return useQuery(qk.artistInfo(id), () => fetchArtistInfo(id))
}
export const useQueryArtistTopSongs = (artistName?: string) => {
const fetchArtistTopSongs = useFetchArtistTopSongs()
const query = useQuery(qk.artistTopSongs(artistName || ''), () => fetchArtistTopSongs(artistName as string), {
enabled: !!artistName,
retry: false,
staleTime: Infinity,
cacheTime: Infinity,
notifyOnChangeProps: ['data', 'isError', 'isFetched', 'isSuccess', 'isFetching'],
})
const querySuccess = query.isFetched && query.isSuccess && query.data && query.data.length > 0
const fetchSearchResults = useFetchSearchResults()
const backupQuery = useQuery(
qk.search(artistName || '', 0, 0, 50),
() => fetchSearchResults({ query: artistName as string, songCount: 50 }),
{
enabled: !!artistName && !query.isFetching && !querySuccess,
select: data =>
// sortBy is a stable sort, so that this doesn't change order arbitrarily and re-render
_.sortBy(data.songs, [s => -(s.playCount || 0), s => -(s.averageRating || 0), s => -(s.userRating || 0)]),
staleTime: Infinity,
cacheTime: Infinity,
notifyOnChangeProps: ['data', 'isError'],
},
)
return useFixCoverArt(querySuccess ? query : backupQuery)
}
export const useQueryPlaylists = () => useQuery(qk.playlists, useFetchPlaylists())
export const useQueryPlaylist = (id: string, placeholderPlaylist?: Playlist) => {
const fetchPlaylist = useFetchPlaylist()
const query = useQuery(qk.playlist(id), () => fetchPlaylist(id), {
placeholderData: () => {
if (placeholderPlaylist) {
return { playlist: placeholderPlaylist }
}
const playlist = queryClient.getQueryData<CollectionById<Playlist>>(qk.playlists)?.byId[id]
if (playlist) {
return { playlist, songs: [] }
}
},
})
return useFixCoverArt(query)
}
export const useQueryAlbum = (id: string, placeholderAlbum?: Album) => {
const fetchAlbum = useFetchAlbum()
const query = useQuery(qk.album(id), () => fetchAlbum(id), {
placeholderData: (): { album: Album; songs?: Song[] } | undefined =>
placeholderAlbum ? { album: placeholderAlbum } : undefined,
})
return useFixCoverArt(query)
}
export const useQueryAlbumList = (type: GetAlbumList2TypeBase, size: number) => {
const fetchAlbumList = useFetchAlbumList()
return useInfiniteQuery(
qk.albumList(type, size),
async context => {
return await fetchAlbumList(size, context.pageParam || 0, type)
},
{
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length === 0) {
return
}
return allPages.length * size
},
cacheTime: 0,
},
)
}
export const useQuerySearchResults = (params: Search3Params) => {
const fetchSearchResults = useFetchSearchResults()
const query = useInfiniteQuery(
qk.search(params.query, params.artistCount, params.albumCount, params.songCount),
async context => {
return await fetchSearchResults({
...params,
artistOffset: context.pageParam?.artistOffset || 0,
albumOffset: context.pageParam?.albumOffset || 0,
songOffset: context.pageParam?.songOffset || 0,
})
},
{
getNextPageParam: (lastPage, allPages) => {
if (lastPage.albums.length + lastPage.artists.length + lastPage.songs.length === 0) {
return
}
return {
artistOffset: allPages.reduce((acc, val) => (acc += val.artists.length), 0),
albumOffset: allPages.reduce((acc, val) => (acc += val.albums.length), 0),
songOffset: allPages.reduce((acc, val) => (acc += val.songs.length), 0),
}
},
cacheTime: 1000 * 60,
enabled: !!params.query && params.query.length > 1,
},
)
return useFixCoverArt(query)
}
export const useQueryHomeLists = (types: GetAlbumList2TypeBase[], size: number) => {
const fetchAlbumList = useFetchAlbumList()
const listQueries = useQueries(
types.map(type => {
return {
queryKey: qk.albumList(type, size),
queryFn: async () => {
const albums = await fetchAlbumList(size, 0, type as GetAlbumList2TypeBase)
return { type, albums }
},
}
}),
)
return listQueries
}
export const useStar = (id: string, type: StarrableItemType) => {
const fetchStar = useFetchStar()
const fetchUnstar = useFetchUnstar()
const fetchSong = useFetchSong()
const fetchAlbum = useFetchAlbum()
const fetchArtist = useFetchArtist()
const query = useQuery(
qk.starredItems(id),
async () => {
switch (type) {
case 'album':
console.log('fetch album starred', id)
return !!(await fetchAlbum(id)).album.starred
case 'artist':
console.log('fetch artist starred', id)
return !!(await fetchArtist(id)).artist.starred
default:
console.log('fetch song starred', id)
return !!(await fetchSong(id)).starred
}
},
{
cacheTime: Infinity,
staleTime: Infinity,
},
)
const toggle = useMutation(
() => {
const params: StarParams = {
id: type === 'song' ? id : undefined,
albumId: type === 'album' ? id : undefined,
artistId: type === 'artist' ? id : undefined,
}
return !query.data ? fetchStar(params) : fetchUnstar(params)
},
{
onMutate: () => {
queryClient.setQueryData<boolean>(qk.starredItems(id), !query.data)
},
onSuccess: () => {
if (type === 'album') {
queryClient.invalidateQueries(qk.albumList('starred'))
}
},
},
)
return { query, toggle }
}
export const useQueryExistingFile = (itemType: CacheItemTypeKey, itemId: string) => {
const fetchExistingFile = useFetchExistingFile()
return useQuery(qk.existingFiles(itemType, itemId), () => fetchExistingFile({ itemType, itemId }), {
staleTime: Infinity,
cacheTime: Infinity,
notifyOnChangeProps: ['data', 'isFetched'],
})
}
export const useQueryCoverArtPath = (coverArt = '-1', size: CacheImageSize = 'thumbnail') => {
const fetchFile = useFetchFile()
const client = useStore(store => store.client)
const itemType: CacheItemTypeKey = size === 'original' ? 'coverArt' : 'coverArtThumb'
const existing = useQueryExistingFile(itemType, coverArt)
const query = useQuery(
qk.coverArt(coverArt, size),
async () => {
if (!client) {
return
}
const fromUrl = client.getCoverArtUri({ id: coverArt, size: itemType === 'coverArtThumb' ? '256' : undefined })
return await fetchFile({ itemType, itemId: coverArt, fromUrl, expectedContentType: 'image' })
},
{
enabled: existing.isFetched && !existing.data && !!client,
staleTime: Infinity,
cacheTime: Infinity,
},
)
return { ...query, data: existing.data || query.data, isExistingFetching: existing.isFetching }
}
export const useQueryArtistArtPath = (artistId: string, size: CacheImageSize = 'thumbnail') => {
const fetchFile = useFetchFile()
const client = useStore(store => store.client)
const { data: artistInfo } = useQueryArtistInfo(artistId)
const itemType: CacheItemTypeKey = size === 'original' ? 'artistArt' : 'artistArtThumb'
const existing = useQueryExistingFile(itemType, artistId)
const query = useQuery(
qk.artistArt(artistId, size),
async () => {
if (!client || !artistInfo?.smallImageUrl || !artistInfo?.largeImageUrl) {
return
}
const fromUrl = itemType === 'artistArtThumb' ? artistInfo.smallImageUrl : artistInfo.largeImageUrl
return await fetchFile({ itemType, itemId: artistId, fromUrl, expectedContentType: 'image' })
},
{
enabled:
existing.isFetched &&
!existing.data &&
!!client &&
(!!artistInfo?.smallImageUrl || !!artistInfo?.largeImageUrl),
staleTime: Infinity,
cacheTime: Infinity,
},
)
return { ...query, data: existing.data || query.data, isExistingFetching: existing.isFetching }
}
type WithSongs = Song[] | { songs?: Song[] }
type InfiniteWithSongs = { songs: Song[] }
type AnyDataWithSongs = WithSongs | InfiniteData<InfiniteWithSongs>
type AnyQueryWithSongs = UseQueryResult<WithSongs> | UseInfiniteQueryResult<{ songs: Song[] }>
function getSongs<T extends AnyDataWithSongs>(data: T | undefined): Song[] {
if (!data) {
return []
}
if (Array.isArray(data)) {
return data
}
if ('pages' in data) {
return data.pages.flatMap(p => p.songs)
}
return data.songs || []
}
function setSongCoverArt<T extends AnyQueryWithSongs>(query: T, coverArts: UseQueryResult<AlbumCoverArt>[]): T {
if (!query.data) {
return query
}
const mapSongCoverArt = (song: Song) => ({
...song,
coverArt: coverArts.find(c => c.data?.albumId === song.albumId)?.data?.coverArt,
})
if (Array.isArray(query.data)) {
return {
...query,
data: query.data.map(mapSongCoverArt),
}
}
if ('pages' in query.data) {
return {
...query,
data: {
pages: query.data.pages.map(p => ({
...p,
songs: p.songs.map(mapSongCoverArt),
})),
},
}
}
if (query.data.songs) {
return {
...query,
data: {
...query.data,
songs: query.data.songs.map(mapSongCoverArt),
},
}
}
return query
}
// song cover art comes back from the api as a unique id per song even if it all points to the same
// album art, which prevents us from caching it once, so we need to use the album's cover art
const useFixCoverArt = <T extends AnyQueryWithSongs>(query: T) => {
const fetchAlbum = useFetchAlbum()
const songs = getSongs(query.data)
const albumIds = _.uniq((songs || []).map(s => s.albumId).filter((id): id is string => id !== undefined))
const coverArts = useQueries(
albumIds.map(id => ({
queryKey: qk.albumCoverArt(id),
queryFn: async (): Promise<AlbumCoverArt> => {
const res = await fetchAlbum(id)
return { albumId: res.album.id, coverArt: res.album.coverArt }
},
staleTime: Infinity,
cacheTime: Infinity,
notifyOnChangeProps: ['data', 'isFetched'] as any,
})),
)
if (coverArts.every(c => c.isFetched)) {
return setSongCoverArt(query, coverArts)
}
return query
}

52
app/hooks/queryKeys.ts Normal file
View File

@@ -0,0 +1,52 @@
import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache'
import { GetAlbumList2TypeBase } from '@app/subsonic/params'
const qk = {
starredItems: (id: string) => ['starredItems', id],
albumCoverArt: (id: string) => ['albumCoverArt', id],
artists: 'artists',
artist: (id: string) => ['artist', id],
artistInfo: (id: string) => ['artistInfo', id],
artistTopSongs: (artistName: string) => ['artistTopSongs', artistName],
playlists: 'playlists',
playlist: (id: string) => ['playlist', id],
album: (id: string) => ['album', id],
albumList: (type: GetAlbumList2TypeBase, size?: number) => {
const key: (string | number)[] = ['albumList', type]
size !== undefined && key.push(size)
return key
},
search: (query: string, artistCount?: number, albumCount?: number, songCount?: number) => [
'search',
query,
artistCount,
albumCount,
songCount,
],
coverArt: (coverArt?: string, size?: CacheImageSize) => {
const key: string[] = ['coverArt']
coverArt !== undefined && key.push(coverArt)
size !== undefined && key.push(size)
return key
},
artistArt: (artistId?: string, size?: CacheImageSize) => {
const key: string[] = ['artistArt']
artistId !== undefined && key.push(artistId)
size !== undefined && key.push(size)
return key
},
existingFiles: (type?: CacheItemTypeKey, itemId?: string) => {
const key: string[] = ['existingFiles']
type !== undefined && key.push(type)
itemId !== undefined && key.push(itemId)
return key
},
}
export default qk

View File

@@ -1,6 +1,10 @@
import { useReset } from '@app/hooks/trackplayer'
import { useStore } from '@app/state/store'
import { useEffect } from 'react'
import { CacheItemTypeKey } from '@app/models/cache'
import queryClient from '@app/queryClient'
import { useStore, useStoreDeep } from '@app/state/store'
import { cacheDir } from '@app/util/fs'
import RNFS from 'react-native-fs'
import qk from './queryKeys'
export const useSwitchActiveServer = () => {
const activeServerId = useStore(store => store.settings.activeServerId)
@@ -12,21 +16,53 @@ export const useSwitchActiveServer = () => {
return
}
await queryClient.cancelQueries(undefined, { active: true })
await resetPlayer()
queryClient.removeQueries()
setActiveServer(id)
}
}
export const useActiveServerRefresh = (refresh: () => void) => {
const activeServerId = useStore(store => store.settings.activeServerId)
useEffect(() => {
if (activeServerId) {
refresh()
}
}, [activeServerId, refresh])
}
export const useFirstRun = () => {
return useStore(store => Object.keys(store.settings.servers).length === 0)
}
export const useResetImageCache = () => {
const serverIds = useStoreDeep(store => Object.keys(store.settings.servers))
const changeCacheBuster = useStore(store => store.changeCacheBuster)
return async () => {
// disable/invalidate queries
await Promise.all([
queryClient.cancelQueries(qk.artistArt(), { active: true }),
queryClient.cancelQueries(qk.coverArt(), { active: true }),
queryClient.cancelQueries(qk.existingFiles(), { active: true }),
queryClient.invalidateQueries(qk.artistArt(), { refetchActive: false }),
queryClient.invalidateQueries(qk.coverArt(), { refetchActive: false }),
queryClient.invalidateQueries(qk.existingFiles(), { refetchActive: false }),
])
// delete all images
const itemTypes: CacheItemTypeKey[] = ['artistArt', 'artistArtThumb', 'coverArt', 'coverArtThumb']
await Promise.all(
serverIds.flatMap(id =>
itemTypes.map(async type => {
const dir = cacheDir(id, type)
try {
await RNFS.unlink(dir)
} catch {}
}),
),
)
// change cacheBuster
changeCacheBuster()
// enable queries
await Promise.all([
queryClient.refetchQueries(qk.existingFiles(), { active: true }),
queryClient.refetchQueries(qk.artistArt(), { active: true }),
queryClient.refetchQueries(qk.coverArt(), { active: true }),
])
}
}

View File

@@ -1,6 +1,14 @@
import { Song } from '@app/models/library'
import { QueueContextType, TrackExt } from '@app/models/trackplayer'
import queryClient from '@app/queryClient'
import { useStore, useStoreDeep } from '@app/state/store'
import { getQueue, trackPlayerCommands } from '@app/state/trackplayer'
import { getQueue, SetQueueOptions, trackPlayerCommands } from '@app/state/trackplayer'
import userAgent from '@app/util/userAgent'
import _ from 'lodash'
import TrackPlayer from 'react-native-track-player'
import { useQueries } from 'react-query'
import { useFetchExistingFile, useFetchFile } from './fetch'
import qk from './queryKeys'
export const usePlay = () => {
return () => trackPlayerCommands.enqueue(() => TrackPlayer.play())
@@ -83,3 +91,88 @@ export const useIsPlaying = (contextId: string | undefined, track: number) => {
return contextId === queueContextId && track === currentTrackIdx
}
export const useSetQueue = (type: QueueContextType, songs?: Song[]) => {
const _setQueue = useStore(store => store.setQueue)
const client = useStore(store => store.client)
const buildStreamUri = useStore(store => store.buildStreamUri)
const fetchFile = useFetchFile()
const fetchExistingFile = useFetchExistingFile()
const songCoverArt = _.uniq((songs || []).map(s => s.coverArt)).filter((c): c is string => c !== undefined)
const coverArtPaths = useQueries(
songCoverArt.map(coverArt => ({
queryKey: qk.coverArt(coverArt, 'thumbnail'),
queryFn: async () => {
if (!client) {
return
}
const itemType = 'coverArtThumb'
const existingCache = queryClient.getQueryData<string | undefined>(qk.existingFiles(itemType, coverArt))
if (existingCache) {
return existingCache
}
const existingDisk = await fetchExistingFile({ itemId: coverArt, itemType })
if (existingDisk) {
return existingDisk
}
const fromUrl = client.getCoverArtUri({ id: coverArt, size: '256' })
return await fetchFile({
itemType,
itemId: coverArt,
fromUrl,
expectedContentType: 'image',
})
},
enabled: !!client && !!songs,
staleTime: Infinity,
cacheTime: Infinity,
notifyOnChangeProps: ['data', 'isFetched'] as any,
})),
)
const songCoverArtToPath = _.zipObject(
songCoverArt,
coverArtPaths.map(c => c.data),
)
const mapSongToTrackExt = (s: Song): TrackExt => {
let artwork = require('@res/fallback.png')
if (s.coverArt) {
const filePath = songCoverArtToPath[s.coverArt]
if (filePath) {
artwork = `file://${filePath}`
}
}
return {
id: s.id,
title: s.title,
artist: s.artist || 'Unknown Artist',
album: s.album || 'Unknown Album',
url: buildStreamUri(s.id),
userAgent,
artwork,
coverArt: s.coverArt,
duration: s.duration,
artistId: s.artistId,
albumId: s.albumId,
track: s.track,
discNumber: s.discNumber,
}
}
const contextId = `${type}-${songs?.map(s => s.id).join('-')}`
const setQueue = async (options: SetQueueOptions) => {
const queue = (songs || []).map(mapSongToTrackExt)
return await _setQueue({ queue, type, contextId, ...options })
}
return { setQueue, contextId, isReady: coverArtPaths.every(c => c.isFetched) }
}