mirror of
https://github.com/austinried/subtracks.git
synced 2026-02-10 06:52:43 +01:00
fix view artist from context menu add loading indicators to song list and artist views (show info we have right away)
543 lines
12 KiB
TypeScript
543 lines
12 KiB
TypeScript
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<T> {
|
|
[id: string]: T
|
|
}
|
|
|
|
export type OneToMany = ById<string[]>
|
|
|
|
export interface OrderedById<T> {
|
|
byId: ById<T>
|
|
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<Artist>
|
|
artistInfo: ById<ArtistInfo>
|
|
artistAlbums: OneToMany
|
|
artistNameTopSongs: OneToMany
|
|
|
|
albums: ById<Album>
|
|
albumSongs: OneToMany
|
|
|
|
playlists: ById<Playlist>
|
|
playlistSongs: OneToMany
|
|
|
|
songs: ById<Song>
|
|
}
|
|
|
|
resetLibrary: (state?: WritableDraft<Store>) => void
|
|
|
|
fetchLibraryArtists: () => Promise<void>
|
|
fetchLibraryArtist: (id: string) => Promise<void>
|
|
fetchLibraryArtistInfo: (artistId: string) => Promise<void>
|
|
fetchLibraryArtistTopSongs: (artistName: string) => Promise<void>
|
|
|
|
fetchLibraryAlbum: (id: string) => Promise<void>
|
|
|
|
fetchLibraryPlaylists: () => Promise<void>
|
|
fetchLibraryPlaylist: (id: string) => Promise<void>
|
|
|
|
fetchLibrarySong: (id: string) => Promise<void>
|
|
|
|
fetchLibraryAlbumList: (params: GetAlbumList2Params) => Promise<string[]>
|
|
fetchLibrarySearchResults: (params: Search3Params) => Promise<SearchResults>
|
|
star: (params: StarParams) => Promise<void>
|
|
unstar: (params: StarParams) => Promise<void>
|
|
}
|
|
|
|
function reduceById<T extends { id: string }>(collection: T[]): ById<T> {
|
|
return collection.reduce((acc, value) => {
|
|
acc[value.id] = value
|
|
return acc
|
|
}, {} as ById<T>)
|
|
}
|
|
|
|
function mergeById<T extends { [id: string]: unknown }>(object: T, source: T): void {
|
|
merge(object, source)
|
|
}
|
|
|
|
export function mapById<T>(object: ById<T>, 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<Store>, get: GetState<Store>): 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<GetArtistsResponse>
|
|
try {
|
|
response = await client.getArtists()
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
const artists = response.data.artists.map(mapArtist)
|
|
const artistsById = reduceById(artists)
|
|
|
|
set(
|
|
produce<LibrarySlice>(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<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(
|
|
produce<LibrarySlice>(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<GetArtistInfo2Response>
|
|
try {
|
|
response = await client.getArtistInfo2({ id })
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
const info = mapArtistInfo(id, response.data.artistInfo)
|
|
|
|
set(
|
|
produce<LibrarySlice>(state => {
|
|
state.entities.artistInfo[id] = info
|
|
}),
|
|
)
|
|
},
|
|
|
|
fetchLibraryArtistTopSongs: async artistName => {
|
|
const client = get().client
|
|
if (!client) {
|
|
return
|
|
}
|
|
|
|
let response: SubsonicResponse<GetTopSongsResponse>
|
|
try {
|
|
response = await client.getTopSongs({ artist: artistName, count: 50 })
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
const topSongs = response.data.songs.map(mapSong)
|
|
const topSongsById = reduceById(topSongs)
|
|
|
|
set(
|
|
produce<LibrarySlice>(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<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)
|
|
|
|
set(
|
|
produce<LibrarySlice>(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<GetPlaylistsResponse>
|
|
try {
|
|
response = await client.getPlaylists()
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
const playlists = response.data.playlists.map(mapPlaylist)
|
|
const playlistsById = reduceById(playlists)
|
|
|
|
set(
|
|
produce<LibrarySlice>(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<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)
|
|
|
|
set(
|
|
produce<LibrarySlice>(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<GetSongResponse>
|
|
try {
|
|
response = await client.getSong({ id })
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
const song = mapSong(response.data.song)
|
|
|
|
set(
|
|
produce<LibrarySlice>(state => {
|
|
state.entities.songs[id] = song
|
|
}),
|
|
)
|
|
},
|
|
|
|
fetchLibraryAlbumList: async params => {
|
|
const client = get().client
|
|
if (!client) {
|
|
return []
|
|
}
|
|
|
|
let response: SubsonicResponse<GetAlbumList2Response>
|
|
try {
|
|
response = await client.getAlbumList2(params)
|
|
} catch {
|
|
return []
|
|
}
|
|
|
|
const albums = response.data.albums.map(mapAlbum)
|
|
const albumsById = reduceById(albums)
|
|
|
|
set(
|
|
produce<LibrarySlice>(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<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)
|
|
|
|
set(
|
|
produce<LibrarySlice>(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<LibrarySlice>(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<LibrarySlice>(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
|
|
}
|
|
}),
|
|
)
|
|
},
|
|
})
|