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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389/// <reference lib="webworker" />
import { CacheableResponsePlugin } from "workbox-cacheable-response";import { ExpirationPlugin } from "workbox-expiration";import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute,} from "workbox-precaching";import { NavigationRoute, registerRoute } from "workbox-routing";import { CacheFirst } from "workbox-strategies";import { buildApiParams } from "./lib/apiParams.js";import type { Credentials } from "./lib/credentials.js";import { type AutoSyncMode, backgroundSyncConfigMessage, backgroundSyncRequestMessage, backgroundSyncTag, isAutoSyncEligible, updateAppBadge,} from "./lib/offline/sync.js";
declare let self: ServiceWorkerGlobalScope & { __WB_MANIFEST: Array<string | { url: string; revision?: string | null }>;};
type QueueItem = { id?: number; sourceId?: string; method: string; params: Record<string, unknown>; isPost: boolean; timestamp: number;};
type SyncConfig = { revision: number; mode: AutoSyncMode; sources: Record< string, { credentials: Credentials; supportsFormPost?: boolean } >;};
type ReplayResult = { ok: boolean; playlistId?: string };type SyncEvent = ExtendableEvent & { tag: string };
const pwaDbName = "tinysub_pwa";const pwaStoreName = "state";const offlineDbName = "tinysub_offline";let syncConfigWrite: Promise<void> = Promise.resolve();
cleanupOutdatedCaches();precacheAndRoute(self.__WB_MANIFEST);
self.addEventListener("message", (event) => { if (event.data?.type === "SKIP_WAITING") { void self.skipWaiting(); } else if (event.data?.type === backgroundSyncConfigMessage) { syncConfigWrite = syncConfigWrite .catch(() => {}) .then(() => saveSyncConfig(event.data)); event.waitUntil(syncConfigWrite); }});
registerRoute( new NavigationRoute( createHandlerBoundToURL(`${import.meta.env.BASE_URL}index.html`), ),);
registerRoute( ({ request, url }) => request.cache !== "no-store" && url.pathname.endsWith("/rest/getCoverArt"), new CacheFirst({ cacheName: "getCoverArt", plugins: [ new CacheableResponsePlugin({ statuses: [0, 200] }), { cacheKeyWillBeUsed: async ({ request }) => { const url = new URL(request.url); const key = new URL(url.origin + url.pathname); for (const name of ["id", "size", "__tinysub_source"]) { const value = url.searchParams.get(name); if (value !== null) key.searchParams.set(name, value); } key.searchParams.set("__tinysub_request_mode", request.mode); return key.href; }, requestWillFetch: async ({ request }) => { const url = new URL(request.url); if (!url.searchParams.has("__tinysub_source")) return request; url.searchParams.delete("__tinysub_source"); return new Request(url, request); }, }, new ExpirationPlugin({ maxEntries: 500, maxAgeSeconds: 24 * 60 * 60 }), ], }),);
const requestResult = <T>(request: IDBRequest<T>) => new Promise<T>((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });
const transactionResult = (transaction: IDBTransaction) => new Promise<void>((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); transaction.onabort = () => reject(transaction.error); });
const openDatabase = (name: string, version?: number) => new Promise<IDBDatabase>((resolve, reject) => { const request = indexedDB.open(name, version); request.onupgradeneeded = () => { if ( name === pwaDbName && !request.result.objectStoreNames.contains(pwaStoreName) ) { request.result.createObjectStore(pwaStoreName, { keyPath: "key" }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });
const withDatabase = async <T>( name: string, action: (db: IDBDatabase) => Promise<T>, version?: number,): Promise<T> => { const db = await openDatabase(name, version); try { return await action(db); } finally { db.close(); }};
const emptySyncConfig = (): SyncConfig => ({ revision: 0, mode: "none", sources: {},});
const readSyncConfig = async (): Promise<SyncConfig> => { try { const value = await withDatabase( pwaDbName, (db) => requestResult< SyncConfig & { key: string; revision?: number; credentials?: Credentials | null; supportsFormPost?: boolean; } >(db.transaction(pwaStoreName).objectStore(pwaStoreName).get("config")), 1, ); if (!value?.mode) return emptySyncConfig(); if (value.sources) return { revision: value.revision ?? 0, mode: value.mode, sources: value.sources, }; if (value.credentials) { return { revision: value.revision ?? 0, mode: value.mode, sources: { legacy: { credentials: value.credentials, supportsFormPost: value.supportsFormPost, }, }, }; } return emptySyncConfig(); } catch { return emptySyncConfig(); }};
const saveSyncConfig = async (value: Partial<SyncConfig>) => { const previous = await readSyncConfig(); if (value.revision !== undefined && value.revision <= previous.revision) return; const configuredSources = value.sources ? Object.fromEntries( Object.entries(value.sources).map(([id, source]) => [ id, { credentials: source.credentials, ...(source.supportsFormPost === undefined && previous.sources[id]?.supportsFormPost !== undefined ? { supportsFormPost: previous.sources[id].supportsFormPost } : source.supportsFormPost === undefined ? {} : { supportsFormPost: source.supportsFormPost }), }, ]), ) : previous.sources; const config: SyncConfig = { revision: value.revision ?? previous.revision, mode: value.mode === "all" || value.mode === "scrobbles" ? value.mode : "none", sources: value.mode === "none" ? {} : configuredSources, }; await withDatabase( pwaDbName, async (db) => { const transaction = db.transaction(pwaStoreName, "readwrite"); transaction.objectStore(pwaStoreName).put({ key: "config", ...config }); await transactionResult(transaction); }, 1, ); if (config.mode === "none") await updateAppBadge(0);};
const readQueue = async (): Promise<QueueItem[]> => { try { return await withDatabase(offlineDbName, async (db) => { if (!db.objectStoreNames.contains("unsyncedChanges")) return []; return requestResult<QueueItem[]>( db .transaction("unsyncedChanges") .objectStore("unsyncedChanges") .getAll(), ); }); } catch { return []; }};
const deleteQueueItem = async (id: number) => { await withDatabase(offlineDbName, async (db) => { if (!db.objectStoreNames.contains("unsyncedChanges")) return; const transaction = db.transaction("unsyncedChanges", "readwrite"); transaction.objectStore("unsyncedChanges").delete(id); await transactionResult(transaction); });};
const sourceStorageKey = (sourceId: string, id: string) => `${sourceId}:${encodeURIComponent(id)}`;
const deleteLocalPlaylist = async (id: string, sourceId: string) => { await withDatabase(offlineDbName, async (db) => { if (!db.objectStoreNames.contains("playlists")) return; const transaction = db.transaction("playlists", "readwrite"); const playlists = transaction.objectStore("playlists"); playlists.delete(sourceStorageKey(sourceId, id)); playlists.delete(id); await transactionResult(transaction); });};
const valueAsString = (value: unknown) => typeof value === "string" ? value : undefined;
const replay = async ( item: QueueItem, sourceId: string, credentials: Credentials, supportsFormPost: boolean, playlistIds: Map<string, string>,): Promise<ReplayResult> => { const params = { ...item.params }; let localPlaylistId: string | undefined; if (item.method === "scrobble") params.time = item.timestamp; if (item.method === "createPlaylist") { localPlaylistId = valueAsString(params.playlistId); if (params.isNew) delete params.playlistId; else if (localPlaylistId) params.playlistId = playlistIds.get(sourceStorageKey(sourceId, localPlaylistId)) || localPlaylistId; delete params.isNew; } else if (item.method === "updatePlaylist") { const id = valueAsString(params.playlistId); if (id) params.playlistId = playlistIds.get(sourceStorageKey(sourceId, id)) || id; } else if (item.method === "deletePlaylist") { const id = valueAsString(params.id); if (id) params.id = playlistIds.get(sourceStorageKey(sourceId, id)) || id; }
const search = buildApiParams(credentials, params); const endpoint = `${credentials.server.replace(/\/+$/, "")}/rest/${item.method}`; const response = await fetch( item.isPost && supportsFormPost ? endpoint : `${endpoint}?${search}`, item.isPost && supportsFormPost ? { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: search.toString(), } : undefined, ); if (!response.ok) return { ok: false };
try { const data = (await response.json())?.["subsonic-response"]; if (data?.status !== "ok") return { ok: false }; return { ok: true, playlistId: item.method === "createPlaylist" && params.playlistId === undefined ? valueAsString(data.playlist?.id) : undefined, }; } catch { return { ok: false }; }};
const syncQueuedChanges = async () => { await syncConfigWrite; const config = await readSyncConfig(); const queue = (await readQueue()).filter((item) => isAutoSyncEligible(item.method, config.mode), ); await updateAppBadge(queue.length); if (!queue.length || !Object.keys(config.sources).length) return;
const openClients = await self.clients.matchAll({ type: "window", includeUncontrolled: true, }); if (openClients.length) { openClients[0].postMessage({ type: backgroundSyncRequestMessage }); return; }
const playlistIds = new Map<string, string>(); const configuredSourceEntries = Object.entries(config.sources); for (const item of queue) { if (item.id === undefined) continue; const sourceId = item.sourceId ?? (configuredSourceEntries.length === 1 ? configuredSourceEntries[0][0] : undefined); const source = sourceId ? config.sources[sourceId] : undefined; if (!sourceId || !source) continue; const result = await replay( item, sourceId, source.credentials, source.supportsFormPost === true, playlistIds, ); if (!result.ok) break; if (item.method === "createPlaylist" && item.params.isNew) { const localId = valueAsString(item.params.playlistId); if (localId) { if (result.playlistId) playlistIds.set( sourceStorageKey(sourceId, localId), result.playlistId, ); await deleteLocalPlaylist(localId, sourceId).catch((err) => console.warn("failed to remove synced local playlist:", localId, err), ); } } await deleteQueueItem(item.id); }
await updateAppBadge( (await readQueue()).filter((item) => isAutoSyncEligible(item.method, config.mode), ).length, );};
self.addEventListener("sync", ((event: SyncEvent) => { if (event.tag === backgroundSyncTag) event.waitUntil(syncQueuedChanges());}) as EventListener);