import { type App, type Plugin, inject } from 'vue' import { orderBy, sumBy, uniqBy, startCase } from 'lodash-es' import { Md5 } from 'ts-md5'; import { randomString } from './utils' import fallbackImage from './assets/fallback.svg'; import type { Auth, ServerInfo, StreamFormat, Playlist, PlayQueue, Album, AlbumGenre, AlbumSort, Artist, Track, SearchMode, SearchResult, } from './types' export class UnsupportedOperationError extends Error { } export class SubsonicError extends Error { readonly code: string | null constructor(message: string, code: string | null) { super(message) this.name = 'SubsonicError' this.code = code } } export class OfflineError extends Error { constructor() { super('Offline') this.name = 'OfflineError' } } export const useSubsonicApi = (): SubsonicApi => (inject('subsonicApi') as SubsonicApi) export const createSubsonicApi = (): SubsonicApi & Plugin => { const instance = new SubsonicApi() return Object.assign(instance, { install: (app: App) => { app.provide('subsonicApi', instance) } }) } export class SubsonicApi { public static clientName = import.meta.env.VITE_APP_NAME ?? 'Domsonic' public static staticParams = { f: 'json', v: "1.16.1", c: SubsonicApi.clientName, } public serverUrl: string = '' public auth: { u: string, s: string, t: string } | null = null public streamFormat: StreamFormat | null = null public streamBitrate: number | null = null public coverSize: number | null = null private initialized: boolean = false private readonly fetch = async (endpoint: string, params?: any): Promise => { const url = new URL(endpoint, this.serverUrl), searchParams = new URLSearchParams({ ...SubsonicApi.staticParams, ...this.auth, ...params, }), reqParams = { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', }, body: searchParams, }, request = new Request(url, reqParams) try { const response = await fetch(request) if (!response.ok) { if (response.status === 501) throw new UnsupportedOperationError( `Request failed with status ${response.status}` ) throw new Error(`Request failed with status ${response.status}`) } const json = await response.json(), subsonicResponse = json['subsonic-response'] if (subsonicResponse.status !== 'ok') throw new SubsonicError( subsonicResponse.error?.message || subsonicResponse.status, subsonicResponse.error?.code ?? null ); return subsonicResponse } catch (err: any) { if (err instanceof TypeError && !navigator.onLine) { console.info('[Offline mode] Api request skipped:', endpoint) throw new OfflineError() } throw err } } public static createAuth(username: string, password: string): Auth { const salt = randomString() return { username, salt, hash: Md5.hashStr(password + salt), } } constructor() {} setServerUrl = (url: string) => { if (!URL.parse(url)) throw new Error( 'Invalid server url supplied.' ) this.initialized = false this.auth = null this.serverUrl = url } setAuth = (auth: Auth) => { if (!auth) throw new Error( 'Invalid Auth object supplied.' ) this.initialized = false this.auth = { u: auth.username, s: auth.salt, t: auth.hash, } } setStreamFormat = (format: StreamFormat, bitrate: number) => { if (!format) throw new Error( 'Invalid format specified' ) if (typeof bitrate !== 'number') throw new Error( 'Invalid bitrate specified' ) this.streamFormat = format this.streamBitrate = bitrate } setCoverSize = (size: number) => { if (!size) throw new Error( 'Invalid size specified' ) this.coverSize = size } isInitialized = (): boolean => this.initialized checkInitialized = (): boolean => { if (!this.initialized) throw Error( 'Not initialized.' ) return true } initialize = (): boolean => { if (this.serverUrl === '') throw new Error( 'No server-url set.' ) if (!this.auth) throw new Error( 'No credentials set.' ) this.initialized = true return true } async fetchServerInfo(): Promise { this.checkInitialized() const response = await this.fetch('/rest/getOpenSubsonicExtensions') if (!response || response.status !== 'ok' ) throw new Error( response?.error?.message || response?.status || 'Unknown error' ) if (!response?.openSubsonic) throw new Error( 'This server is not OpenSubsonic compatible.' ) return { name: response.type, version: response.version, openSubsonic: true, extensions: (response.openSubsonicExtensions ?? []).map( (ext: any) => ext.name ), } } async isOnline(): Promise { this.checkInitialized() try { const response = await this.fetch('/rest/ping', {}) return response?.status === 'ok' } catch (err) { if (err instanceof OfflineError) return false return false } } async getGenres() { this.checkInitialized() const response = await this.fetch('/rest/getGenres', {}) return (response.genres.genre || []) .map((item: any) => ({ id: item.value, name: item.value, albumCount: item.albumCount ?? 0, trackCount: item.songCount ?? 0, })) .sort((a: any, b:any) => b.albumCount - a.albumCount) } async getAlbumsByGenre( id: string, size: number, offset = 0, random = false, ) { this.checkInitialized() const response = await this.fetch('/rest/getAlbumList2', { type: 'byGenre', genre: id, size, offset, }), albums = (response.albumList2?.album || []).map( this.normalizeAlbum, this, ) if (!random) { // Fisher–Yates shuffle (in-place) for (let i = albums.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)) ;[albums[i], albums[j]] = [albums[j], albums[i]] } } return albums } async getTracksByGenre(id: string, size: number, offset = 0) { this.checkInitialized() const response = await this.fetch('/rest/getSongsByGenre', { genre: id, count: size, offset, }) return (response.songsByGenre?.song || []).map(this.normalizeTrack, this) } async getSimilarTracksByArtist(id: string, size = 50): Promise { this.checkInitialized() const artist = await this.getArtistDetails(id), albums = artist.albums || [] if (!albums.length) return [] const genreWeightMap: Record = {} for (const alb of albums) { for (const g of alb.genres || []) { genreWeightMap[g.name] = (genreWeightMap[g.name] || 0) + 1 } } const weightedGenres = Object.entries(genreWeightMap).flatMap( ([genre, count]) => Array(count).fill(genre) ) if (!weightedGenres.length) return [] const chosenGenre = weightedGenres[Math.floor(Math.random() * weightedGenres.length)] return this.getRandomTracks({ genre: chosenGenre, size }) } async getArtists(): Promise { this.checkInitialized() const response = await this.fetch('/rest/getArtists') return ( (response.artists?.index || []) .flatMap((index: any) => index.artist) .map(this.normalizeArtist, this) ) } async getAlbums(sort: AlbumSort, size: number, offset = 0): Promise { this.checkInitialized() const response = await this.fetch('/rest/getAlbumList2', { type: { 'a-z': 'alphabeticalByName', 'recently-added': 'newest', 'recently-played': 'recent', 'most-played': 'frequent', random: 'random', }[sort], offset, size }) return (response.albumList2?.album || []).map(this.normalizeAlbum, this) } async getArtistDetails(id: string): Promise { this.checkInitialized() const artist = await this.fetch('/rest/getArtist', { id }).then(r => r.artist) return this.normalizeArtist({ topSongs: await this.fetch('/rest/getTopSongs', { artist: artist.name }).then(r => r.topSongs?.song), album: artist.album, ...(await this.fetch('/rest/getArtistInfo2', { id }).then(r => r.artistInfo2)), ...artist, }) } async * getTracksByArtist(id: string): AsyncGenerator { this.checkInitialized() const artist = await this.fetch('/rest/getArtist', { id }).then(r => r.artist), albumIds = orderBy(artist.album || [], x => x.year || 0, 'desc').map(x => x.id), pending = albumIds.map(albumId => this.getAlbumDetails(albumId)) for (const promise of pending) { const { tracks } = await promise if (tracks?.length) yield tracks } } async getAlbumDetails(id: string): Promise { this.checkInitialized() const params = { id }, [info, info2] = await Promise.all([ this.fetch('/rest/getAlbum', params), this.fetch('/rest/getAlbumInfo2', params), ]) return this.normalizeAlbum({ ...info.album, ...info2.albumInfo }) } async getPlaylists() { this.checkInitialized() const response = await this.fetch('/rest/getPlaylists') return (response.playlists?.playlist || []).map(this.normalizePlaylist, this) } async getPlaylist(id: string): Promise { this.checkInitialized() if (id === 'random') { const tracks = await this.getRandomTracks() return { id, name: 'Random', comment: '', createdAt: '', updatedAt: '', duration: sumBy(tracks, 'duration'), isPublic: false, isReadOnly: true, trackCount: tracks.length, tracks, } } const response = await this.fetch('/rest/getPlaylist', { id }) return { ...this.normalizePlaylist(response.playlist), tracks: (response.playlist.entry || []).map(this.normalizeTrack, this), } } async createPlaylist(name: string, tracks?: string[]) { this.checkInitialized() await this.fetch('/rest/createPlaylist', { songId: tracks, name, }) return this.getPlaylists() } async editPlaylist(playlistId: string, name: string, comment: string, isPublic: boolean) { this.checkInitialized() await this.fetch('/rest/updatePlaylist', { playlistId, name, comment, public: isPublic, }) } async deletePlaylist(id: string) { this.checkInitialized() await this.fetch('/rest/deletePlaylist', { id }) } async addToPlaylist(playlistId: string, tracks: string[]) { this.checkInitialized() await this.fetch('/rest/updatePlaylist', { songIdToAdd: tracks, playlistId, }) } async removeFromPlaylist(playlistId: string, index: number) { this.checkInitialized() await this.fetch('/rest/updatePlaylist', { songIndexToRemove: index, playlistId, }) } async getPlayQueue(): Promise { this.checkInitialized() const response = await this.fetch('/rest/getPlayQueue'), tracks = (response.playQueue?.entry || []).map(this.normalizeTrack, this) as Track[], currentTrackId = response.playQueue?.current?.toString(), index = tracks.findIndex(track => track.id === currentTrackId), currentTrack = (index >= 0) ? index : 0 return { currentTrackPosition: (response.playQueue?.position || 0) / 1000, currentTrack, tracks, } } async savePlayQueue( tracks: Track[], currentTrack: Track | null, currentTime: number | null ) { this.checkInitialized() try { const tracksIds = tracks.filter(t => !t.isStream).map(t => t.id) await this.fetch('/rest/savePlayQueue', { id: tracksIds, current: (!currentTrack?.isStream) ? currentTrack?.id : undefined, position: (currentTime !== null) ? Math.round(currentTime * 1000) : undefined, }) } catch (err: any) { if ( err instanceof OfflineError || err.code === 0 || err.code === 10 ) return throw err } } async getRandomTracks( { size = 200, genre, fromYear, toYear, }: { size?: number genre?: string fromYear?: number toYear?: number } = {} ): Promise { this.checkInitialized() const response = await this.fetch('/rest/getRandomSongs', { size, ...genre && { genre }, ...fromYear && { fromYear }, ...toYear && { toYear }, }) return (response.randomSongs?.song || []).map(this.normalizeTrack, this) } async getFavourites() { this.checkInitialized() const response = await this.fetch('/rest/getStarred2') return { albums: (response.starred2?.album || []).map(this.normalizeAlbum, this), artists: (response.starred2?.artist || []).map(this.normalizeArtist, this), tracks: (response.starred2?.song || []).map(this.normalizeTrack, this) } } async getRecentlyPlayedTracks(size = 200) { this.checkInitialized() const albums = await this.getAlbums('recently-played', size) return albums.flatMap(a => a.tracks || []) } async addFavourite(id: string, type: 'track' | 'album' | 'artist') { this.checkInitialized() await this.fetch('/rest/star', { id: type === 'track' ? id : undefined, albumId: type === 'album' ? id : undefined, artistId: type === 'artist' ? id : undefined, }) } async removeFavourite(id: string, type: 'track' | 'album' | 'artist') { this.checkInitialized() await this.fetch('/rest/unstar', { id: type === 'track' ? id : undefined, albumId: type === 'album' ? id : undefined, artistId: type === 'artist' ? id : undefined, }) } async search (query: string, mode: SearchMode, size: number, offset?: number): Promise { this.checkInitialized() const data = await this.fetch('/rest/search3', { query, albumCount: !mode || mode === 'album' ? size : 0, artistCount: !mode || mode === 'artist' ? size : 0, songCount: !mode || mode === 'track' ? size : 0, albumOffset: offset ?? 0, artistOffset: offset ?? 0, songOffset: offset ?? 0, }) return { albums: (data.searchResult3.album || []).map(this.normalizeAlbum, this), artists: (data.searchResult3.artist || []).map(this.normalizeArtist, this), tracks: (data.searchResult3.song || []).map(this.normalizeTrack, this), } } scan = async (): Promise => { this.checkInitialized() return this.fetch('/rest/startScan') } async getScanStatus(): Promise { this.checkInitialized() const response = await this.fetch('/rest/getScanStatus') return response.scanStatus.scanning } async scrobble(id: string): Promise { this.checkInitialized() try { await this.fetch('/rest/scrobble', { id, submission: true }) } catch (err) { if (err instanceof OfflineError) return throw err } } getDownloadUrl = (id: any): string => { this.checkInitialized() const url = new URL('/rest/download', this.serverUrl) url.search = new URLSearchParams({ v: SubsonicApi.staticParams.v, c: SubsonicApi.clientName, ...this.auth, id, }).toString() return url.toString() } getCoverArtUrl = (item: any): string | undefined => { this.checkInitialized() if (!item.coverArt) return fallbackImage const url = new URL('/rest/getCoverArt', this.serverUrl) url.search = new URLSearchParams({ v: SubsonicApi.staticParams.v, c: SubsonicApi.clientName, ...this.auth, size: (this.coverSize ?? 512).toString(), id: item.coverArt, }).toString() return url.toString() } getStreamUrl = (id: any): string => { this.checkInitialized() const url = new URL('/rest/stream', this.serverUrl) url.search = new URLSearchParams({ v: SubsonicApi.staticParams.v, c: SubsonicApi.clientName, ...this.auth, format: this.streamFormat ?? 'raw', maxBitRate: (this.streamBitrate ?? 0).toString(), id, }).toString() return url.toString() } private normalizeTrack = (item: any): Track => ({ id: item.id, title: item.title, duration: item.duration, size: item.size, favourite: !!item.starred, track: item.track, album: item.album, albumId: item.albumId, artists: item.artists?.length ? item.artists : [{ id: item.artistId, name: item.artist }], url: this.getStreamUrl(item.id), image: this.getCoverArtUrl(item), replayGain: (Number.isFinite(item.replayGain?.trackGain) && Number.isFinite(item.replayGain?.albumGain) && item.replayGain?.trackPeak > 0 && item.replayGain?.albumPeak > 0) ? item.replayGain : null, }); private normalizeGenres = (item: any): AlbumGenre[] => ( item.genres?.length ? item.genres : ( item.genre ? [{ name: item.genre }] : [] ) ); private normalizeAlbum = (item: any): Album => ({ id: item.id, name: item.name, description: (item.notes || '').replace(/]*>.*?<\/a>/gm, ''), artists: item.artists?.length ? item.artists : [{ id: item.artistId, name: item.artist }], image: this.getCoverArtUrl(item), year: item.year || 0, favourite: !!item.starred, genres: this.normalizeGenres(item), lastFmUrl: item.lastFmUrl, musicBrainzUrl: item.musicBrainzId ? `https://musicbrainz.org/release/${item.musicBrainzId}` : undefined, tracks: (item.song || []).map(this.normalizeTrack, this), releaseType: this.normalizeReleaseType(item), }); private normalizeReleaseType = (item: any): string => { if (item.isCompilation) return 'COMPILATION' if (!item.releaseTypes?.length || item.releaseTypes[0] === '') return 'ALBUM' const value = item.releaseTypes[0].toUpperCase() return (['ALBUM', 'EP', 'SINGLE', 'COMPILATION'].includes(value)) ? value : startCase(item.releaseTypes[0].toLowerCase()) } private normalizeArtist = (item: any): Artist => { const rawAlbums = item.album ? (Array.isArray(item.album) ? item.album : [item.album]) : [], getAlbumTime = (a: any) => { const released = (a?.released) ? Date.parse(a.released) : NaN if (!isNaN(released)) return released const year = Number(a?.year) if (!isNaN(year)) return new Date(year, 0, 1).getTime() return Number.MIN_SAFE_INTEGER }, sortedAlbums = [...rawAlbums].sort( (a, b) => getAlbumTime(b) - getAlbumTime(a) ); return { id: item.id, name: item.name, description: (item.biography || '').replace(/]*>.*?<\/a>/gm, ''), genres: uniqBy(sortedAlbums.flatMap(this.normalizeGenres, this), 'name'), albumCount: item.albumCount, trackCount: rawAlbums.reduce((acc, a) => acc + (a.songCount || 0), 0), favourite: !!item.starred, lastFmUrl: item.lastFmUrl, musicBrainzUrl: item.musicBrainzId ? `https://musicbrainz.org/artist/${item.musicBrainzId}` : undefined, albums: sortedAlbums.map(a => this.normalizeAlbum(a)), similarArtist: (item.similarArtist || []).map(this.normalizeArtist, this), topTracks: (item.topSongs || []).slice(0, 5).map(this.normalizeTrack, this), image: item.coverArt ? this.getCoverArtUrl(item) : item.artistImageUrl } } private normalizePlaylist = (response: any): Playlist => ({ id: response.id, name: response.name || '(Unnamed)', comment: response.comment || '', owner: response.owner || '', createdAt: response.created || '', updatedAt: response.changed || '', trackCount: response.songCount, duration: response.duration, isPublic: response.public, isReadOnly: false, image: (response.songCount > 0) ? this.getCoverArtUrl(response) : undefined, }) }