import { 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 = (value: T | T[] | null | undefined): T[] => Array.isArray(value) ? value : value == null ? [] : [value]; const getParams = (sourceId: string, params: Record = {}) => { 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(); const sourceExtensions = new SvelteMap>(); const extensionsFor = (sourceId: string) => sourceExtensions.get(sourceId) ?? new Set(); 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 = {}, 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(); 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, ) => { 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 = {}, 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 => { 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 => { const response = await request("getAlbumInfo2", { id }); return response.albumInfo || null; }, getArtistInfo2: async (id: string): Promise => { 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 => { 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 => { 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 => 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 => { 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 => { 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; const sourceApis = new Map(); 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(); 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 | 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");