import { 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, SubsonicResponse, } from '@app/subsonic/responses' import produce from 'immer' import { WritableDraft } from 'immer/dist/types/types-external' import merge from 'lodash.merge' import pick from 'lodash.pick' import { GetState, SetState } from 'zustand' export interface ById { [id: string]: T } export type OneToMany = ById export interface OrderedById { byId: ById allIds: string[] } export interface PaginatedList { [offset: number]: string[] } export interface Artist { itemType: 'artist' id: string name: string starred?: Date coverArt?: string } export interface ArtistInfo { id: string largeImageUrl?: string } export interface Album { itemType: 'album' id: string name: string artist?: string artistId?: string starred?: Date coverArt?: string year?: number } export interface Playlist { itemType: 'playlist' id: string name: string comment?: string coverArt?: string } export interface Song { itemType: 'song' id: string album?: string albumId?: string artist?: string artistId?: string title: string track?: number discNumber?: number duration?: number starred?: Date coverArt?: string } export interface SearchResults { artists: string[] albums: string[] songs: string[] } 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, 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, coverArt: song.coverArt, } } function mapId(entities: { id: string }[]): string[] { return entities.map(e => e.id) } export type LibrarySlice = { entities: { artists: ById artistInfo: ById artistAlbums: OneToMany artistNameTopSongs: OneToMany albums: ById albumSongs: OneToMany playlists: ById playlistSongs: OneToMany songs: ById } resetLibrary: (state?: WritableDraft) => void fetchLibraryArtists: () => Promise fetchLibraryArtist: (id: string) => Promise fetchLibraryArtistInfo: (artistId: string) => Promise fetchLibraryArtistTopSongs: (artistName: string) => Promise fetchLibraryAlbum: (id: string) => Promise fetchLibraryPlaylists: () => Promise fetchLibraryPlaylist: (id: string) => Promise fetchLibrarySong: (id: string) => Promise fetchLibraryAlbumList: (params: GetAlbumList2Params) => Promise fetchLibrarySearchResults: (params: Search3Params) => Promise star: (params: StarParams) => Promise unstar: (params: StarParams) => Promise } function reduceById(collection: T[]): ById { return collection.reduce((acc, value) => { acc[value.id] = value return acc }, {} as ById) } function mergeById(object: T, source: T): void { merge(object, source) } export function mapById(object: ById, ids: string[]): T[] { return ids.map(id => object[id]).filter(a => a !== undefined) } const defaultEntities = () => ({ artists: {}, artistAlbums: {}, artistInfo: {}, artistNameTopSongs: {}, albums: {}, albumSongs: {}, playlists: {}, playlistSongs: {}, songs: {}, }) export const createLibrarySlice = (set: SetState, get: GetState): LibrarySlice => ({ entities: defaultEntities(), resetLibrary: state => { if (state) { state.entities = defaultEntities() return } set(store => { store.entities = defaultEntities() }) }, fetchLibraryArtists: async () => { const client = get().client if (!client) { return } let response: SubsonicResponse try { response = await client.getArtists() } catch { return } const artists = response.data.artists.map(mapArtist) const artistsById = reduceById(artists) set( produce(state => { state.entities.artists = artistsById state.entities.artistAlbums = pick(state.entities.artistAlbums, mapId(artists)) }), ) }, fetchLibraryArtist: async id => { const client = get().client if (!client) { return } let response: SubsonicResponse 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( produce(state => { state.entities.artists[id] = artist state.entities.artistAlbums[id] = mapId(albums) mergeById(state.entities.albums, albumsById) }), ) }, fetchLibraryArtistInfo: async id => { const client = get().client if (!client) { return } let response: SubsonicResponse try { response = await client.getArtistInfo2({ id }) } catch { return } const info = mapArtistInfo(id, response.data.artistInfo) set( produce(state => { state.entities.artistInfo[id] = info }), ) }, fetchLibraryArtistTopSongs: async artistName => { const client = get().client if (!client) { return } let response: SubsonicResponse try { response = await client.getTopSongs({ artist: artistName, count: 50 }) } catch { return } const topSongs = response.data.songs.map(mapSong) const topSongsById = reduceById(topSongs) set( produce(state => { mergeById(state.entities.songs, topSongsById) state.entities.artistNameTopSongs[artistName] = mapId(topSongs) }), ) }, fetchLibraryAlbum: async id => { const client = get().client if (!client) { return } let response: SubsonicResponse 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) set( produce(state => { state.entities.albums[id] = album state.entities.albumSongs[id] = mapId(songs) mergeById(state.entities.songs, songsById) }), ) }, fetchLibraryPlaylists: async () => { const client = get().client if (!client) { return } let response: SubsonicResponse try { response = await client.getPlaylists() } catch { return } const playlists = response.data.playlists.map(mapPlaylist) const playlistsById = reduceById(playlists) set( produce(state => { state.entities.playlists = playlistsById state.entities.playlistSongs = pick(state.entities.playlistSongs, mapId(playlists)) }), ) }, fetchLibraryPlaylist: async id => { const client = get().client if (!client) { return } let response: SubsonicResponse 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) set( produce(state => { state.entities.playlists[id] = playlist state.entities.playlistSongs[id] = mapId(songs) mergeById(state.entities.songs, songsById) }), ) }, fetchLibrarySong: async id => { const client = get().client if (!client) { return } let response: SubsonicResponse try { response = await client.getSong({ id }) } catch { return } const song = mapSong(response.data.song) set( produce(state => { state.entities.songs[id] = song }), ) }, fetchLibraryAlbumList: async params => { const client = get().client if (!client) { return [] } let response: SubsonicResponse try { response = await client.getAlbumList2(params) } catch { return [] } const albums = response.data.albums.map(mapAlbum) const albumsById = reduceById(albums) set( produce(state => { mergeById(state.entities.albums, albumsById) }), ) return mapId(albums) }, fetchLibrarySearchResults: async params => { const empty = { artists: [], albums: [], songs: [] } const client = get().client if (!client) { return empty } let response: SubsonicResponse 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) set( produce(state => { mergeById(state.entities.artists, artistsById) mergeById(state.entities.albums, albumsById) mergeById(state.entities.songs, songsById) }), ) return { artists: mapId(artists), albums: mapId(albums), songs: mapId(songs), } }, star: async params => { const client = get().client if (!client) { return } try { await client.star(params) } catch { return } set( produce(state => { if (params.id) { state.entities.songs[params.id].starred = new Date() } else if (params.albumId) { state.entities.albums[params.albumId].starred = new Date() } else if (params.artistId) { state.entities.artists[params.artistId].starred = new Date() } }), ) }, unstar: async params => { const client = get().client if (!client) { return } try { await client.unstar(params) } catch { return } set( produce(state => { if (params.id) { state.entities.songs[params.id].starred = undefined } else if (params.albumId) { state.entities.albums[params.albumId].starred = undefined } else if (params.artistId) { state.entities.artists[params.artistId].starred = undefined } }), ) }, })