Something went wrong. Try again.
a simple but full-featured web player for OpenSubsonic music servers (Navidrome, Gonic, Ampache, etc) devins.page/tinysub
svelte typescript javascript subsonic gonic navidrome
Something went wrong. Try again.
TypeScript
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160import { SvelteMap } from "svelte/reactivity";import { buildApiParams } from "./apiParams.js";import { normalizeSongArt, rememberAlbumArt, songArtId } from "./artwork.js";import type { InfoItemType } from "./info.js";import type { OfflineAlbum, QueuedRequest,} from "./offline/indexedDbStore.svelte.js";import { cacheArt, getCoverArtUrl, offlineManager, store,} from "./offline/offlineManager.svelte.js";import { updateBackgroundSync } from "./pwa.svelte.js";import { effectiveAutoSyncMode, settings } from "./settings.svelte.js";import { getSource, sourceStorageKey, sources } from "./sources.svelte.js";
export type AlbumListType = | "random" | "newest" | "highest" | "frequent" | "recent" | "alphabeticalByName" | "alphabeticalByArtist" | "starred" | "byYear" | "byGenre";
export type AlbumListParams = { fromYear?: number; toYear?: number; genre?: string; size?: number; offset?: number;};
export interface Artist { id: string; sourceId?: string; name: string; coverArt?: string; starred?: string; userRating?: number;}
export interface Album { id: string; sourceId?: string; name: string; song?: Song | Song[]; coverArt?: string; artistId?: string; artist?: string; displayArtist?: string; year?: number; starred?: string; userRating?: number;}
interface ArtistWithAlbums extends Artist { album?: Album | Album[];}
interface InfoBase { musicBrainzId?: string; lastFmUrl?: string; smallImageUrl?: string; mediumImageUrl?: string; largeImageUrl?: string;}
export interface AlbumInfo extends InfoBase { notes?: string;}
interface SimilarArtist { id: string; sourceId?: string; name: string; coverArt?: string; artistImageUrl?: string; starred?: string;}
export interface ArtistInfo extends InfoBase { biography?: string; similarArtist?: SimilarArtist[];}
interface InfoResponse { artist?: ArtistWithAlbums; album?: Album; song?: Song;}
export interface Playlist { id: string; sourceId?: string; name: string; coverArt?: string; readonly?: boolean; owner?: string; public?: boolean;}export interface Share { id: string; sourceId?: string; url: string; coverArt?: string; description?: string; username: string; created: string; visitCount: number; expires?: string; entry?: Song[];}
interface LyricsLine { start: string; value: string;}
export interface LyricsResponse { lyrics?: { value?: string } | string; lyricsList?: { structuredLyrics?: Array<{ synced?: boolean; line?: LyricsLine[]; }>; };}
export interface Song { id: string; sourceId?: string; title: string; artist: string; displayArtist?: string; artists?: { id: string; name: string }[]; album: string; genre?: string; genres?: { name: string }[]; duration?: number; coverArt?: string; albumId?: string; artistId?: string; albumArtist?: string; albumArtistId?: string; displayAlbumArtist?: string; albumArtists?: { id: string; name: string }[]; starred?: string; userRating?: number; discNumber?: number; track?: number; year?: number; musicBrainzId?: string; playCount?: number; playcount?: number; bitRate?: number; contentType?: string; suffix?: string; replayGain?: { trackGain?: number; albumGain?: number; trackPeak?: number; albumPeak?: number; baseGain?: number; fallbackGain?: number; }; lyrics?: LyricsResponse; accessed?: number; _origStarred?: string; _origUserRating?: number; _radioStreamUrl?: string;}
export interface InternetRadioStation { id: string; sourceId?: string; name: string; streamUrl: string; homePageUrl?: string; coverArt?: string;}
export const isRadio = (song: Song) => !!song._radioStreamUrl;export const songArtist = (song: Song) => song.displayArtist || song.artist;export const songAlbumArtist = (song: Song) => song.displayAlbumArtist || song.albumArtist || song.albumArtists?.map((artist) => artist.name).join(", ") || songArtist(song);export { songArtId };
export const canPlayTrack = (song: Song) => !offlineManager.isSourceOffline(song.sourceId) || (!isRadio(song) && offlineManager.isSongDownloaded(song));
export const radioStationToSong = (station: InternetRadioStation): Song => ({ id: `radio-${station.id}`, title: station.name, artist: "", album: "", coverArt: station.coverArt, sourceId: station.sourceId, _radioStreamUrl: station.streamUrl,});
const sourceCredentials = (sourceId: string) => { const credentials = getSource(sourceId)?.credentials; if (!credentials) throw new Error("Auth required"); return credentials;};
const base = (sourceId: string) => sourceCredentials(sourceId).server;
export const asArray = <T>(value: T | T[] | null | undefined): T[] => Array.isArray(value) ? value : value == null ? [] : [value];
const getParams = (sourceId: string, params: Record<string, unknown> = {}) => { return buildApiParams(sourceCredentials(sourceId), params);};
const sourceAlbum = (album: Album, sourceId: string) => ({ ...album, sourceId, ...(album.song === undefined ? {} : { song: asArray(album.song).map((song) => ({ ...song, sourceId })), }),});
const openSubsonicSources = new Set<string>();const sourceExtensions = new SvelteMap<string, Set<string>>();
const extensionsFor = (sourceId: string) => sourceExtensions.get(sourceId) ?? new Set<string>();
export const hasSourceExtension = (sourceId: string, name: string) => extensionsFor(sourceId).has(name.toLowerCase());
class ApiError extends Error { constructor( message: string, readonly code?: number, readonly httpStatus?: number, ) { super(message); this.name = "ApiError"; }}
export const isNetworkError = (err: unknown) => { if (err instanceof ApiError && (err.httpStatus ?? 0) >= 500) return true; const message = String(err); return ( message.includes("Failed to fetch") || message.includes("Load failed") || message.includes("NetworkError") || message.includes("ERR_INTERNET_DISCONNECTED") );};
export const isAuthenticationError = (err: unknown) => err instanceof ApiError && (err.httpStatus === 401 || [40, 41, 42, 43, 44].includes(err.code ?? -1));
const requestSource = async ( sourceId: string, method: string, params: Record<string, unknown> = {}, isPost = false,) => { const usePost = isPost && extensionsFor(sourceId).has("formpost"); try { const response = await fetch( usePost ? `${base(sourceId)}/rest/${method}` : `${base(sourceId)}/rest/${method}?${getParams(sourceId, params)}`, usePost ? { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: getParams(sourceId, params).toString(), } : {}, ); if (!response.ok) { throw new ApiError( `server returned HTTP ${response.status}`, undefined, response.status, ); } const body = await response.json(); const data = body?.["subsonic-response"]; if (!data || typeof data !== "object" || typeof data.status !== "string") { throw new ApiError("invalid Subsonic response"); } if (data.status === "failed") { throw new ApiError( typeof data.error?.message === "string" ? data.error.message : "API error", typeof data.error?.code === "number" ? data.error.code : undefined, ); } if (data.openSubsonic) openSubsonicSources.add(sourceId); else openSubsonicSources.delete(sourceId); return data; } catch (err) { if (isNetworkError(err)) { console.warn("server unreachable, entering offline mode:", err); offlineManager.setOffline(sourceId); } throw err; }};
export const songCache = new Map<string, Song>();
export const queuedSongState = (sourceId?: string) => { const state = new Map< string, { starred?: string | null; userRating?: number } >(); for (const req of offlineManager.queuedRequests) { if ( req.method !== "star" && req.method !== "unstar" && req.method !== "setRating" ) continue; if (sourceId && req.sourceId !== sourceId) continue; const id = req.params.id; if (!id) continue; const pending = state.get(id) ?? {}; if (req.method === "star") pending.starred = new Date().toISOString(); else if (req.method === "unstar") pending.starred = null; else if (req.method === "setRating") pending.userRating = req.params.rating; state.set(id, pending); } return state;};
export const internSongBatch = ( songs: Array< Song & { replayGainTrackGain?: number; replayGainAlbumGain?: number; replayGainTrackPeak?: number; replayGainAlbumPeak?: number; replayGainBaseGain?: number; replayGainFallbackGain?: number; } >, sourceId?: string,): Song[] => { const resolvedSource = sourceId ?? songs[0]?.sourceId ?? sources.activeId; return songs.map((raw) => { const itemSource = raw.sourceId ?? resolvedSource; raw.sourceId ??= itemSource || undefined; const pending = queuedSongState(itemSource || undefined); normalizeSongArt(raw); if ( !raw.replayGain && (raw.replayGainTrackGain !== undefined || raw.replayGainAlbumGain !== undefined || raw.replayGainTrackPeak !== undefined || raw.replayGainAlbumPeak !== undefined || raw.replayGainBaseGain !== undefined || raw.replayGainFallbackGain !== undefined) ) { raw.replayGain = { trackGain: raw.replayGainTrackGain, albumGain: raw.replayGainAlbumGain, trackPeak: raw.replayGainTrackPeak, albumPeak: raw.replayGainAlbumPeak, baseGain: raw.replayGainBaseGain, fallbackGain: raw.replayGainFallbackGain, }; } const cached = itemSource ? songCache.get(sourceStorageKey(itemSource, raw.id)) : undefined; if (cached) { if (raw.starred !== undefined || cached._origStarred === undefined) { cached._origStarred = raw.starred; } if ( raw.userRating !== undefined || cached._origUserRating === undefined ) { cached._origUserRating = raw.userRating; } Object.assign(cached, raw); applyQueuedSongState(cached, pending); return cached; } const fresh = $state({ ...raw, _origStarred: raw.starred, _origUserRating: raw.userRating, }); applyQueuedSongState(fresh, pending); if (itemSource) songCache.set(sourceStorageKey(itemSource, raw.id), fresh); return fresh; });};
const rememberAlbums = (albums: OfflineAlbum[], sourceId: string) => { for (const album of albums) { album.sourceId ??= sourceId; rememberAlbumArt(album); if ( !settings.downloads || offlineManager.isSourceOffline(sourceId) || !offlineManager.isAlbumDownloaded(album.id, sourceId) ) { continue; } if (album.coverArt) void cacheArt(album.coverArt, 512, sourceId); void store.putAlbum(album, sourceId).catch((err: unknown) => { console.error("failed to update cached album:", album.id, err); }); }};
const rememberArtists = (artists: Artist[], sourceId: string) => { for (const artist of artists) { artist.sourceId ??= sourceId; if ( !settings.downloads || !offlineManager.isArtistDownloaded(artist.id, sourceId) ) { continue; } if (artist.coverArt) void cacheArt(artist.coverArt, 128, sourceId); void store.putArtist(artist, sourceId).catch((err: unknown) => { console.error("failed to update cached artist:", artist.id, err); }); }};
const internSongs = async (songs: Song[], sourceId: string) => { if (settings.downloads && offlineManager.dbInitialized) { const albumIds = new Set( songs.flatMap((song) => (song.albumId ? [song.albumId] : [])), ); const albums = await Promise.all( [...albumIds].map((albumId) => store.getAlbum(albumId, sourceId)), ); for (const album of albums) { if (album) rememberAlbumArt(album); } } return internSongBatch(songs, sourceId);};
export const getOnlineCoverArt = ( id: string, size = 128, sourceId = sources.activeId,) => { if (!sourceId || !id) return ""; const params = getParams(sourceId, { id, size }); params.set("__tinysub_source", sourceId); return `${base(sourceId)}/rest/getCoverArt?${params}`;};
const applyQueuedSongState = ( song: Song, pending: ReturnType<typeof queuedSongState>,) => { const state = pending.get(song.id); song.starred = state?.starred !== undefined ? state.starred || undefined : song._origStarred; song.userRating = state?.userRating ?? song._origUserRating;};
const compareNames = (a = "", b = "") => { const bucket = (value = "") => { const first = value.trim()[0] || ""; if (!/[A-Za-z]/.test(first)) return 0; if (/[A-Z]/.test(first)) return 1; return 2; }; const bucketDiff = bucket(a) - bucket(b); if (bucketDiff) return bucketDiff; return a.localeCompare(b, undefined, { sensitivity: "variant" });};
const createApi = (resolveSourceId: () => string | null) => { const sourceId = () => { const id = resolveSourceId(); if (!id) throw new Error("Library required"); return id; }; const request = ( method: string, params: Record<string, unknown> = {}, isPost = false, ) => requestSource(sourceId(), method, params, isPost); const hasExtension = (name: string) => hasSourceExtension(sourceId(), name); const getCoverArt = (id: string, size = 128) => getCoverArtUrl(id, size, sourceId()); const sourceOffline = () => offlineManager.isSourceOffline(sourceId()); const client = { ping: async () => { if (sourceOffline()) return { status: "ok" }; return request("ping"); }, forcePing: async () => request("ping"), getInfo: async (type: InfoItemType, id: string): Promise<InfoResponse> => { const method = type === "artist" ? "getArtist" : type === "album" ? "getAlbum" : type === "playlist" ? "getPlaylist" : type === "song" ? "getSong" : type === "share" ? "getShares" : "getInternetRadioStations"; const currentSource = sourceId(); const response = await request( method, type === "share" || type === "radio" ? {} : { id }, ); return { ...response, artist: response.artist ? { ...response.artist, sourceId: currentSource, album: asArray(response.artist.album).map((album) => sourceAlbum(album, currentSource), ), } : undefined, album: response.album ? sourceAlbum(response.album, currentSource) : undefined, song: response.song ? { ...response.song, sourceId: currentSource } : undefined, }; }, getAlbumInfo2: async (id: string): Promise<AlbumInfo | null> => { const response = await request("getAlbumInfo2", { id }); return response.albumInfo || null; }, getArtistInfo2: async (id: string): Promise<ArtistInfo | null> => { const response = await request("getArtistInfo2", { id }); const info = response.artistInfo2 as ArtistInfo | undefined; if (!info) return null; const currentSource = sourceId(); return { ...info, similarArtist: asArray(info.similarArtist).map((artist) => ({ ...artist, sourceId: currentSource, })), }; }, getTopSongs: async (artist: string, id?: string): Promise<Song[]> => { const params = id && hasExtension("topsongsbyartistid") ? { id, count: 100 } : { artist, count: 100 }; const response = await request("getTopSongs", params); return internSongs(asArray(response.topSongs?.song), sourceId()); }, getSonicSimilarTracks: async (id: string): Promise<Song[]> => { if (!hasExtension("sonicsimilarity")) return []; const response = await request("getSonicSimilarTracks", { id, count: 10, }); return internSongs( asArray(response.sonicMatch).flatMap((match: { entry?: Song }) => match.entry ? [match.entry] : [], ), sourceId(), ); }, getArtists: async () => { if (sourceOffline()) { return (await store.getAllArtists(sourceId())).sort((a, b) => compareNames(a.name, b.name), ); } const response = await request("getArtists"); const currentSource = sourceId(); const artists: Artist[] = asArray(response.artists?.index).flatMap( (index: { artist?: Artist | Artist[] }) => asArray(index.artist), ); for (const artist of artists) artist.sourceId = currentSource; rememberArtists(artists, currentSource); return artists; }, getPlaylists: async () => { if (sourceOffline()) { return (await store.getAllPlaylists(sourceId())).sort((a, b) => compareNames(a.name, b.name), ); } return request("getPlaylists").then(async (response) => { const currentSource = sourceId(); const deletedIds = new Set( offlineManager.queuedRequests .filter( ( req, ): req is Extract<QueuedRequest, { method: "deletePlaylist" }> => req.sourceId === currentSource && req.method === "deletePlaylist", ) .map((req) => req.params.id) .filter(Boolean), ); let list = asArray(response.playlists?.playlist) .map((playlist) => ({ ...playlist, sourceId: currentSource })) .filter((playlist) => !deletedIds.has(playlist.id));
if ( offlineManager.queuedRequests.some( (req) => req.sourceId === currentSource && req.method === "createPlaylist" && req.params?.name, ) ) { list = [ ...list, ...(await store.getAllPlaylists(currentSource)).filter( (playlist: Playlist) => playlist.id.startsWith("local_playlist_") && !deletedIds.has(playlist.id), ), ]; }
void offlineManager.refreshPlaylistCache(list); return list; }); }, getArtistAlbums: async (id: string): Promise<Album[]> => { if (sourceOffline()) { const albums = await store.getAllAlbums(sourceId()); return albums .filter( (album: OfflineAlbum) => album.artistId === id || album.artistName === id, ) .sort((a, b) => { const yearDiff = (a.year || 0) - (b.year || 0); return yearDiff || compareNames(a.name, b.name); }); } const response = await request("getArtist", { id }); const currentSource = sourceId(); const albums: OfflineAlbum[] = asArray(response.artist.album).map( (album) => ({ ...album, sourceId: currentSource, }), ); rememberAlbums(albums, currentSource); return albums; }, getAlbum: async (id: string) => { if (sourceOffline()) { const currentSource = sourceId(); const songs = await store.getAllSongs(currentSource); return internSongs( songs .filter((song: Song) => song.albumId === id) .sort( (a, b) => (a.track || 0) - (b.track || 0) || (a.discNumber || 0) - (b.discNumber || 0) || compareNames(a.title, b.title), ), currentSource, ); } const response = await request("getAlbum", { id }); const currentSource = sourceId(); rememberAlbums( [{ ...response.album, sourceId: currentSource }], currentSource, ); return internSongs(asArray(response.album.song), currentSource); }, getPlaylist: async (id: string) => { const currentSource = sourceId(); if (sourceOffline() || id.startsWith("local_playlist_")) { const playlist = await store.getPlaylist(id, currentSource); return internSongs(playlist?.entry || [], currentSource); } return request("getPlaylist", { id }).then(async (response) => { let songs = await internSongs( asArray(response.playlist.entry), currentSource, );
for (const req of offlineManager.queuedRequests.filter( ( req, ): req is Extract< (typeof offlineManager.queuedRequests)[number], { method: "updatePlaylist" } > => req.sourceId === currentSource && req.method === "updatePlaylist" && req.params?.playlistId === id, )) { const { songIdToAdd, songIndexToRemove } = req.params; if (songIndexToRemove?.length) { const removedIndices = new Set(songIndexToRemove); songs = songs.filter((_, idx) => !removedIndices.has(idx)); } if (songIdToAdd?.length) { const addedSongs = ( await Promise.all( songIdToAdd.map((id) => store.getSong(id, currentSource)), ) ).filter((song: Song | null): song is Song => song !== null); songs = [ ...songs, ...(await internSongs(addedSongs, currentSource)), ]; } }
const playlist = response.playlist; if (settings.downloads) { if (playlist.coverArt) void cacheArt(playlist.coverArt, 128, currentSource); void store .putPlaylist( { id: playlist.id, sourceId: currentSource, name: playlist.name, coverArt: playlist.coverArt, readonly: playlist.readonly, owner: playlist.owner, public: playlist.public, entry: songs, }, currentSource, ) .catch((err: unknown) => { console.error( "failed to cache playlist entries:", playlist.id, err, ); }); } return songs; }); }, getAlbumList2: async ( type: AlbumListType, params: AlbumListParams = {}, ) => { const response = await request("getAlbumList2", { type, ...params, size: params.size ?? 500, }); const albums: OfflineAlbum[] = asArray(response.albumList2?.album); rememberAlbums(albums, sourceId()); return albums; }, getRandomSongs: async (params: AlbumListParams = {}) => { const response = await request("getRandomSongs", { ...params, size: params.size ?? 500, }); return internSongs(asArray(response.randomSongs?.song), sourceId()); }, search3: async (query: string) => { if (sourceOffline()) { const results = await store.search(query, sourceId()); rememberAlbums(results.album, sourceId()); results.song = await internSongs(results.song, sourceId()); return results; } const response = await request("search3", { query, artistCount: 100, albumCount: 100, songCount: 100, }); const results = response.searchResult3 || {}; const currentSource = sourceId(); if (results.artist) results.artist = asArray(results.artist).map((artist) => ({ ...artist, sourceId: currentSource, })); if (results.album) rememberAlbums(asArray(results.album), sourceId()); if (results.song) results.song = await internSongs(asArray(results.song), sourceId()); return results; }, stream: ( id: string, params: { maxBitRate?: number; format?: string; timeOffset?: number; } = {}, ) => `${base(sourceId())}/rest/stream?${getParams(sourceId(), { id, ...params })}`, download: (id: string) => `${base(sourceId())}/rest/download?${getParams(sourceId(), { id })}`, getCoverArt, star: (params: { id?: string; albumId?: string; artistId?: string }) => { if (sourceOffline()) { return offlineManager.queueRequest("star", params, false, sourceId()); } return request("star", params); }, unstar: (params: { id?: string; albumId?: string; artistId?: string }) => { if (sourceOffline()) { return offlineManager.queueRequest("unstar", params, false, sourceId()); } return request("unstar", params); }, setRating: (id: string, rating: number) => { if (sourceOffline()) { return offlineManager.queueRequest( "setRating", { id, rating }, false, sourceId(), ); } return request("setRating", { id, rating }); }, getLyricsBySongId: async (id: string) => { if (sourceOffline()) { return (await store.getSong(id, sourceId()))?.lyrics || null; } return request("getLyricsBySongId", { id }); }, getLyrics: async (artist: string, title: string) => { if (sourceOffline()) { const song = (await store.getAllSongs(sourceId())).find( (song: Song) => songArtist(song) === artist && song.title === title, ); return song?.lyrics || null; } return request("getLyrics", { artist, title }); }, createPlaylist: (name?: string, songId?: string[], playlistId?: string) => { if (sourceOffline()) { const isNew = !playlistId; const targetId = playlistId || `local_playlist_${Date.now()}`; offlineManager.createLocalPlaylistOffline( name, songId, targetId, sourceId(), ); return offlineManager.queueRequest( "createPlaylist", { name, songId, playlistId: targetId, isNew }, true, sourceId(), ); } return request("createPlaylist", { name, songId, playlistId }, true).then( (response) => { const refreshId = playlistId || response.playlist?.id; if (refreshId) { void client.getPlaylist(refreshId).catch((err: unknown) => { console.error( "failed to refresh playlist after create:", refreshId, err, ); }); } return response; }, ); }, updatePlaylist: ( playlistId: string, name?: string, songIdToAdd?: string[], songIndexToRemove?: number[], isPublic?: boolean, ) => { if (sourceOffline()) { offlineManager.updateLocalPlaylistOffline( playlistId, name, songIdToAdd, songIndexToRemove, isPublic, sourceId(), ); return offlineManager.queueRequest( "updatePlaylist", { playlistId, name, songIdToAdd, songIndexToRemove, public: isPublic, }, true, sourceId(), ); } return request( "updatePlaylist", { playlistId, name, songIdToAdd, songIndexToRemove, public: isPublic, }, true, ).then((response) => { void client.getPlaylist(playlistId).catch((err: unknown) => { console.error( "failed to refresh playlist after update:", playlistId, err, ); }); return response; }); }, deletePlaylist: (id: string) => { if (sourceOffline()) { offlineManager.deleteLocalPlaylistOffline(id, sourceId()); return offlineManager.queueRequest( "deletePlaylist", { id }, false, sourceId(), ); } return request("deletePlaylist", { id }).then((response) => { void store.deletePlaylist(id, sourceId()).catch((err: unknown) => { console.error("failed to delete cached playlist:", id, err); }); return response; }); }, getOpenSubsonicExtensions: () => { if (sourceOffline()) return Promise.resolve({ openSubsonicExtensions: [] }); return request("getOpenSubsonicExtensions"); }, savePlayQueue: ( id: string[], current?: string | number, position?: number, ) => { if (sourceOffline()) return Promise.resolve(); if (hasExtension("indexbasedqueue")) { let currentIdx: number | undefined; if (typeof current === "number") { currentIdx = current; } else if (typeof current === "string") { const idx = id.indexOf(current); currentIdx = idx !== -1 ? idx : undefined; } return request( "savePlayQueueByIndex", { id, currentIndex: currentIdx, position }, true, ); } else { let currentId: string | undefined; if (typeof current === "string") { currentId = current; } else if (typeof current === "number") { currentId = id[current]; } return request( "savePlayQueue", { id, current: currentId, position }, true, ); } }, getPlayQueue: () => { if (sourceOffline()) return Promise.resolve({}); if (hasExtension("indexbasedqueue")) { return request("getPlayQueueByIndex").then((response) => { if (response.playQueueByIndex) { response.playQueue = response.playQueueByIndex; response.playQueue.current = response.playQueueByIndex.currentIndex; } return response; }); } else { return request("getPlayQueue"); } }, getShares: async () => { const response = await request("getShares"); return asArray(response.shares?.share).map((share) => ({ ...share, sourceId: sourceId(), })); }, createShare: (id: string[], description?: string, expires?: number) => request("createShare", { id, description, expires }), updateShare: (id: string, description?: string, expires?: number) => request("updateShare", { id, description, expires }), deleteShare: (id: string) => request("deleteShare", { id }), scrobble: (id: string, submission = true, time?: number) => { if (sourceOffline()) { if (submission) { return offlineManager.queueRequest( "scrobble", { id, submission }, false, sourceId(), ); } return Promise.resolve(); } return request("scrobble", { id, submission, time }); }, getUser: async (username: string) => { if (sourceOffline()) return { adminRole: false }; const response = await request("getUser", { username }); return response.user; }, tokenInfo: async () => { if (sourceOffline()) return null; return request("tokenInfo"); }, getInternetRadioStations: async (): Promise<InternetRadioStation[]> => { const response = await request("getInternetRadioStations"); return asArray(response.internetRadioStations?.internetRadioStation).map( (station) => ({ ...station, sourceId: sourceId() }), ); }, createInternetRadioStation: ( streamUrl: string, name: string, homepageUrl?: string, ) => request( "createInternetRadioStation", { streamUrl, name, homepageUrl }, true, ), updateInternetRadioStation: ( id: string, streamUrl: string, name: string, homepageUrl?: string, ) => request( "updateInternetRadioStation", { id, streamUrl, name, homepageUrl }, true, ), deleteInternetRadioStation: (id: string) => request("deleteInternetRadioStation", { id }), }; return client;};
type SourceApi = ReturnType<typeof createApi>;
const sourceApis = new Map<string, SourceApi>();export const getSourceApi = (sourceId: string) => { const existing = sourceApis.get(sourceId); if (existing) return existing; const created = createApi(() => sourceId); sourceApis.set(sourceId, created); return created;};
export const detectExtensions = async (sourceId = sources.activeId) => { if (!sourceId || offlineManager.isSourceOffline(sourceId)) return; const extensions = new Set<string>(); if (openSubsonicSources.has(sourceId)) { try { const response = await getSourceApi(sourceId).getOpenSubsonicExtensions(); const extData = response.openSubsonicExtensions; const list = Array.isArray(extData) ? extData : asArray(extData?.extension); for (const e of list) { if (e?.name) { extensions.add(String(e.name).toLowerCase()); } } } catch (err) { console.warn("failed to fetch OpenSubsonic extensions:", err); } } if (offlineManager.isSourceOffline(sourceId)) return; sourceExtensions.set(sourceId, extensions); updateBackgroundSync(effectiveAutoSyncMode(), { id: sourceId, credentials: sourceCredentials(sourceId), supportsFormPost: extensions.has("formpost"), });};
type SparkMd5Lib = typeof import("spark-md5") extends { default: infer T } ? T : typeof import("spark-md5");
let sparkMd5Promise: Promise<SparkMd5Lib> | null = null;
export const createToken = async (password: string) => { const salt = Math.random().toString(36).substring(2, 8); if (!sparkMd5Promise) { sparkMd5Promise = import("spark-md5") .then((mod) => ("default" in mod ? mod.default : mod) as SparkMd5Lib) .catch((err: unknown) => { sparkMd5Promise = null; throw err; }); } const SparkMD5 = await sparkMd5Promise; return { token: SparkMD5.hash(password + salt), salt };};
const anonymousRequest = async (server: string, method: string) => { server = server.trim().replace(/\/+$/, ""); const response = await fetch( `${server}/rest/${method}?v=1.16.1&c=tinysub&f=json`, ); return (await response.json())["subsonic-response"];};
export const anonymousPing = (server: string) => anonymousRequest(server, "ping");
export const anonymousGetExtensions = (server: string) => anonymousRequest(server, "getOpenSubsonicExtensions");