mirror of
https://github.com/austinried/subtracks.git
synced 2025-12-27 17:19:27 +01:00
* start of music store refactor moving stuff into a state cache better separate it from view logic * added paginated list/album list * reworked fetchAlbumList to remove ui state refactored home screen to use new method i broke playing songs somehow, JS thread goes into a loop * don't reset parts manually, do it all at once * fixed perf issue related to too many rerenders rerenders were caused by strict equality check on object/array picks switched artistInfo to new store updated zustand and fixed deprecation warnings * update typescript and use workspace tsc version for vscode * remove old artistInfo * switched to new playlist w/songs removed more unused stuff * remove unused + (slightly) rework search * refactor star * use only original/large imges for covers/artist fix view artist from context menu add loading indicators to song list and artist views (show info we have right away) * set starred/unstar assuming it works and correct state on error * reorg, remove old music slice files * added back fix for song cover art * sort artists by localCompare name * update licenses * fix now playing background grey bar * update react-native-gesture-handler for node-fetch security alert * fix another gradient height grey bar issue * update licenses again * remove thumbnail cache * rename to remove "Library" from methods * Revert "remove thumbnail cache" This reverts commit e0db4931f11bbf4cd8e73102d06505c6ae85f4a6. * use ids for lists, pull state later * Revert "use only original/large imges for covers/artist" This reverts commit c9aea9065ce6ebe3c8b09c10dd74d4de153d76fd. * deep equal ListItem props for now this needs a bigger refactor * use immer as middleware * refactor api client to use string method hoping to use this for requestKey/deduping next * use thumbnails in list items * Revert "refactor api client to use string method" This reverts commit 234326135b7af96cb91b941e7ca515f45c632556. * rename/cleanup * store servers by id * get rid of settings selectors * renames for clarity remove unused estimateContentLength setting * remove trackplayer selectors * fix migration for library filter settings * fixed shuffle order reporting wrong track/queue * removed the other selectors * don't actually need es6/react for our state * fix slow artist sort on star localeCompare is too slow for large lists
522 lines
12 KiB
TypeScript
522 lines
12 KiB
TypeScript
import { Album, Artist, ArtistInfo, Playlist, SearchResults, Song } from '@app/models/library'
|
|
import { ById, OneToMany } from '@app/models/state'
|
|
import { GetStore, SetStore, Store } from '@app/state/store'
|
|
import {
|
|
AlbumID3Element,
|
|
ArtistID3Element,
|
|
ArtistInfo2Element,
|
|
ChildElement,
|
|
PlaylistElement,
|
|
} from '@app/subsonic/elements'
|
|
import { GetAlbumList2Params, Search3Params, StarParams } from '@app/subsonic/params'
|
|
import {
|
|
GetAlbumList2Response,
|
|
GetAlbumResponse,
|
|
GetArtistInfo2Response,
|
|
GetArtistResponse,
|
|
GetArtistsResponse,
|
|
GetPlaylistResponse,
|
|
GetPlaylistsResponse,
|
|
GetSongResponse,
|
|
GetTopSongsResponse,
|
|
Search3Response,
|
|
} from '@app/subsonic/responses'
|
|
import PromiseQueue from '@app/util/PromiseQueue'
|
|
import { mapId, mergeById, reduceById } from '@app/util/state'
|
|
import { WritableDraft } from 'immer/dist/types/types-external'
|
|
import pick from 'lodash.pick'
|
|
|
|
const songCoverArtQueue = new PromiseQueue(2)
|
|
|
|
export type LibrarySlice = {
|
|
library: {
|
|
artists: ById<Artist>
|
|
artistInfo: ById<ArtistInfo>
|
|
artistAlbums: OneToMany
|
|
artistNameTopSongs: OneToMany
|
|
artistOrder: string[]
|
|
|
|
albums: ById<Album>
|
|
albumSongs: OneToMany
|
|
|
|
playlists: ById<Playlist>
|
|
playlistSongs: OneToMany
|
|
|
|
songs: ById<Song>
|
|
}
|
|
|
|
resetLibrary: (state?: WritableDraft<Store>) => void
|
|
|
|
fetchArtists: () => Promise<void>
|
|
fetchArtist: (id: string) => Promise<void>
|
|
fetchArtistInfo: (artistId: string) => Promise<void>
|
|
fetchArtistTopSongs: (artistName: string) => Promise<void>
|
|
|
|
fetchAlbum: (id: string) => Promise<void>
|
|
|
|
fetchPlaylists: () => Promise<void>
|
|
fetchPlaylist: (id: string) => Promise<void>
|
|
|
|
fetchSong: (id: string) => Promise<void>
|
|
|
|
fetchAlbumList: (params: GetAlbumList2Params) => Promise<string[]>
|
|
fetchSearchResults: (params: Search3Params) => Promise<SearchResults>
|
|
star: (params: StarParams) => Promise<void>
|
|
unstar: (params: StarParams) => Promise<void>
|
|
|
|
_fixSongCoverArt: (songs: Song[]) => Promise<void>
|
|
}
|
|
|
|
const defaultLibrary = () => ({
|
|
artists: {},
|
|
artistAlbums: {},
|
|
artistInfo: {},
|
|
artistNameTopSongs: {},
|
|
artistOrder: [],
|
|
|
|
albums: {},
|
|
albumSongs: {},
|
|
|
|
playlists: {},
|
|
playlistSongs: {},
|
|
|
|
songs: {},
|
|
})
|
|
|
|
export const createLibrarySlice = (set: SetStore, get: GetStore): LibrarySlice => ({
|
|
library: defaultLibrary(),
|
|
|
|
resetLibrary: state => {
|
|
if (state) {
|
|
state.library = defaultLibrary()
|
|
return
|
|
}
|
|
set(store => {
|
|
store.library = defaultLibrary()
|
|
})
|
|
},
|
|
|
|
fetchArtists: async () => {
|
|
const client = get().client
|
|
if (!client) {
|
|
return
|
|
}
|
|
|
|
let response: GetArtistsResponse
|
|
try {
|
|
response = await client.getArtists()
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
const artists = response.data.artists.map(mapArtist)
|
|
const artistsById = reduceById(artists)
|
|
const artistIds = mapId(artists)
|
|
|
|
set(state => {
|
|
state.library.artists = artistsById
|
|
state.library.artistAlbums = pick(state.library.artistAlbums, artistIds)
|
|
state.library.artistOrder = artistIds
|
|
})
|
|
},
|
|
|
|
fetchArtist: async id => {
|
|
const client = get().client
|
|
if (!client) {
|
|
return
|
|
}
|
|
|
|
let response: GetArtistResponse
|
|
try {
|
|
response = await client.getArtist({ id })
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
const artist = mapArtist(response.data.artist)
|
|
const albums = response.data.albums.map(mapAlbum)
|
|
const albumsById = reduceById(albums)
|
|
|
|
set(state => {
|
|
state.library.artists[id] = artist
|
|
state.library.artistAlbums[id] = mapId(albums)
|
|
mergeById(state.library.albums, albumsById)
|
|
})
|
|
},
|
|
|
|
fetchArtistInfo: async id => {
|
|
const client = get().client
|
|
if (!client) {
|
|
return
|
|
}
|
|
|
|
let response: GetArtistInfo2Response
|
|
try {
|
|
response = await client.getArtistInfo2({ id })
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
const info = mapArtistInfo(id, response.data.artistInfo)
|
|
|
|
set(state => {
|
|
state.library.artistInfo[id] = info
|
|
})
|
|
},
|
|
|
|
fetchArtistTopSongs: async artistName => {
|
|
const client = get().client
|
|
if (!client) {
|
|
return
|
|
}
|
|
|
|
let response: GetTopSongsResponse
|
|
try {
|
|
response = await client.getTopSongs({ artist: artistName, count: 50 })
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
const topSongs = response.data.songs.map(mapSong)
|
|
const topSongsById = reduceById(topSongs)
|
|
|
|
get()._fixSongCoverArt(topSongs)
|
|
|
|
set(state => {
|
|
mergeById(state.library.songs, topSongsById)
|
|
state.library.artistNameTopSongs[artistName] = mapId(topSongs)
|
|
})
|
|
},
|
|
|
|
fetchAlbum: async id => {
|
|
const client = get().client
|
|
if (!client) {
|
|
return
|
|
}
|
|
|
|
let response: GetAlbumResponse
|
|
try {
|
|
response = await client.getAlbum({ id })
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
const album = mapAlbum(response.data.album)
|
|
const songs = response.data.songs.map(mapSong)
|
|
const songsById = reduceById(songs)
|
|
|
|
get()._fixSongCoverArt(songs)
|
|
|
|
set(state => {
|
|
state.library.albums[id] = album
|
|
state.library.albumSongs[id] = mapId(songs)
|
|
mergeById(state.library.songs, songsById)
|
|
})
|
|
},
|
|
|
|
fetchPlaylists: async () => {
|
|
const client = get().client
|
|
if (!client) {
|
|
return
|
|
}
|
|
|
|
let response: GetPlaylistsResponse
|
|
try {
|
|
response = await client.getPlaylists()
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
const playlists = response.data.playlists.map(mapPlaylist)
|
|
const playlistsById = reduceById(playlists)
|
|
|
|
set(state => {
|
|
state.library.playlists = playlistsById
|
|
state.library.playlistSongs = pick(state.library.playlistSongs, mapId(playlists))
|
|
})
|
|
},
|
|
|
|
fetchPlaylist: async id => {
|
|
const client = get().client
|
|
if (!client) {
|
|
return
|
|
}
|
|
|
|
let response: GetPlaylistResponse
|
|
try {
|
|
response = await client.getPlaylist({ id })
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
const playlist = mapPlaylist(response.data.playlist)
|
|
const songs = response.data.playlist.songs.map(mapSong)
|
|
const songsById = reduceById(songs)
|
|
|
|
get()._fixSongCoverArt(songs)
|
|
|
|
set(state => {
|
|
state.library.playlists[id] = playlist
|
|
state.library.playlistSongs[id] = mapId(songs)
|
|
mergeById(state.library.songs, songsById)
|
|
})
|
|
},
|
|
|
|
fetchSong: async id => {
|
|
const client = get().client
|
|
if (!client) {
|
|
return
|
|
}
|
|
|
|
let response: GetSongResponse
|
|
try {
|
|
response = await client.getSong({ id })
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
const song = mapSong(response.data.song)
|
|
|
|
get()._fixSongCoverArt([song])
|
|
|
|
set(state => {
|
|
state.library.songs[id] = song
|
|
})
|
|
},
|
|
|
|
fetchAlbumList: async params => {
|
|
const client = get().client
|
|
if (!client) {
|
|
return []
|
|
}
|
|
|
|
let response: GetAlbumList2Response
|
|
try {
|
|
response = await client.getAlbumList2(params)
|
|
} catch {
|
|
return []
|
|
}
|
|
|
|
const albums = response.data.albums.map(mapAlbum)
|
|
const albumsById = reduceById(albums)
|
|
|
|
set(state => {
|
|
mergeById(state.library.albums, albumsById)
|
|
})
|
|
|
|
return mapId(albums)
|
|
},
|
|
|
|
fetchSearchResults: async params => {
|
|
const empty = { artists: [], albums: [], songs: [] }
|
|
|
|
const client = get().client
|
|
if (!client) {
|
|
return empty
|
|
}
|
|
|
|
let response: Search3Response
|
|
try {
|
|
response = await client.search3(params)
|
|
} catch {
|
|
return empty
|
|
}
|
|
|
|
const artists = response.data.artists.map(mapArtist)
|
|
const artistsById = reduceById(artists)
|
|
const albums = response.data.albums.map(mapAlbum)
|
|
const albumsById = reduceById(albums)
|
|
const songs = response.data.songs.map(mapSong)
|
|
const songsById = reduceById(songs)
|
|
|
|
get()._fixSongCoverArt(songs)
|
|
|
|
set(state => {
|
|
mergeById(state.library.artists, artistsById)
|
|
mergeById(state.library.albums, albumsById)
|
|
mergeById(state.library.songs, songsById)
|
|
})
|
|
|
|
return {
|
|
artists: mapId(artists),
|
|
albums: mapId(albums),
|
|
songs: mapId(songs),
|
|
}
|
|
},
|
|
|
|
star: async params => {
|
|
const client = get().client
|
|
if (!client) {
|
|
return
|
|
}
|
|
|
|
let id = '-1'
|
|
let entity: 'songs' | 'artists' | 'albums' = 'songs'
|
|
if (params.id) {
|
|
id = params.id
|
|
entity = 'songs'
|
|
} else if (params.albumId) {
|
|
id = params.albumId
|
|
entity = 'albums'
|
|
} else if (params.artistId) {
|
|
id = params.artistId
|
|
entity = 'artists'
|
|
} else {
|
|
return
|
|
}
|
|
|
|
const item = get().library[entity][id]
|
|
const originalValue = item ? item.starred : null
|
|
|
|
set(state => {
|
|
state.library[entity][id].starred = new Date()
|
|
})
|
|
|
|
try {
|
|
await client.star(params)
|
|
} catch {
|
|
set(state => {
|
|
if (originalValue !== null) {
|
|
state.library[entity][id].starred = originalValue
|
|
}
|
|
})
|
|
}
|
|
},
|
|
|
|
unstar: async params => {
|
|
const client = get().client
|
|
if (!client) {
|
|
return
|
|
}
|
|
|
|
let id = '-1'
|
|
let entity: 'songs' | 'artists' | 'albums' = 'songs'
|
|
if (params.id) {
|
|
id = params.id
|
|
entity = 'songs'
|
|
} else if (params.albumId) {
|
|
id = params.albumId
|
|
entity = 'albums'
|
|
} else if (params.artistId) {
|
|
id = params.artistId
|
|
entity = 'artists'
|
|
} else {
|
|
return
|
|
}
|
|
|
|
const item = get().library[entity][id]
|
|
const originalValue = item ? item.starred : null
|
|
|
|
set(state => {
|
|
state.library[entity][id].starred = undefined
|
|
})
|
|
|
|
try {
|
|
await client.unstar(params)
|
|
} catch {
|
|
set(state => {
|
|
if (originalValue !== null) {
|
|
state.library[entity][id].starred = originalValue
|
|
}
|
|
})
|
|
}
|
|
},
|
|
|
|
// 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
|
|
_fixSongCoverArt: async songs => {
|
|
const client = get().client
|
|
if (!client) {
|
|
return
|
|
}
|
|
|
|
const albumsToGet: ById<Song[]> = {}
|
|
for (const song of songs) {
|
|
if (!song.albumId) {
|
|
continue
|
|
}
|
|
|
|
let album = get().library.albums[song.albumId]
|
|
if (album) {
|
|
song.coverArt = album.coverArt
|
|
continue
|
|
}
|
|
|
|
albumsToGet[song.albumId] = albumsToGet[song.albumId] || []
|
|
albumsToGet[song.albumId].push(song)
|
|
}
|
|
|
|
for (const id in albumsToGet) {
|
|
songCoverArtQueue
|
|
.enqueue(() => client.getAlbum({ id }))
|
|
.then(res => {
|
|
const album = mapAlbum(res.data.album)
|
|
|
|
set(state => {
|
|
state.library.albums[album.id] = album
|
|
for (const song of albumsToGet[album.id]) {
|
|
state.library.songs[song.id].coverArt = album.coverArt
|
|
}
|
|
})
|
|
})
|
|
}
|
|
},
|
|
})
|
|
|
|
function mapArtist(artist: ArtistID3Element): Artist {
|
|
return {
|
|
itemType: 'artist',
|
|
id: artist.id,
|
|
name: artist.name,
|
|
starred: artist.starred,
|
|
coverArt: artist.coverArt,
|
|
}
|
|
}
|
|
|
|
function mapArtistInfo(id: string, info: ArtistInfo2Element): ArtistInfo {
|
|
return {
|
|
id,
|
|
smallImageUrl: info.smallImageUrl,
|
|
largeImageUrl: info.largeImageUrl,
|
|
}
|
|
}
|
|
|
|
function mapAlbum(album: AlbumID3Element): Album {
|
|
return {
|
|
itemType: 'album',
|
|
id: album.id,
|
|
name: album.name,
|
|
artist: album.artist,
|
|
artistId: album.artistId,
|
|
starred: album.starred,
|
|
coverArt: album.coverArt,
|
|
year: album.year,
|
|
}
|
|
}
|
|
|
|
function mapPlaylist(playlist: PlaylistElement): Playlist {
|
|
return {
|
|
itemType: 'playlist',
|
|
id: playlist.id,
|
|
name: playlist.name,
|
|
comment: playlist.comment,
|
|
coverArt: playlist.coverArt,
|
|
}
|
|
}
|
|
|
|
function mapSong(song: ChildElement): Song {
|
|
return {
|
|
itemType: 'song',
|
|
id: song.id,
|
|
album: song.album,
|
|
albumId: song.albumId,
|
|
artist: song.artist,
|
|
artistId: song.artistId,
|
|
title: song.title,
|
|
track: song.track,
|
|
discNumber: song.discNumber,
|
|
duration: song.duration,
|
|
starred: song.starred,
|
|
}
|
|
}
|