///
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);