/// 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; }; type QueueItem = { id?: number; sourceId?: string; method: string; params: Record; 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 = 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 = (request: IDBRequest) => new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); const transactionResult = (transaction: IDBTransaction) => new Promise((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); transaction.onabort = () => reject(transaction.error); }); const openDatabase = (name: string, version?: number) => new Promise((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 ( name: string, action: (db: IDBDatabase) => Promise, version?: number, ): Promise => { 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 => { 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) => { 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 => { try { return await withDatabase(offlineDbName, async (db) => { if (!db.objectStoreNames.contains("unsyncedChanges")) return []; return requestResult( 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, ): Promise => { 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(); 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);