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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020import { SvelteSet } from "svelte/reactivity";import { settings } from "../app/settings.svelte.js";import type { OfflineAlbum, QueuedRequest,} from "../offline/indexedDbStore.svelte.js";import { cacheArt, getCoverArtUrl, offlineManager, store,} from "../offline/offlineManager.svelte.js";import { clearAlbumArtIds, normalizeSongArt, rememberAlbumArt,} from "../ui/artwork.svelte.js";import { ApiError, buildApiParams, type Credentials, credentialsStorageKey, isNetworkError, isTimeoutError, normalizeServer,} from "./connection.js";import type { Album, AlbumInfo, Artist, ArtistInfo, ArtistWithAlbums, InfoItemType, InfoResponse, InternetRadioStation, PlaybackReportState, Playlist, ScanStatus, ServerSong, Song,} from "./types.js";
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 const itemSortName = (item: Artist | Album | Song): string => item.sortName || ("title" in item ? item.title : item.name) || "";
export const isLocalPlaylistId = (id?: string | null) => !!id?.startsWith("local_playlist_");
export const radioStationToSong = (station: InternetRadioStation): Song => ({ id: `radio-${station.id}`, title: station.name, artist: "", album: "", coverArt: station.coverArt, _radioStreamUrl: station.streamUrl,});
export const asArray = <T>(value: T | T[] | null | undefined): T[] => Array.isArray(value) ? value : value == null ? [] : [value];
export const canPlayTrack = (song: Song) => !offlineManager.isOfflineMode || (!isRadio(song) && offlineManager.downloadedSongIds.has(song.id));
let credentials: Credentials | null = null;const base = () => { if (!credentials) throw new Error("Auth required"); return credentials.server;};
export const setCredentials = (creds: Credentials | null) => { clearAlbumArtIds(); for (const cache of [songCache, albumCache, artistCache]) cache.clear(); inflightPing = undefined; if (creds) { creds.server = normalizeServer(creds.server); if (creds.username) { creds.username = creds.username.trim(); } } else { extensions.clear(); } credentials = creds;};
export const persistCredentials = () => { credentials ? localStorage.setItem(credentialsStorageKey, JSON.stringify(credentials)) : localStorage.removeItem(credentialsStorageKey);};
const getParams = (params: Record<string, unknown> = {}) => { if (!credentials) throw new Error("Auth required"); return buildApiParams(credentials, params);};
let isOpenSubsonic = false;
const pingServer = async () => { try { const response = await fetch(`${base()}/rest/ping?${getParams({})}`, { signal: AbortSignal.timeout(5000), }); return response.status < 500; } catch { return false; }};
let inflightPing: Promise<boolean> | undefined;
const isReachable = () => { if (!inflightPing) inflightPing = pingServer().finally(() => (inflightPing = undefined)); return inflightPing;};
const request = async ( method: string, params: Record<string, unknown> = {}, isPost = false,) => { const usePost = isPost && extensions.has("formpost"); const signal = AbortSignal.timeout(30000); try { const response = await fetch( usePost ? `${base()}/rest/${method}` : `${base()}/rest/${method}?${getParams(params)}`, usePost ? { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: getParams(params).toString(), signal, } : { signal }, ); if (!response.ok) { throw new ApiError( `server returned HTTP ${response.status}`, undefined, response.status, ); } const data = (await response.json())?.["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, ); } isOpenSubsonic = !!data.openSubsonic; return data; } catch (err) { if (isNetworkError(err) && !isTimeoutError(err) && !(await isReachable())) { console.warn("server unreachable, entering offline mode:", err); offlineManager.setOffline(); } throw err; }};
export const songCache = new Map<string, Song>();
const albumCache = new Map<string, Album>();const artistCache = new Map<string, Artist>();
const internById = <T extends { id: string }>( cache: Map<string, T>, items: T[],): T[] => items.map((raw) => { const cached = cache.get(raw.id); if (cached) { if (cached !== raw) Object.assign(cached, raw); return cached; } const fresh = $state({ ...raw }) as T; cache.set(raw.id, fresh); return fresh; });
const internAlbumBatch = (albums: Album[]): Album[] => internById(albumCache, albums);
const internArtistBatch = (artists: Artist[]): Artist[] => internById(artistCache, artists);
export const queuedSongState = () => { 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; 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: ServerSong[]): Song[] => { const pending = queuedSongState(); return songs.map((raw) => { const cached = songCache.get(raw.id); const alreadyInterned = cached === raw; if (!alreadyInterned) normalizeSongArt(raw); if ( !alreadyInterned && !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, }; } if (cached) { if (raw.starred !== undefined || cached._origStarred === undefined) { cached._origStarred = raw.starred; } if ( raw.userRating !== undefined || cached._origUserRating === undefined ) { cached._origUserRating = raw.userRating; } if (!alreadyInterned) Object.assign(cached, raw); applyQueuedSongState(cached, pending); return cached; } const fresh = $state({ ...raw, _origStarred: raw.starred, _origUserRating: raw.userRating, }); applyQueuedSongState(fresh, pending); songCache.set(raw.id, fresh); return fresh; });};
const cacheOfflineCopy = ( coverArt: string | undefined | null, persist: () => Promise<unknown>, kind: string, id: string,) => { if (coverArt) void cacheArt(coverArt); void persist().catch((err: unknown) => { console.error(`failed to update cached ${kind}:`, id, err); });};
const rememberAlbums = (albums: OfflineAlbum[]) => { for (const album of albums) { rememberAlbumArt(album); if ( settings.downloads && !offlineManager.isOfflineMode && offlineManager.downloadedAlbumIds.has(album.id) ) { cacheOfflineCopy( album.coverArt, () => store.putAlbum(album), "album", album.id, ); } }};
const rememberArtists = (artists: Artist[]) => { for (const artist of artists) { if ( settings.downloads && offlineManager.downloadedArtistIds.has(artist.id) ) { cacheOfflineCopy( artist.coverArt, () => store.putArtist(artist), "artist", artist.id, ); } }};
const internSongs = async (songs: Song[]) => { 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)), ); for (const album of albums) { if (album) rememberAlbumArt(album); } } return internSongBatch(songs);};
export const extensions = $state(new SvelteSet<string>());
export const getOnlineCoverArt = (id: string, size?: number) => { if (!credentials || !id) return ""; return `${base()}/rest/getCoverArt?${getParams({ id, size })}`;};
export 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 nameCollator = new Intl.Collator(undefined, { sensitivity: "variant",});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 nameCollator.compare(a, b);};
const slicePage = <T>(items: T[], offset: number, count: number | undefined) => items.slice(offset, count === undefined ? undefined : offset + count);
const compareYears = (aYear: number | undefined, bYear: number | undefined) => { if (aYear === undefined && bYear === undefined) return 0; if (aYear === undefined) return 1; if (bYear === undefined) return -1; return aYear - bYear;};
const BROWSE_PAGE_SIZE = 500;const BROWSE_MAX_PAGES = 2000;
const fetchAllPages = async <T extends { id: string }>( fetchPage: (offset: number) => Promise<T[]>, isCurrent: () => boolean,): Promise<T[]> => { const all: T[] = []; let offset = 0; let prevFirstId: string | undefined; for (let page = 0; page < BROWSE_MAX_PAGES; page++) { if (!isCurrent()) break; const items = await fetchPage(offset); if (!isCurrent()) break; if (!items.length || items[0].id === prevFirstId) break; prevFirstId = items[0].id; all.push(...items); offset += items.length; if (items.length < BROWSE_PAGE_SIZE) break; } return all;};
const MATCH_ALL_QUERY = '""';
const fetchAlbumsPage = async (offset: number): Promise<Album[]> => { const response = await request("getAlbumList2", { type: "alphabeticalByName", size: BROWSE_PAGE_SIZE, offset, }); return asArray(response.albumList2?.album) as Album[];};
const fetchSongsPage = async (offset: number): Promise<Song[]> => { const response = await request("search3", { query: MATCH_ALL_QUERY, artistCount: 0, songCount: BROWSE_PAGE_SIZE, songOffset: offset, }); return asArray(response.searchResult3?.song) as Song[];};
const infoListCount = 100;
const infoMethod: Record<InfoItemType, string> = { artist: "getArtist", album: "getAlbum", playlist: "getPlaylist", radio: "getInternetRadioStations", song: "getSong", share: "getShares",};
const requestOrQueue = ( method: QueuedRequest["method"], params: Record<string, unknown>,) => offlineManager.isOfflineMode ? offlineManager.queueRequest(method, params as QueuedRequest["params"]) : request(method, params);
const fetchScanStatus = async (method: string): Promise<ScanStatus | null> => { if (offlineManager.isOfflineMode) return null; return ((await request(method)).scanStatus as ScanStatus | undefined) ?? null;};
const refreshPlaylistCache = (id: string | undefined, action: string) => { if (!id) return; void api.getPlaylist(id, { forceCache: true }).catch((err: unknown) => { console.error(`failed to refresh playlist after ${action}:`, id, err); });};
export const api = { forcePing: async () => request("ping"), getInfo: async (type: InfoItemType, id: string): Promise<InfoResponse> => { const response: InfoResponse = await request( infoMethod[type], type === "share" || type === "radio" ? {} : { id }, ); if (response.song) response.song = internSongBatch(asArray(response.song))[0] ?? response.song; if (response.album) { const album = internAlbumBatch(asArray(response.album))[0] ?? response.album; if (album.song) album.song = internSongBatch(asArray(album.song)); response.album = album; } if (response.artist) { const nestedAlbums = (response.artist as ArtistWithAlbums).album; const artist = internArtistBatch(asArray(response.artist))[0] ?? response.artist; if (nestedAlbums) (artist as ArtistWithAlbums).album = internAlbumBatch( asArray(nestedAlbums), ); response.artist = artist; } return response; }, getAlbumInfo2: async (id: string): Promise<AlbumInfo | null> => (await request("getAlbumInfo2", { id })).albumInfo || null, getArtistInfo2: async (id: string): Promise<ArtistInfo | null> => { const response = await request("getArtistInfo2", { id, count: infoListCount, }); const info = response.artistInfo2 as ArtistInfo | undefined; if (!info) return null; return { ...info, similarArtist: internArtistBatch(asArray(info.similarArtist) as Artist[]), }; }, getTopSongs: async (artist: string, id?: string): Promise<Song[]> => { const params = id && extensions.has("topsongsbyartistid") ? { id, count: infoListCount } : { artist, count: infoListCount }; const response = await request("getTopSongs", params); return internSongBatch(asArray(response.topSongs?.song)); }, getSonicSimilarTracks: async (id: string): Promise<Song[]> => { if (!extensions.has("sonicsimilarity")) return []; const response = await request("getSonicSimilarTracks", { id, count: infoListCount, }); return internSongBatch( asArray(response.sonicMatch).flatMap((match: { entry?: Song }) => match.entry ? [match.entry] : [], ), ); }, getSimilarSongs2: async (id: string): Promise<Song[]> => { const response = await request("getSimilarSongs2", { id, count: infoListCount, }); return internSongBatch(asArray(response.similarSongs2?.song)); }, getArtists: async () => { if (offlineManager.isOfflineMode) { return internArtistBatch( (await store.getAllArtists()).sort((a, b) => compareNames(itemSortName(a), itemSortName(b)), ), ); } const response = await request("getArtists"); const artists: Artist[] = asArray(response.artists?.index).flatMap( (index: { artist?: Artist | Artist[] }) => asArray(index.artist), ); rememberArtists(artists); return internArtistBatch(artists); }, getPlaylists: async () => { if (offlineManager.isOfflineMode) { return (await store.getAllPlaylists()).sort((a, b) => compareNames(a.name, b.name), ); } return request("getPlaylists").then(async (response) => { const deletedIds = new Set( offlineManager.queuedRequests .filter((req) => req.method === "deletePlaylist") .map((req) => req.params?.id) .filter(Boolean), ); let list = asArray(response.playlists?.playlist).filter( (playlist) => !deletedIds.has(playlist.id), );
if ( offlineManager.queuedRequests.some( (req) => req.method === "createPlaylist" && req.params?.name, ) ) { list = [ ...list, ...(await store.getAllPlaylists()).filter( (playlist: Playlist) => isLocalPlaylistId(playlist.id) && !deletedIds.has(playlist.id), ), ]; }
void offlineManager.refreshPlaylistCache(list); return list; }); }, getArtistAlbums: async (id: string): Promise<Album[]> => { const byYearThenName = (a: Album, b: Album) => compareYears(a.year, b.year) || compareNames(itemSortName(a), itemSortName(b)); if (offlineManager.isOfflineMode) { return internAlbumBatch( (await store.getAllAlbums()) .filter( (album: OfflineAlbum) => album.artistId === id || album.artistName === id, ) .sort(byYearThenName), ); } const response = await request("getArtist", { id }); const albums: Album[] = asArray(response.artist.album); rememberAlbums(albums); return internAlbumBatch(albums).sort(byYearThenName); }, getAlbum: async (id: string) => { let songs: Song[]; if (offlineManager.isOfflineMode) { songs = (await store.getAllSongs()) .filter((song: Song) => song.albumId === id) .sort( (a, b) => (a.track || 0) - (b.track || 0) || (a.discNumber || 0) - (b.discNumber || 0) || compareNames(itemSortName(a), itemSortName(b)), ); } else { const response = await request("getAlbum", { id }); rememberAlbums([response.album]); songs = asArray(response.album.song); } return internSongs(songs); }, getPlaylist: async (id: string, options?: { forceCache?: boolean }) => { if (offlineManager.isOfflineMode || isLocalPlaylistId(id)) { const playlist = await store.getPlaylist(id); return internSongs(playlist?.entry || []); } return request("getPlaylist", { id }).then(async (response) => { let storedEntries = asArray(response.playlist.entry);
for (const req of offlineManager.queuedRequests.filter( ( req, ): req is Extract< (typeof offlineManager.queuedRequests)[number], { method: "updatePlaylist" } > => req.method === "updatePlaylist" && req.params?.playlistId === id, )) { const { songIdToAdd, songIndexToRemove } = req.params; if (songIndexToRemove?.length) { const removedIndices = new Set(songIndexToRemove); storedEntries = storedEntries.filter( (_, idx) => !removedIndices.has(idx), ); } if (songIdToAdd?.length) { const addedSongs = ( await Promise.all(songIdToAdd.map((id) => store.getSong(id))) ).filter((song: Song | null): song is Song => song !== null); storedEntries = [...storedEntries, ...addedSongs]; } } const songs = await internSongs(storedEntries);
const playlist = response.playlist; if ( settings.downloads && (options?.forceCache || songs.some((song) => offlineManager.downloadedSongIds.has(song.id))) ) { if (playlist.coverArt) void cacheArt(playlist.coverArt); void store .putPlaylist({ id: playlist.id, name: playlist.name, coverArt: playlist.coverArt, readonly: playlist.readonly, owner: playlist.owner, public: playlist.public, entry: storedEntries.map((song) => { const { lyrics: _omitted, ...rest } = song; return rest; }), }) .catch((err: unknown) => { console.error( "failed to cache playlist entries:", playlist.id, err, ); }); } return songs; }); }, fetchAllAlbums: async (isCurrent: () => boolean): Promise<Album[]> => { if (offlineManager.isOfflineMode) return internAlbumBatch(await store.getAllAlbums()); const albums = await fetchAllPages(fetchAlbumsPage, isCurrent); rememberAlbums(albums); return internAlbumBatch(albums); }, fetchAllSongs: async (isCurrent: () => boolean): Promise<Song[]> => { const songs = offlineManager.isOfflineMode ? await store.getAllSongs() : await fetchAllPages(fetchSongsPage, isCurrent); return internSongs(songs); }, search3: async ( query: string, options: { artistCount?: number; albumCount?: number; songCount?: number; artistOffset?: number; albumOffset?: number; songOffset?: number; } = {}, ) => { const { artistCount, albumCount, songCount, artistOffset = 0, albumOffset = 0, songOffset = 0, } = options; if (offlineManager.isOfflineMode) { const results = await store.search(query); const album = slicePage(results.album, albumOffset, albumCount); rememberAlbums(album); return { artist: internArtistBatch( slicePage(results.artist, artistOffset, artistCount), ), album: internAlbumBatch(album), song: await internSongs(slicePage(results.song, songOffset, songCount)), }; } const response = await request("search3", { query, artistCount, albumCount, songCount, artistOffset, albumOffset, songOffset, }); const results = response.searchResult3 || {}; if (results.album) { const albums = asArray(results.album); rememberAlbums(albums); results.album = internAlbumBatch(albums); } if (results.artist) results.artist = internArtistBatch(asArray(results.artist)); if (results.song) results.song = await internSongs(asArray(results.song)); return results; }, stream: ( id: string, params: { maxBitRate?: number; format?: string; timeOffset?: number; } = {}, ) => `${base()}/rest/stream?${getParams({ id, ...params })}`, download: (id: string) => `${base()}/rest/download?${getParams({ id })}`, getCoverArt: getCoverArtUrl, star: (params: { id?: string; albumId?: string; artistId?: string }) => requestOrQueue("star", params), unstar: (params: { id?: string; albumId?: string; artistId?: string }) => requestOrQueue("unstar", params), setRating: (id: string, rating: number) => requestOrQueue("setRating", { id, rating }), getLyricsBySongId: async (id: string) => { if (offlineManager.isOfflineMode) { return (await store.getSong(id))?.lyrics || null; } return request("getLyricsBySongId", { id }); }, getLyrics: async (artist: string, title: string) => { if (offlineManager.isOfflineMode) { return ( (await store.getAllSongs()).find( (song: Song) => songArtist(song) === artist && song.title === title, )?.lyrics || null ); } return request("getLyrics", { artist, title }); }, createPlaylist: (name?: string, songId?: string[], playlistId?: string) => { if (offlineManager.isOfflineMode) { const isNew = !playlistId; const targetId = playlistId || `local_playlist_${Date.now()}`; offlineManager.createLocalPlaylistOffline(name, songId, targetId); return offlineManager.queueRequest("createPlaylist", { name, songId, playlistId: targetId, isNew, }); } return request("createPlaylist", { name, songId, playlistId }, true).then( (response) => { refreshPlaylistCache(playlistId || response.playlist?.id, "create"); return response; }, ); }, updatePlaylist: ( playlistId: string, name?: string, songIdToAdd?: string[], songIndexToRemove?: number[], isPublic?: boolean, ) => { if (offlineManager.isOfflineMode) { offlineManager.updateLocalPlaylistOffline( playlistId, name, songIdToAdd, songIndexToRemove, isPublic, ); return offlineManager.queueRequest("updatePlaylist", { playlistId, name, songIdToAdd, songIndexToRemove, public: isPublic, }); } return request( "updatePlaylist", { playlistId, name, songIdToAdd, songIndexToRemove, public: isPublic, }, true, ).then((response) => { refreshPlaylistCache(playlistId, "update"); return response; }); }, deletePlaylist: (id: string) => { if (offlineManager.isOfflineMode) { offlineManager.deleteLocalPlaylistOffline(id); return offlineManager.queueRequest("deletePlaylist", { id }); } return request("deletePlaylist", { id }).then((response) => { void store.deletePlaylist(id).catch((err: unknown) => { console.error("failed to delete cached playlist:", id, err); }); return response; }); }, getOpenSubsonicExtensions: () => { if (offlineManager.isOfflineMode) return Promise.resolve({ openSubsonicExtensions: [] }); return request("getOpenSubsonicExtensions"); }, savePlayQueue: ( id: string[], current?: string | number, position?: number, ) => { if (offlineManager.isOfflineMode) return Promise.resolve(); if (extensions.has("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 (offlineManager.isOfflineMode) return Promise.resolve({}); if (extensions.has("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 () => asArray((await request("getShares")).shares?.share), 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 (offlineManager.isOfflineMode) { if (submission) { return offlineManager.queueRequest("scrobble", { id, submission }); } return Promise.resolve(); } return request("scrobble", { id, submission, time }); }, reportPlayback: ( mediaId: string, state: PlaybackReportState, positionMs: number, ) => { if (offlineManager.isOfflineMode) return Promise.resolve(); if (!extensions.has("playbackreport")) return Promise.resolve(); return request( "reportPlayback", { mediaId, mediaType: "song", positionMs: Math.max(0, Math.floor(positionMs)), state, }, true, ); }, getScanStatus: (): Promise<ScanStatus | null> => fetchScanStatus("getScanStatus"), startScan: (): Promise<ScanStatus | null> => fetchScanStatus("startScan"), getUser: async (username: string) => { if (offlineManager.isOfflineMode) return { adminRole: false }; return (await request("getUser", { username })).user; }, tokenInfo: async () => { if (offlineManager.isOfflineMode) return null; return request("tokenInfo"); }, getInternetRadioStations: async (): Promise<InternetRadioStation[]> => asArray( (await request("getInternetRadioStations")).internetRadioStations ?.internetRadioStation, ), 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 }),};
export type StarredType = "album" | "artist" | "song";export type StarredItem = Album | Artist | Song;
export const toggleStar = async (item: StarredItem, type: StarredType) => { const starred = !!item.starred; const prev = item.starred; const params = type === "album" ? { albumId: item.id } : type === "artist" ? { artistId: item.id } : { id: item.id }; item.starred = starred ? undefined : new Date().toISOString(); try { if (starred) await api.unstar(params); else await api.star(params); } catch (err) { item.starred = prev; console.error(err); }};
export const setRating = async (item: StarredItem, rating: number) => { const prev = item.userRating; item.userRating = rating; try { await api.setRating(item.id, rating); } catch (err) { item.userRating = prev; console.error("failed to set rating:", err); }};
export const detectExtensions = async () => { extensions.clear(); if (isOpenSubsonic) { try { const response = await api.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); } }};