import isMobile from 'ismobilejs' import slugify from 'slugify' import { merge, orderBy, sumBy, take, unionBy, uniqBy } from 'lodash' import { reactive, watch } from 'vue' import { arrayify, isSong, logger, secondsToHumanReadable, use } from '@/utils' import { authService, cache, http } from '@/services' import { albumStore, artistStore, commonStore, overviewStore, playlistStore, preferenceStore } from '@/stores' export type SongUpdateData = { title?: string artist_name?: string album_name?: string album_artist_name?: string track?: number | null disc?: number | null lyrics?: string year?: number | null genre?: string visibility?: 'public' | 'private' | 'unchanged' } export interface SongUpdateResult { songs: Song[] artists: Artist[] albums: Album[] removed: { albums: Pick[] artists: Pick[] } } export interface SongListPaginateParams extends Record { sort: MaybeArray order: SortOrder page: number own_songs_only: boolean } export interface GenreSongListPaginateParams extends Record { sort: MaybeArray order: SortOrder page: number } export const songStore = { vault: new Map(), state: reactive<{ songs: Playable[] }>({ songs: [] }), getFormattedLength: (playables: MaybeArray) => secondsToHumanReadable(sumBy(arrayify(playables), 'length')), byId (id: string) { const song = this.vault.get(id) if (!song) return if (isSong(song) && song.deleted) return return song }, byIds (ids: string[]) { const playables: Playable[] = [] ids.forEach(id => use(this.byId(id), song => playables.push(song!))) return playables }, byAlbum (album: Album) { return Array.from(this.vault.values()).filter(playable => isSong(playable) && playable.album_id === album.id) }, async resolve (id: string) { let playable = this.byId(id) if (!playable) { try { playable = this.syncWithVault(await http.get(`songs/${id}`))[0] } catch (error: unknown) { logger.error(error) } } return playable }, /** * Match a title to a song. * Forget about Levenshtein distance, this implementation is good enough. */ match: (title: string, songs: Song[]) => { title = slugify(title.toLowerCase()) for (const song of songs) { if (slugify(song.title.toLowerCase()) === title) { return song } } return null }, /** * Increase a play count for a playable. */ registerPlay: async (playable: Playable) => { const interaction = await http.silently.post('interaction/play', { song: playable.id }) // Use the data from the server to make sure we don't miss a play from another device. playable.play_count = interaction.play_count }, scrobble: async (song: Song) => { if (!isSong(song)) { throw 'Scrobble is only supported for songs.' } return await http.silently.post(`songs/${song.id}/scrobble`, { timestamp: song.play_start_time }) }, async update (songsToUpdate: Song[], data: SongUpdateData) { if (songsToUpdate.some(song => !isSong(song))) { throw 'Only songs can be updated.' } const result = await http.put('songs', { data, songs: songsToUpdate.map(song => song.id) }) this.syncWithVault(result.songs) albumStore.syncWithVault(result.albums) artistStore.syncWithVault(result.artists) albumStore.removeByIds(result.removed.albums.map(album => album.id)) artistStore.removeByIds(result.removed.artists.map(artist => artist.id)) return result }, getSourceUrl: (playable: Playable) => { return isMobile.any && preferenceStore.transcode_on_mobile ? `${commonStore.state.cdn_url}play/${playable.id}/1/128?t=${authService.getAudioToken()}` : `${commonStore.state.cdn_url}play/${playable.id}?t=${authService.getAudioToken()}` }, getShareableUrl: (song: Playable) => `${window.BASE_URL}#/song/${song.id}`, syncWithVault (playables: MaybeArray) { return arrayify(playables).map(playable => { let local = this.byId(playable.id) if (local) { merge(local, playable) } else { local = reactive(playable) local.playback_state = 'Stopped' this.watchPlayCount(local) this.vault.set(local.id, local) } return local }) }, watchPlayCount: (playable: Playable) => { watch(() => playable.play_count, () => overviewStore.refreshPlayStats()) }, ensureNotDeleted: (songs: MaybeArray) => arrayify(songs).filter(({ deleted }) => !deleted), async fetchForAlbum (album: Album | number) { const id = typeof album === 'number' ? album : album.id return this.ensureNotDeleted(await cache.remember( [`album.songs`, id], async () => this.syncWithVault(await http.get(`albums/${id}/songs`)) )) }, async fetchForArtist (artist: Artist | number) { const id = typeof artist === 'number' ? artist : artist.id return this.ensureNotDeleted(await cache.remember( [`artist.songs`, id], async () => this.syncWithVault(await http.get(`artists/${id}/songs`)) )) }, async fetchForPlaylist (playlist: Playlist | Playlist['id'], refresh = false) { const id = typeof playlist === 'string' ? playlist : playlist.id if (refresh) { cache.remove(['playlist.songs', id]) } const songs = this.ensureNotDeleted(await cache.remember( [`playlist.songs`, id], async () => this.syncWithVault(await http.get(`playlists/${id}/songs`)) )) playlistStore.byId(id)!.playables = songs return songs }, async fetchForPlaylistFolder (folder: PlaylistFolder) { const playables: Playable[] = [] for await (const playlist of playlistStore.byFolder(folder)) { playables.push(...await songStore.fetchForPlaylist(playlist)) } return uniqBy(playables, 'id') }, async fetchForPodcast (podcast: Podcast | string, refresh = false) { const id = typeof podcast === 'string' ? podcast : podcast.id if (refresh) { cache.remove(['podcast.episodes', id]) } return await cache.remember( [`podcast.episodes`, id], async () => this.syncWithVault(await http.get(`podcasts/${id}/episodes${refresh ? '?refresh=1' : ''}`)) ) }, async paginateForGenre (genre: Genre | Genre['name'], params: GenreSongListPaginateParams) { const name = typeof genre === 'string' ? genre : genre.name const resource = await http.get(`genres/${name}/songs?${new URLSearchParams(params).toString()}`) const songs = this.syncWithVault(resource.data) return { songs, nextPage: resource.links.next ? ++resource.meta.current_page : null } }, async fetchRandomForGenre (genre: Genre | Genre['name'], limit = 500) { const name = typeof genre === 'string' ? genre : genre.name return this.syncWithVault(await http.get(`genres/${name}/songs/random?limit=${limit}`)) }, async paginate (params: SongListPaginateParams) { const resource = await http.get(`songs?${new URLSearchParams(params).toString()}`) this.state.songs = unionBy(this.state.songs, this.syncWithVault(resource.data), 'id') return resource.links.next ? ++resource.meta.current_page : null }, getMostPlayed (count: number) { return take( orderBy( Array .from(this.vault.values()) .filter(playable => isSong(playable) && !playable.deleted && playable.play_count > 0), 'play_count', 'desc' ), count ) }, async deleteFromFilesystem (songs: Song[]) { const ids = songs.map(song => { // Whenever a vault sync is requested (e.g. upon playlist/album/artist fetching) // songs marked as "deleted" will be excluded. song.deleted = true return song.id }) await http.delete('songs', { songs: ids }) }, async publicize (songs: Song[]) { if (songs.some(song => !isSong(song))) { throw 'This action is only supported for songs.' } await http.put('songs/publicize', { songs: songs.map(song => song.id) }) songs.forEach(song => (song.is_public = true)); }, async privatize (songs: Song[]) { if (songs.some(song => !isSong(song))) { throw 'This action is only supported for songs.' } const privatizedIds = await http.put('songs/privatize', { songs: songs.map(({ id }) => id) }) privatizedIds.forEach(id => { const song = this.byId(id) as Song song && (song.is_public = false) }) return privatizedIds } }