855 lines
26 KiB
JavaScript
855 lines
26 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";
|
|
const DEBUG_TRACE = true;
|
|
const DEBUG_TRACE_MARKER = "S3DBG_MAPTRACE_V1";
|
|
|
|
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 normalizeName(value) {
|
|
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
}
|
|
|
|
function debugMapTrace(stage, entry) {
|
|
if (!DEBUG_TRACE) {
|
|
return;
|
|
}
|
|
const safe = entry && typeof entry === "object" ? entry : {};
|
|
console.debug(DEBUG_TRACE_MARKER, stage, {
|
|
sampleId: safe.sampleId || "",
|
|
fileName: safe.fileName || "",
|
|
key: safe.key || "",
|
|
bpm: safe.bpm || "",
|
|
tagsCount: Array.isArray(safe.tags) ? safe.tags.length : 0,
|
|
coverImageUrl: safe.coverImageUrl || "",
|
|
previewUrl: safe.previewUrl || "",
|
|
fullUrl: safe.fullUrl || "",
|
|
source: safe.source || ""
|
|
});
|
|
}
|
|
|
|
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 mergeEntry(tabState, inputEntry, source, options) {
|
|
const setLatest = !options || options.setLatest !== false;
|
|
const sampleId = inputEntry.sampleId || extractSampleIdFromUrl(inputEntry.previewUrl || inputEntry.fullUrl || "");
|
|
if (!sampleId) {
|
|
return null;
|
|
}
|
|
|
|
const existing = tabState.entriesBySample.get(sampleId) || {
|
|
sampleId,
|
|
previewUrl: "",
|
|
fullUrl: "",
|
|
fileName: "",
|
|
key: "",
|
|
bpm: "",
|
|
tags: [],
|
|
coverImageUrl: "",
|
|
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);
|
|
}
|
|
if (inputEntry.fileName) {
|
|
merged.fileName = String(inputEntry.fileName).trim();
|
|
}
|
|
if (typeof inputEntry.key === "string" && inputEntry.key.trim()) {
|
|
merged.key = inputEntry.key.trim();
|
|
}
|
|
if (typeof inputEntry.bpm === "string" && inputEntry.bpm.trim()) {
|
|
merged.bpm = inputEntry.bpm.trim();
|
|
}
|
|
if (Array.isArray(inputEntry.tags) && inputEntry.tags.length) {
|
|
merged.tags = Array.from(new Set(inputEntry.tags.map((x) => String(x).trim()).filter(Boolean)));
|
|
}
|
|
if (typeof inputEntry.coverImageUrl === "string" && inputEntry.coverImageUrl.trim()) {
|
|
merged.coverImageUrl = inputEntry.coverImageUrl.trim();
|
|
}
|
|
|
|
tabState.entriesBySample.set(sampleId, merged);
|
|
if (setLatest) {
|
|
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) : "",
|
|
fileName: context.fileName || "",
|
|
key: context.key || "",
|
|
bpm: context.bpm || "",
|
|
tags: Array.isArray(context.tags) ? context.tags : [],
|
|
coverImageUrl: context.coverImageUrl || ""
|
|
},
|
|
source || "graphql_preview",
|
|
{ setLatest: false }
|
|
);
|
|
}
|
|
|
|
function upsertUrl(tabId, url, source) {
|
|
const sourceTag = source && typeof source === "object" ? source.tag || "" : source || "";
|
|
const sourceFileName = source && typeof source === "object" ? source.fileName || "" : "";
|
|
const sourceKey = source && typeof source === "object" ? source.key || "" : "";
|
|
const sourceBpm = source && typeof source === "object" ? source.bpm || "" : "";
|
|
const sourceTags = source && typeof source === "object" && Array.isArray(source.tags) ? source.tags : [];
|
|
const sourceCoverImageUrl = source && typeof source === "object" ? source.coverImageUrl || "" : "";
|
|
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 : "",
|
|
fileName: sourceFileName,
|
|
key: sourceKey,
|
|
bpm: sourceBpm,
|
|
tags: sourceTags,
|
|
coverImageUrl: sourceCoverImageUrl
|
|
};
|
|
|
|
if (typeof tabId === "number" && tabId >= 0) {
|
|
return mergeEntry(ensureTabState(tabId), entry, sourceTag);
|
|
}
|
|
|
|
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 seenMap = new Map();
|
|
current.forEach((item, idx) => {
|
|
seenMap.set(`${item.sampleId}|${item.url}`, idx);
|
|
});
|
|
|
|
for (const row of rows) {
|
|
const key = `${row.sampleId}|${row.url}`;
|
|
if (!row.url) {
|
|
continue;
|
|
}
|
|
if (seenMap.has(key)) {
|
|
const existingIdx = seenMap.get(key);
|
|
const existing = combined[existingIdx] || {};
|
|
const hasCoverImageUrl = Object.prototype.hasOwnProperty.call(row, "coverImageUrl");
|
|
combined[existingIdx] = {
|
|
...existing,
|
|
...row,
|
|
fileName: row.fileName || existing.fileName || "",
|
|
key: row.key || existing.key || "",
|
|
bpm: row.bpm || existing.bpm || "",
|
|
tags: Array.isArray(row.tags) && row.tags.length ? row.tags : (existing.tags || []),
|
|
coverImageUrl: hasCoverImageUrl ? String(row.coverImageUrl || "") : (existing.coverImageUrl || "")
|
|
};
|
|
continue;
|
|
}
|
|
seenMap.set(key, combined.length);
|
|
combined.push(row);
|
|
}
|
|
|
|
await browser.storage.local.set({ [HISTORY_KEY]: combined.slice(-MAX_HISTORY_ITEMS) });
|
|
}
|
|
|
|
async function appendDownloadHistory(entry, result) {
|
|
const resolvedEntry = result && result.resolvedEntry ? result.resolvedEntry : entry;
|
|
const historyRow = {
|
|
sampleId: resolvedEntry.sampleId || "",
|
|
previewUrl: resolvedEntry.previewUrl || "",
|
|
fullUrl: resolvedEntry.fullUrl || "",
|
|
fileName: resolvedEntry.fileName || "",
|
|
key: resolvedEntry.key || "",
|
|
bpm: resolvedEntry.bpm || "",
|
|
tags: Array.isArray(resolvedEntry.tags) ? resolvedEntry.tags : [],
|
|
coverImageUrl: resolvedEntry.coverImageUrl || "",
|
|
url: result.historyUrl || resolvedEntry.fullUrl || "",
|
|
source: result.mode === "full" ? "full_download" : "decoded_preview",
|
|
capturedAt: Date.now(),
|
|
mode: result.mode
|
|
};
|
|
await appendHistoryRows([historyRow]);
|
|
}
|
|
|
|
function makeDecodedPlaceholderRow(entry, source) {
|
|
const sampleId = entry.sampleId || "";
|
|
if (!sampleId) {
|
|
return null;
|
|
}
|
|
return {
|
|
sampleId,
|
|
previewUrl: entry.previewUrl || "",
|
|
fullUrl: entry.fullUrl || "",
|
|
fileName: entry.fileName || "",
|
|
key: entry.key || "",
|
|
bpm: entry.bpm || "",
|
|
tags: Array.isArray(entry.tags) ? entry.tags : [],
|
|
coverImageUrl: entry.coverImageUrl || "",
|
|
url: `decoded://${sampleId}`,
|
|
source: source || entry.source || "captured_preview",
|
|
capturedAt: entry.capturedAt || Date.now(),
|
|
mode: "pending_decoded"
|
|
};
|
|
}
|
|
|
|
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 sampleBase = entry.sampleId || `splice-${Date.now()}`;
|
|
const rawName = typeof entry.fileName === "string" ? entry.fileName.trim() : "";
|
|
const cleanName = rawName ? rawName.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_") : "";
|
|
const hasExt = cleanName.lastIndexOf(".") > 0;
|
|
const stem = hasExt ? cleanName.slice(0, cleanName.lastIndexOf(".")) : cleanName;
|
|
const ext = hasExt ? cleanName.slice(cleanName.lastIndexOf(".")).toLowerCase() : "";
|
|
|
|
const safeStem = stem || sampleBase;
|
|
if (mode === "full") {
|
|
if (ext === ".wav" || ext === ".mp3" || ext === ".aif" || ext === ".aiff" || ext === ".flac") {
|
|
return `${safeStem}${ext}`;
|
|
}
|
|
const full = (entry.fullUrl || "").toLowerCase();
|
|
if (full.endsWith(".wav") || full.includes(".wav?")) {
|
|
return `${safeStem}.wav`;
|
|
}
|
|
if (full.endsWith(".mp3") || full.includes(".mp3?")) {
|
|
return `${safeStem}.mp3`;
|
|
}
|
|
return `${safeStem}.wav`;
|
|
}
|
|
return `${safeStem}_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, historyUrl: url };
|
|
}
|
|
|
|
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,
|
|
historyUrl: `decoded://${entry.sampleId || Date.now()}`
|
|
};
|
|
}
|
|
|
|
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 "";
|
|
}
|
|
}
|
|
|
|
function findEntryInTabStates(sampleId) {
|
|
for (const state of TAB_CAPTURED.values()) {
|
|
if (!state || !state.entriesBySample) {
|
|
continue;
|
|
}
|
|
const entry = state.entriesBySample.get(sampleId);
|
|
if (entry && (entry.previewUrl || entry.fullUrl)) {
|
|
return {
|
|
sampleId: entry.sampleId || sampleId,
|
|
previewUrl: normalizeUrl(entry.previewUrl || ""),
|
|
fullUrl: normalizeUrl(entry.fullUrl || ""),
|
|
fileName: entry.fileName || "",
|
|
key: entry.key || "",
|
|
bpm: entry.bpm || "",
|
|
tags: Array.isArray(entry.tags) ? entry.tags : [],
|
|
coverImageUrl: entry.coverImageUrl || ""
|
|
};
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function hydrateEntryFromHistory(sampleId) {
|
|
const data = await browser.storage.local.get(HISTORY_KEY);
|
|
const rows = Array.isArray(data[HISTORY_KEY]) ? data[HISTORY_KEY] : [];
|
|
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
const row = rows[i];
|
|
if (!row || row.sampleId !== sampleId) {
|
|
continue;
|
|
}
|
|
const previewUrl = normalizeUrl(row.previewUrl || "");
|
|
const fullUrl = normalizeUrl(row.fullUrl || "");
|
|
if (previewUrl || fullUrl) {
|
|
return {
|
|
sampleId,
|
|
previewUrl,
|
|
fullUrl,
|
|
fileName: row.fileName || "",
|
|
key: row.key || "",
|
|
bpm: row.bpm || "",
|
|
tags: Array.isArray(row.tags) ? row.tags : [],
|
|
coverImageUrl: row.coverImageUrl || ""
|
|
};
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function resolveAndDownloadEntry(entryInput) {
|
|
const entry = entryInput || {};
|
|
const sampleId = entry.sampleId || extractSampleIdFromUrl(entry.previewUrl || entry.fullUrl || "");
|
|
let normalizedEntry = {
|
|
sampleId,
|
|
previewUrl: normalizeUrl(entry.previewUrl || ""),
|
|
fullUrl: normalizeUrl(entry.fullUrl || ""),
|
|
fileName: entry.fileName || "",
|
|
key: entry.key || "",
|
|
bpm: entry.bpm || "",
|
|
tags: Array.isArray(entry.tags) ? entry.tags : [],
|
|
coverImageUrl: entry.coverImageUrl || ""
|
|
};
|
|
|
|
if (normalizedEntry.sampleId && !normalizedEntry.previewUrl && !normalizedEntry.fullUrl) {
|
|
const fromTabState = findEntryInTabStates(normalizedEntry.sampleId);
|
|
if (fromTabState) {
|
|
normalizedEntry = fromTabState;
|
|
} else {
|
|
const fromHistory = await hydrateEntryFromHistory(normalizedEntry.sampleId);
|
|
if (fromHistory) {
|
|
normalizedEntry = fromHistory;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (normalizedEntry.fullUrl && isFullUrl(normalizedEntry.fullUrl)) {
|
|
const result = await downloadDirectUrl(normalizedEntry.fullUrl, normalizedEntry);
|
|
return { ...result, resolvedEntry: normalizedEntry };
|
|
}
|
|
|
|
const discoveredFull = await queryBackendForFullUrl(normalizedEntry);
|
|
if (discoveredFull) {
|
|
normalizedEntry.fullUrl = discoveredFull;
|
|
const result = await downloadDirectUrl(discoveredFull, normalizedEntry);
|
|
return { ...result, resolvedEntry: normalizedEntry };
|
|
}
|
|
|
|
if (normalizedEntry.previewUrl) {
|
|
const result = await downloadDecodedPreview(normalizedEntry.previewUrl, normalizedEntry);
|
|
return { ...result, resolvedEntry: 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 || "",
|
|
fileName: entry.fileName || "",
|
|
key: entry.key || "",
|
|
bpm: entry.bpm || "",
|
|
tags: Array.isArray(entry.tags) ? entry.tags : [],
|
|
coverImageUrl: entry.coverImageUrl || "",
|
|
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 : [];
|
|
for (const context of contexts) {
|
|
const merged = upsertPreviewContext(tabId, context, "graphql_preview");
|
|
if (merged) {
|
|
debugMapTrace("CAPTURE_PREVIEW_CONTEXT_MERGED", merged);
|
|
}
|
|
}
|
|
return Promise.resolve({ ok: true, added: contexts.length });
|
|
}
|
|
|
|
if (message.type === "CAPTURE_URLS") {
|
|
const tabId = tabIdFromSender(sender);
|
|
const urls = Array.isArray(message.urls) ? message.urls : [];
|
|
const canAttachPlaybackMeta = urls.length === 1;
|
|
const rows = [];
|
|
for (const raw of urls) {
|
|
const merged = upsertUrl(tabId, raw, {
|
|
tag: message.source || "network_preview",
|
|
fileName: canAttachPlaybackMeta ? (message.fileName || "") : "",
|
|
key: "",
|
|
bpm: "",
|
|
tags: [],
|
|
coverImageUrl: ""
|
|
});
|
|
if (merged) {
|
|
debugMapTrace("CAPTURE_URLS_MERGED", merged);
|
|
}
|
|
if (merged && merged.previewUrl && canAttachPlaybackMeta) {
|
|
const row = makeDecodedPlaceholderRow(merged, message.source || "network_preview");
|
|
if (row) {
|
|
rows.push(row);
|
|
}
|
|
}
|
|
}
|
|
return appendHistoryRows(rows).then(() => ({ ok: true, added: urls.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] : [];
|
|
const filtered = rows.filter((row) => {
|
|
const url = typeof row.url === "string" ? row.url : "";
|
|
if (url.startsWith("decoded://")) {
|
|
return true;
|
|
}
|
|
if (typeof row.fullUrl === "string" && isFullUrl(row.fullUrl)) {
|
|
return true;
|
|
}
|
|
return isFullUrl(url);
|
|
});
|
|
filtered.sort((a, b) => (b.capturedAt || 0) - (a.capturedAt || 0));
|
|
return { ok: true, history: filtered };
|
|
});
|
|
}
|
|
|
|
if (message.type === "DOWNLOAD_ENTRY") {
|
|
return resolveAndDownloadEntry(message.entry)
|
|
.then(async (result) => {
|
|
if (result && result.ok) {
|
|
const entry = message.entry || {};
|
|
await appendDownloadHistory(entry, result);
|
|
}
|
|
return result;
|
|
})
|
|
.catch((error) => ({ ok: false, error: error.message || "Download failed." }));
|
|
}
|
|
|
|
if (message.type === "UPDATE_ACTIVE_PLAYBACK_META" || message.type === "UPDATE_ACTIVE_FILENAME") {
|
|
const tabId = tabIdFromSender(sender);
|
|
const state = TAB_CAPTURED.get(tabId);
|
|
if (!state || !state.latestSampleId) {
|
|
return Promise.resolve({ ok: false });
|
|
}
|
|
const latest = state.entriesBySample.get(state.latestSampleId);
|
|
if (!latest) {
|
|
return Promise.resolve({ ok: false });
|
|
}
|
|
const fileName = typeof message.fileName === "string" ? message.fileName.trim() : "";
|
|
const key = typeof message.key === "string" ? message.key.trim() : "";
|
|
const bpm = typeof message.bpm === "string" ? message.bpm.trim() : "";
|
|
const tags = Array.isArray(message.tags) ? message.tags.map((x) => String(x).trim()).filter(Boolean) : [];
|
|
const coverImageUrl = typeof message.coverImageUrl === "string" ? message.coverImageUrl.trim() : "";
|
|
const entries = Array.from(state.entriesBySample.values());
|
|
let targetEntry = null;
|
|
if (fileName) {
|
|
const wantedName = normalizeName(fileName);
|
|
const matches = entries
|
|
.filter((entry) => normalizeName(entry.fileName) === wantedName)
|
|
.sort((a, b) => (b.capturedAt || 0) - (a.capturedAt || 0));
|
|
if (matches.length) {
|
|
targetEntry = matches[0];
|
|
}
|
|
}
|
|
if (!targetEntry && state.latestSampleId) {
|
|
const latestEntry = state.entriesBySample.get(state.latestSampleId);
|
|
const freshEnough = latestEntry && (Date.now() - (latestEntry.capturedAt || 0) < 15000);
|
|
const canHydrate = latestEntry && !latestEntry.fileName;
|
|
if (freshEnough && canHydrate) {
|
|
targetEntry = latestEntry;
|
|
}
|
|
}
|
|
if (!targetEntry) {
|
|
return Promise.resolve({ ok: false });
|
|
}
|
|
|
|
if (fileName) {
|
|
targetEntry.fileName = fileName;
|
|
}
|
|
if (key) {
|
|
targetEntry.key = key;
|
|
}
|
|
if (bpm) {
|
|
targetEntry.bpm = bpm;
|
|
}
|
|
if (tags.length) {
|
|
targetEntry.tags = Array.from(new Set(tags));
|
|
}
|
|
if (coverImageUrl) {
|
|
targetEntry.coverImageUrl = coverImageUrl;
|
|
}
|
|
targetEntry.capturedAt = Date.now();
|
|
state.entriesBySample.set(targetEntry.sampleId, targetEntry);
|
|
state.latestSampleId = targetEntry.sampleId;
|
|
debugMapTrace("UPDATE_ACTIVE_PLAYBACK_META_TARGET", targetEntry);
|
|
const row = makeDecodedPlaceholderRow(targetEntry, targetEntry.source || "playback_meta");
|
|
if (!row) {
|
|
return Promise.resolve({ ok: true });
|
|
}
|
|
return appendHistoryRows([row]).then(() => ({ ok: true }));
|
|
}
|
|
|
|
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(async (result) => {
|
|
if (result && result.ok) {
|
|
await appendDownloadHistory(latest, result);
|
|
}
|
|
return 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");
|
|
void merged;
|
|
}
|
|
|
|
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);
|
|
});
|