Files
splirate/background.js
2026-08-05 21:39:14 -05:00

554 lines
15 KiB
JavaScript

"use strict";
const TAB_CAPTURED = new Map();
const FULL_URL_BY_SAMPLE = new Map();
const HISTORY_KEY = "capturedS3History";
const MAX_HISTORY_ITEMS = 500;
const SPLICE_DOMAIN_RE = /^https?:\/\/([a-z0-9-]+\.)*splice\.com(\/|$)/i;
const AUDIO_SAMPLE_PATH_RE = /\/audio_samples\//i;
const WAVEFORM_PATH_RE = /\.wv\.json$/i;
const SCRAMBLED_PATH_RE = /-scrambled\//i;
const HASH_RE = /^[a-f0-9]{64}$/i;
const GRAPHQL_URL = "https://surfaces-graphql.splice.com/graphql";
function isDomainAllowed(pageUrl) {
return typeof pageUrl === "string" && SPLICE_DOMAIN_RE.test(pageUrl);
}
function isS3Url(url) {
if (typeof url !== "string") {
return false;
}
try {
const parsed = new URL(url);
if (parsed.protocol !== "https:") {
return false;
}
const host = parsed.hostname.toLowerCase();
const isAwsHost = host.endsWith(".amazonaws.com") || host.endsWith(".amazonaws.com.cn");
const isS3Host = host.includes(".s3.") || host.startsWith("s3.");
return isAwsHost && isS3Host;
} catch (error) {
return false;
}
}
function isAudioSampleUrl(url) {
if (!isS3Url(url)) {
return false;
}
try {
const parsed = new URL(url);
return AUDIO_SAMPLE_PATH_RE.test(parsed.pathname || "");
} catch (error) {
return false;
}
}
function isWaveformUrl(url) {
try {
const parsed = new URL(url);
return WAVEFORM_PATH_RE.test(parsed.pathname || "");
} catch (error) {
return false;
}
}
function isPreviewUrl(url) {
try {
const parsed = new URL(url);
return SCRAMBLED_PATH_RE.test(parsed.pathname || "");
} catch (error) {
return false;
}
}
function isFullUrl(url) {
return isAudioSampleUrl(url) && !isWaveformUrl(url) && !isPreviewUrl(url);
}
function normalizeUrl(url) {
try {
return new URL(url).toString();
} catch (error) {
return "";
}
}
function extractSampleIdFromUrl(url) {
try {
const parsed = new URL(url);
const path = parsed.pathname || "";
const marker = "/audio_samples/";
const idx = path.indexOf(marker);
if (idx < 0) {
return "";
}
const rest = path.slice(idx + marker.length);
const parts = rest.split("/").filter(Boolean);
const first = parts[0] || "";
const second = parts[1] || "";
const firstNormalized = first.replace(/-scrambled$/i, "");
if (HASH_RE.test(firstNormalized)) {
return firstNormalized.toLowerCase();
}
const secondStem = second.split(".")[0];
if (HASH_RE.test(secondStem)) {
return secondStem.toLowerCase();
}
if (HASH_RE.test(first)) {
return first.toLowerCase();
}
return "";
} catch (error) {
return "";
}
}
function ensureTabState(tabId) {
if (!TAB_CAPTURED.has(tabId)) {
TAB_CAPTURED.set(tabId, {
latestSampleId: "",
entriesBySample: new Map()
});
}
return TAB_CAPTURED.get(tabId);
}
function tabIdFromSender(sender) {
return sender && sender.tab && typeof sender.tab.id === "number" ? sender.tab.id : -1;
}
function maybeClearTabState(tabId) {
if (typeof tabId === "number" && tabId >= 0) {
TAB_CAPTURED.delete(tabId);
}
}
function toHistoryRow(entry) {
return {
sampleId: entry.sampleId || "",
previewUrl: entry.previewUrl || "",
fullUrl: entry.fullUrl || "",
url: entry.fullUrl || entry.previewUrl || "",
source: entry.source || "",
capturedAt: entry.capturedAt || Date.now()
};
}
function mergeEntry(tabState, inputEntry, source) {
const sampleId = inputEntry.sampleId || extractSampleIdFromUrl(inputEntry.previewUrl || inputEntry.fullUrl || "");
if (!sampleId) {
return null;
}
const existing = tabState.entriesBySample.get(sampleId) || {
sampleId,
previewUrl: "",
fullUrl: "",
source: source || "unknown",
capturedAt: Date.now()
};
const merged = {
...existing,
sampleId,
source: source || existing.source || "unknown",
capturedAt: Date.now()
};
if (inputEntry.previewUrl) {
merged.previewUrl = inputEntry.previewUrl;
}
if (inputEntry.fullUrl) {
merged.fullUrl = inputEntry.fullUrl;
FULL_URL_BY_SAMPLE.set(sampleId, inputEntry.fullUrl);
} else if (!merged.fullUrl && FULL_URL_BY_SAMPLE.has(sampleId)) {
merged.fullUrl = FULL_URL_BY_SAMPLE.get(sampleId);
}
tabState.entriesBySample.set(sampleId, merged);
tabState.latestSampleId = sampleId;
return merged;
}
function upsertPreviewContext(tabId, context, source) {
const tabState = ensureTabState(tabId);
const previewUrl = normalizeUrl(context.previewUrl || "");
if (!previewUrl || !isAudioSampleUrl(previewUrl) || isWaveformUrl(previewUrl)) {
return null;
}
const sampleId = (context.sampleId || extractSampleIdFromUrl(previewUrl)).toLowerCase();
if (!sampleId) {
return null;
}
return mergeEntry(
tabState,
{ sampleId, previewUrl, fullUrl: context.fullUrl ? normalizeUrl(context.fullUrl) : "" },
source || "graphql_preview"
);
}
function upsertUrl(tabId, url, source) {
const normalized = normalizeUrl(url);
if (!normalized || !isAudioSampleUrl(normalized) || isWaveformUrl(normalized)) {
return null;
}
const sampleId = extractSampleIdFromUrl(normalized);
if (!sampleId) {
return null;
}
const entry = {
sampleId,
previewUrl: isPreviewUrl(normalized) ? normalized : "",
fullUrl: isFullUrl(normalized) ? normalized : ""
};
if (typeof tabId === "number" && tabId >= 0) {
return mergeEntry(ensureTabState(tabId), entry, source);
}
if (entry.fullUrl) {
FULL_URL_BY_SAMPLE.set(sampleId, entry.fullUrl);
}
return null;
}
async function appendHistoryRows(rows) {
if (!rows.length) {
return;
}
const data = await browser.storage.local.get(HISTORY_KEY);
const current = Array.isArray(data[HISTORY_KEY]) ? data[HISTORY_KEY] : [];
const combined = [...current];
const seen = new Set(current.map((item) => `${item.sampleId}|${item.url}`));
for (const row of rows) {
const key = `${row.sampleId}|${row.url}`;
if (!row.url || seen.has(key)) {
continue;
}
seen.add(key);
combined.push(row);
}
await browser.storage.local.set({ [HISTORY_KEY]: combined.slice(-MAX_HISTORY_ITEMS) });
}
function decodePass(startIdx, arr, encodeBlk, endIdx) {
let blockIdx = 0;
for (let i = startIdx; i < endIdx; i += 1) {
if (blockIdx > encodeBlk.length - 1) {
blockIdx = 0;
}
arr[i] = arr[i] ^ encodeBlk.charCodeAt(blockIdx);
blockIdx += 1;
}
return endIdx;
}
function decodeSpliceAudio(data) {
const sizeData = Array.from(data.subarray(2, 10));
let size = 0;
for (let i = sizeData.length - 1; i >= 0; i -= 1) {
size = (256 * size) + sizeData[i];
}
const encodingData = data.subarray(10, 28);
const chunks = [];
for (let i = 0; i < encodingData.length; i += 32768) {
chunks.push(String.fromCharCode(...Array.from(encodingData.subarray(i, i + 32768))));
}
const encodeBlk = chunks.join("");
const audioData = data.slice(28);
const passIdx = decodePass(0, audioData, encodeBlk, size) + size;
decodePass(passIdx, audioData, encodeBlk, passIdx + size);
return audioData;
}
function deriveFilename(entry, mode) {
const base = entry.sampleId || `splice-${Date.now()}`;
if (mode === "full") {
const full = entry.fullUrl || "";
if (full.endsWith(".wav") || full.includes(".wav?")) {
return `${base}.wav`;
}
}
return `${base}-decoded.mp3`;
}
async function downloadDirectUrl(url, entry) {
const filename = deriveFilename(entry, "full");
await browser.downloads.download({
url,
saveAs: true,
filename,
conflictAction: "uniquify"
});
return { ok: true, mode: "full", filename };
}
async function downloadDecodedPreview(previewUrl, entry) {
const response = await fetch(previewUrl, { credentials: "include" });
if (!response.ok) {
throw new Error(`Preview fetch failed (${response.status}).`);
}
const raw = new Uint8Array(await response.arrayBuffer());
const decoded = decodeSpliceAudio(raw);
const blob = new Blob([decoded], { type: "audio/mpeg" });
const objectUrl = URL.createObjectURL(blob);
const filename = deriveFilename(entry, "decoded");
try {
await browser.downloads.download({
url: objectUrl,
saveAs: true,
filename,
conflictAction: "uniquify"
});
} finally {
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30000);
}
return { ok: true, mode: "decoded", filename };
}
function extractAudioUrlsFromObject(input, output) {
if (!input) {
return;
}
if (typeof input === "string") {
const normalized = normalizeUrl(input);
if (normalized && isFullUrl(normalized)) {
output.add(normalized);
}
return;
}
if (Array.isArray(input)) {
for (const item of input) {
extractAudioUrlsFromObject(item, output);
}
return;
}
if (typeof input === "object") {
for (const value of Object.values(input)) {
extractAudioUrlsFromObject(value, output);
}
}
}
async function queryBackendForFullUrl(entry) {
const sampleId = entry.sampleId || "";
if (!sampleId) {
return "";
}
if (FULL_URL_BY_SAMPLE.has(sampleId)) {
return FULL_URL_BY_SAMPLE.get(sampleId);
}
const body = {
operationName: "SampleLookupForDownload",
query:
"query SampleLookupForDownload($query: String!, $limit: Int = 5) {" +
" assetsSearch(filter: {legacy: true, published: true, asset_type_slug: sample, query: $query}," +
" pagination: {page: 1, limit: $limit}, sort: {sort: relevance, order: DESC}) {" +
" items { ... on IAsset { uuid files { asset_file_type_slug url path } } } } }",
variables: { query: sampleId, limit: 5 }
};
try {
const response = await fetch(GRAPHQL_URL, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify(body)
});
if (!response.ok) {
return "";
}
const json = await response.json();
const urls = new Set();
extractAudioUrlsFromObject(json, urls);
for (const url of urls) {
const id = extractSampleIdFromUrl(url);
if (id === sampleId && isFullUrl(url)) {
FULL_URL_BY_SAMPLE.set(sampleId, url);
return url;
}
}
return "";
} catch (error) {
return "";
}
}
async function resolveAndDownloadEntry(entryInput) {
const entry = entryInput || {};
const sampleId = entry.sampleId || extractSampleIdFromUrl(entry.previewUrl || entry.fullUrl || "");
const normalizedEntry = {
sampleId,
previewUrl: normalizeUrl(entry.previewUrl || ""),
fullUrl: normalizeUrl(entry.fullUrl || "")
};
if (normalizedEntry.fullUrl && isFullUrl(normalizedEntry.fullUrl)) {
return downloadDirectUrl(normalizedEntry.fullUrl, normalizedEntry);
}
const discoveredFull = await queryBackendForFullUrl(normalizedEntry);
if (discoveredFull) {
normalizedEntry.fullUrl = discoveredFull;
return downloadDirectUrl(discoveredFull, normalizedEntry);
}
if (normalizedEntry.previewUrl) {
return downloadDecodedPreview(normalizedEntry.previewUrl, normalizedEntry);
}
throw new Error("No preview or full URL available for this sample.");
}
function getLatestEntryForTab(tabId) {
const state = TAB_CAPTURED.get(tabId);
if (!state || !state.latestSampleId) {
return null;
}
return state.entriesBySample.get(state.latestSampleId) || null;
}
function serializeEntry(entry) {
return {
sampleId: entry.sampleId || "",
previewUrl: entry.previewUrl || "",
fullUrl: entry.fullUrl || "",
source: entry.source || "",
capturedAt: entry.capturedAt || Date.now()
};
}
function serializeTabEntries(state) {
return Array.from(state.entriesBySample.values())
.sort((a, b) => (b.capturedAt || 0) - (a.capturedAt || 0))
.map(serializeEntry);
}
browser.runtime.onMessage.addListener((message, sender) => {
if (!message || typeof message.type !== "string") {
return undefined;
}
if (message.type === "CAPTURE_PREVIEW_CONTEXT") {
const tabId = tabIdFromSender(sender);
if (tabId < 0) {
return Promise.resolve({ ok: false, added: 0 });
}
const senderUrl = sender && sender.tab ? sender.tab.url : "";
if (!isDomainAllowed(senderUrl)) {
return Promise.resolve({ ok: false, added: 0 });
}
const contexts = Array.isArray(message.contexts) ? message.contexts : [];
const rows = [];
for (const context of contexts) {
const merged = upsertPreviewContext(tabId, context, "graphql_preview");
if (merged) {
rows.push(toHistoryRow(merged));
}
}
return appendHistoryRows(rows).then(() => ({ ok: true, added: rows.length }));
}
if (message.type === "CAPTURE_URLS") {
const tabId = tabIdFromSender(sender);
const urls = Array.isArray(message.urls) ? message.urls : [];
const rows = [];
for (const raw of urls) {
const merged = upsertUrl(tabId, raw, message.source || "network_preview");
if (merged) {
rows.push(toHistoryRow(merged));
}
}
return appendHistoryRows(rows).then(() => ({ ok: true, added: rows.length }));
}
if (message.type === "GET_TAB_URLS") {
const tabId = typeof message.tabId === "number" ? message.tabId : -1;
const state = TAB_CAPTURED.get(tabId);
if (!state) {
return Promise.resolve({ ok: true, entries: [] });
}
return Promise.resolve({
ok: true,
entries: serializeTabEntries(state)
});
}
if (message.type === "GET_HISTORY") {
return browser.storage.local.get(HISTORY_KEY).then((data) => {
const rows = Array.isArray(data[HISTORY_KEY]) ? data[HISTORY_KEY] : [];
rows.sort((a, b) => (b.capturedAt || 0) - (a.capturedAt || 0));
return { ok: true, history: rows };
});
}
if (message.type === "DOWNLOAD_ENTRY") {
return resolveAndDownloadEntry(message.entry)
.then((result) => result)
.catch((error) => ({ ok: false, error: error.message || "Download failed." }));
}
if (message.type === "DOWNLOAD_LATEST") {
const tabId = tabIdFromSender(sender);
const latest = getLatestEntryForTab(tabId);
if (!latest) {
return Promise.resolve({ ok: false, error: "No captured sample is available yet." });
}
return resolveAndDownloadEntry(latest)
.then((result) => result)
.catch((error) => ({ ok: false, error: error.message || "Download failed." }));
}
if (message.type === "OPEN_HISTORY") {
const historyUrl = browser.runtime.getURL("history.html");
return browser.tabs.create({ url: historyUrl }).then(() => ({ ok: true }));
}
return undefined;
});
function handleWebRequestCapture(details) {
const url = typeof details.url === "string" ? details.url : "";
if (!isAudioSampleUrl(url) || isWaveformUrl(url)) {
return;
}
const tabId = typeof details.tabId === "number" ? details.tabId : -1;
const merged = upsertUrl(tabId, url, "webrequest");
if (merged) {
appendHistoryRows([toHistoryRow(merged)]).catch(() => {
// Ignore history write failures.
});
}
}
browser.webRequest.onBeforeRequest.addListener(
handleWebRequestCapture,
{ urls: ["*://*.amazonaws.com/*", "*://*.amazonaws.com.cn/*"] }
);
browser.tabs.onRemoved.addListener((tabId) => {
maybeClearTabState(tabId);
});
browser.webNavigation.onCommitted.addListener((details) => {
if (details.frameId !== 0) {
return;
}
maybeClearTabState(details.tabId);
});