commit b8d7e732d521302ebc29a89f1e8509cc009f4193 Author: Zetaphor Date: Wed Aug 5 21:39:14 2026 -0500 Working diff --git a/README.md b/README.md new file mode 100644 index 0000000..66f86d3 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# Firefox Splice Download Helper + +This Firefox extension captures Splice sample preview context from GraphQL responses, then attempts to download full-file URLs when available. If full retrieval is unavailable, it falls back to decoding preview audio bytes (splicedd-style) and downloads decoded MP3. + +## Features + +- Captures sample context from Splice GraphQL responses (`preview_mp3`, sample UUID/hash). +- Tracks observed audio sample URLs from network/probe fallback. +- Download action is orchestrated as: + 1. Attempt full-file URL retrieval first. + 2. If unavailable, decode scrambled preview bytes and download decoded MP3. +- Injects two controls in the target row: + - **Download**: full-first, decoded-preview fallback + - **History**: opens extension history page with past captured URLs +- Popup/history show each captured sample with source and mode hints. + +## Targeted Page Element + +Inline controls are injected into: + +- `.playbar-playback-info-bar.playback-row-section.svelte-11gjrk3` + +The inserted controls use sibling-style wrappers based on: + +- `bordered-item meta-item svelte-11gjrk3` + +## URL Rules + +- Page scope: `*://*.splice.com/*` +- Audio sample paths include `/audio_samples/` +- Waveform files (`.wv.json`) are excluded from download candidates + +## Load in Firefox (Temporary Add-on) + +1. Open `about:debugging#/runtime/this-firefox` +2. Click **Load Temporary Add-on...** +3. Select this extension's `manifest.json` +4. Visit a matching `*.splice.com` page + +## Usage + +1. Open a page under `*.splice.com` that loads S3 audio links. +2. Wait for inline `Download` and `History` controls to appear in the playback info bar. +3. Click: + - **Download** to run full-first retrieval, then decode fallback if needed + - **History** to open full captured history +4. Optionally click the extension icon to open popup for current-tab entries. + +## Manual Verification Checklist + +- [ ] On matching domain, controls appear in target playback row. +- [ ] Controls remain after SPA/Svelte rerenders. +- [ ] GraphQL preview context is captured for visible samples. +- [ ] `Download` attempts full retrieval first. +- [ ] When full retrieval is unavailable, fallback decoded MP3 is downloaded and playable. +- [ ] `History` opens and allows downloading older entries. +- [ ] Popup updates with captured entries for active tab. +- [ ] Non-`*.splice.com` pages do not capture/store URLs. + +## Known Limitations + +- Full-file URL retrieval depends on backend behavior and session state; it may fail for some assets. +- Fallback decode uses scrambled preview streams, so fallback output follows preview-source quality/availability. +- Signed preview URLs can expire; expired URLs require recapture. +- `saveAs: true` prompts download location each time by design. diff --git a/background.js b/background.js new file mode 100644 index 0000000..5444c75 --- /dev/null +++ b/background.js @@ -0,0 +1,553 @@ +"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); +}); diff --git a/content-script.js b/content-script.js new file mode 100644 index 0000000..50379c7 --- /dev/null +++ b/content-script.js @@ -0,0 +1,598 @@ +"use strict"; + +const TARGET_SELECTOR = ".playbar-playback-info-bar.playback-row-section.svelte-11gjrk3"; +const INJECTED_CONTAINER_ID = "s3-audio-capture-controls"; +const SPLICE_DOMAIN_RE = /^https?:\/\/([a-z0-9-]+\.)*splice\.com(\/|$)/i; +const GRAPHQL_URL_FRAGMENT = "surfaces-graphql.splice.com/graphql"; +const PROBE_EVENT_SOURCE = "S3_CAPTURE_PROBE"; +const DEBUG_CAPTURE = true; +let knownUrls = new Set(); +let knownPerformanceUrls = new Set(); +let observer = null; +let captureObserver = null; +let performanceObserver = null; +let fetchPatched = false; +let xhrPatched = false; +let probeListenerInstalled = false; + +function isDomainAllowed(url) { + return typeof url === "string" && SPLICE_DOMAIN_RE.test(url); +} + +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."); + if (!isAwsHost || !isS3Host) { + return false; + } + const path = parsed.pathname || ""; + if (!path.includes("/audio_samples/")) { + return false; + } + if (path.endsWith(".wv.json")) { + return false; + } + return true; + } catch (error) { + return false; + } +} + +function normalizeUrl(rawUrl) { + try { + return new URL(rawUrl, window.location.href).toString(); + } catch (error) { + return ""; + } +} + +function logDebug(message, extra) { + if (!DEBUG_CAPTURE) { + return; + } + if (typeof extra === "undefined") { + console.debug("[S3 capture]", message); + return; + } + console.debug("[S3 capture]", message, extra); +} + +function collectProbeCandidateUrls(payload) { + if (!payload || typeof payload !== "object") { + return []; + } + + const candidates = []; + const keys = ["requestUrl", "responseUrl", "linkedUrl", "url", "blobUrl"]; + for (const key of keys) { + if (typeof payload[key] === "string" && payload[key]) { + candidates.push(payload[key]); + } + } + + return candidates; +} + +function extractSampleIdFromUrl(url) { + try { + const parsed = new URL(url); + const marker = "/audio_samples/"; + const idx = parsed.pathname.indexOf(marker); + if (idx < 0) { + return ""; + } + const rest = parsed.pathname.slice(idx + marker.length); + const parts = rest.split("/").filter(Boolean); + const first = (parts[0] || "").replace(/-scrambled$/i, ""); + const second = (parts[1] || "").split(".")[0]; + const hashRe = /^[a-f0-9]{64}$/i; + if (hashRe.test(first)) { + return first.toLowerCase(); + } + if (hashRe.test(second)) { + return second.toLowerCase(); + } + return ""; + } catch (error) { + return ""; + } +} + +function recursivelyCollectAudioUrls(value, output) { + if (typeof value === "string") { + const normalized = normalizeUrl(value); + if (normalized && isS3Url(normalized)) { + output.add(normalized); + } + return; + } + if (Array.isArray(value)) { + for (const item of value) { + recursivelyCollectAudioUrls(item, output); + } + return; + } + if (value && typeof value === "object") { + for (const nested of Object.values(value)) { + recursivelyCollectAudioUrls(nested, output); + } + } +} + +function extractPreviewContextsFromGraphql(json) { + const contexts = []; + const items = json && + json.data && + json.data.assetsSearch && + Array.isArray(json.data.assetsSearch.items) + ? json.data.assetsSearch.items + : []; + + for (const item of items) { + if (!item || typeof item !== "object") { + continue; + } + const sampleId = typeof item.uuid === "string" ? item.uuid.toLowerCase() : ""; + const files = Array.isArray(item.files) ? item.files : []; + + for (const file of files) { + if (!file || typeof file !== "object") { + continue; + } + const typeSlug = String(file.asset_file_type_slug || "").toLowerCase(); + const fileUrl = normalizeUrl(file.url || ""); + if (typeSlug !== "preview_mp3" || !fileUrl) { + continue; + } + contexts.push({ + sampleId: sampleId || extractSampleIdFromUrl(fileUrl), + previewUrl: fileUrl + }); + } + } + + return contexts; +} + +function sendPreviewContexts(contexts) { + if (!contexts.length) { + return; + } + browser.runtime.sendMessage({ + type: "CAPTURE_PREVIEW_CONTEXT", + contexts + }).catch(() => { + // Ignore transient extension messaging failures. + }); + logDebug("Captured preview contexts", contexts); +} + +function inspectGraphqlResponse(candidateUrl, response) { + if (typeof candidateUrl !== "string" || !candidateUrl.includes(GRAPHQL_URL_FRAGMENT)) { + return; + } + if (!response || typeof response.clone !== "function") { + return; + } + + response.clone().json().then((json) => { + const contexts = extractPreviewContextsFromGraphql(json); + if (contexts.length) { + sendPreviewContexts(contexts); + } + const urls = new Set(); + recursivelyCollectAudioUrls(json, urls); + if (urls.size) { + captureUrls(Array.from(urls)); + } + }).catch(() => { + // Some GraphQL responses may not be parseable JSON. + }); +} + +function captureUrls(inputUrls) { + if (!isDomainAllowed(window.location.href)) { + return; + } + + const newUrls = []; + for (const rawUrl of inputUrls) { + const normalized = normalizeUrl(rawUrl); + if (!normalized || !isS3Url(normalized) || knownUrls.has(normalized)) { + continue; + } + knownUrls.add(normalized); + newUrls.push(normalized); + } + + if (!newUrls.length) { + return; + } + + browser.runtime.sendMessage({ + type: "CAPTURE_URLS", + urls: newUrls, + source: "content_capture" + }).catch(() => { + // Ignore transient extension context failures. + }); + logDebug("Captured S3 URLs", newUrls); +} + +function scanNodeForUrls(root) { + if (!(root instanceof Element || root instanceof Document)) { + return; + } + + const urlCandidates = []; + const selectors = [ + "a[href]", + "audio[src]", + "source[src]", + "[data-url]", + "[data-src]" + ]; + + for (const selector of selectors) { + const nodes = root.querySelectorAll(selector); + for (const node of nodes) { + const attrs = ["href", "src", "data-url", "data-src"]; + for (const attr of attrs) { + const value = node.getAttribute(attr); + if (value) { + urlCandidates.push(value); + } + } + } + } + + captureUrls(urlCandidates); +} + +function patchFetch() { + if (fetchPatched || typeof window.fetch !== "function") { + return; + } + try { + const originalFetch = window.fetch.bind(window); + window.fetch = function patchedFetch(input, init) { + const candidate = typeof input === "string" ? input : input && input.url; + if (candidate) { + captureUrls([candidate]); + } + if (init && typeof init === "object" && typeof init.url === "string") { + captureUrls([init.url]); + } + return originalFetch(input, init).then((response) => { + if (candidate) { + inspectGraphqlResponse(candidate, response); + } + return response; + }); + }; + fetchPatched = true; + } catch (error) { + // Firefox may expose fetch as read-only in content script worlds. + fetchPatched = false; + } +} + +function patchXhr() { + if (xhrPatched || !window.XMLHttpRequest) { + return; + } + try { + const originalOpen = window.XMLHttpRequest.prototype.open; + window.XMLHttpRequest.prototype.open = function patchedOpen(method, url) { + if (typeof url === "string") { + captureUrls([url]); + } + return originalOpen.apply(this, arguments); + }; + xhrPatched = true; + } catch (error) { + xhrPatched = false; + } +} + +function capturePerformanceResourceUrls() { + if (typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") { + return; + } + + const resources = performance.getEntriesByType("resource"); + const candidates = []; + + for (const entry of resources) { + if (!entry || typeof entry.name !== "string") { + continue; + } + if (knownPerformanceUrls.has(entry.name)) { + continue; + } + knownPerformanceUrls.add(entry.name); + candidates.push(entry.name); + } + + if (candidates.length) { + captureUrls(candidates); + } +} + +function startPerformanceObserver() { + if (performanceObserver || typeof PerformanceObserver !== "function") { + return; + } + try { + performanceObserver = new PerformanceObserver((list) => { + const candidates = []; + for (const entry of list.getEntries()) { + if (!entry || typeof entry.name !== "string") { + continue; + } + if (knownPerformanceUrls.has(entry.name)) { + continue; + } + knownPerformanceUrls.add(entry.name); + candidates.push(entry.name); + } + if (candidates.length) { + captureUrls(candidates); + } + }); + performanceObserver.observe({ type: "resource", buffered: true }); + } catch (error) { + performanceObserver = null; + } +} + +function createIcon(kind) { + const ns = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(ns, "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("width", "14"); + svg.setAttribute("height", "14"); + svg.setAttribute("aria-hidden", "true"); + + if (kind === "download") { + const arrow = document.createElementNS(ns, "path"); + arrow.setAttribute("d", "M12 3v10m0 0l4-4m-4 4l-4-4"); + arrow.setAttribute("fill", "none"); + arrow.setAttribute("stroke", "currentColor"); + arrow.setAttribute("stroke-width", "2"); + arrow.setAttribute("stroke-linecap", "round"); + arrow.setAttribute("stroke-linejoin", "round"); + + const tray = document.createElementNS(ns, "path"); + tray.setAttribute("d", "M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"); + tray.setAttribute("fill", "none"); + tray.setAttribute("stroke", "currentColor"); + tray.setAttribute("stroke-width", "2"); + tray.setAttribute("stroke-linecap", "round"); + tray.setAttribute("stroke-linejoin", "round"); + + svg.appendChild(arrow); + svg.appendChild(tray); + return svg; + } + + const circle = document.createElementNS(ns, "circle"); + circle.setAttribute("cx", "12"); + circle.setAttribute("cy", "12"); + circle.setAttribute("r", "9"); + circle.setAttribute("fill", "none"); + circle.setAttribute("stroke", "currentColor"); + circle.setAttribute("stroke-width", "2"); + + const hand = document.createElementNS(ns, "path"); + hand.setAttribute("d", "M12 7v5l3 2"); + hand.setAttribute("fill", "none"); + hand.setAttribute("stroke", "currentColor"); + hand.setAttribute("stroke-width", "2"); + hand.setAttribute("stroke-linecap", "round"); + hand.setAttribute("stroke-linejoin", "round"); + + svg.appendChild(circle); + svg.appendChild(hand); + return svg; +} + +function createButtonMetaItem(iconType, ariaLabel, onClick) { + const wrapper = document.createElement("div"); + wrapper.className = "bordered-item meta-item svelte-11gjrk3"; + + const inner = document.createElement("div"); + inner.className = "meta-wrap svelte-11gjrk3"; + + const button = document.createElement("button"); + button.type = "button"; + button.className = "meta-value svelte-11gjrk3 s3-capture-button"; + button.setAttribute("aria-label", ariaLabel); + button.setAttribute("title", ariaLabel); + button.appendChild(createIcon(iconType)); + button.addEventListener("click", onClick); + + inner.appendChild(button); + wrapper.appendChild(inner); + + return wrapper; +} + +function ensureInlineStyles() { + if (document.getElementById("s3-audio-capture-style")) { + return; + } + const style = document.createElement("style"); + style.id = "s3-audio-capture-style"; + style.textContent = ` + .s3-capture-button { + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 4px; + background: rgba(255, 255, 255, 0.08); + color: inherit; + cursor: pointer; + padding: 4px 8px; + font: inherit; + display: inline-flex; + align-items: center; + justify-content: center; + } + .s3-capture-button:hover { + background: rgba(255, 255, 255, 0.16); + } + .s3-capture-button svg { + display: block; + } + `; + document.head.appendChild(style); +} + +function injectControls() { + const target = document.querySelector(TARGET_SELECTOR); + if (!target) { + return; + } + + if (target.querySelector("#" + INJECTED_CONTAINER_ID)) { + return; + } + + ensureInlineStyles(); + + const controlsRoot = document.createElement("div"); + controlsRoot.id = INJECTED_CONTAINER_ID; + controlsRoot.style.display = "contents"; + + const currentButton = createButtonMetaItem("download", "Download current audio", () => { + browser.runtime.sendMessage({ type: "DOWNLOAD_LATEST" }).then((result) => { + if (!result || !result.ok) { + window.alert((result && result.error) || "No URL available for download."); + } + }).catch(() => { + window.alert("Extension message failed."); + }); + }); + + const historyButton = createButtonMetaItem("history", "Open captured history", () => { + browser.runtime.sendMessage({ type: "OPEN_HISTORY" }).catch(() => { + window.alert("Unable to open history."); + }); + }); + + controlsRoot.appendChild(currentButton); + controlsRoot.appendChild(historyButton); + target.appendChild(controlsRoot); +} + +function startUiObserver() { + if (observer) { + observer.disconnect(); + } + observer = new MutationObserver(() => { + injectControls(); + }); + observer.observe(document.documentElement, { childList: true, subtree: true }); +} + +function startCaptureObserver() { + if (captureObserver) { + captureObserver.disconnect(); + } + + captureObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const addedNode of mutation.addedNodes) { + scanNodeForUrls(addedNode); + } + if (mutation.type === "attributes" && mutation.target instanceof Element) { + scanNodeForUrls(mutation.target); + } + } + }); + + captureObserver.observe(document.documentElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["href", "src", "data-url", "data-src"] + }); +} + +function installPageProbeListener() { + if (probeListenerInstalled) { + return; + } + + window.addEventListener("message", (event) => { + if (event.source !== window) { + return; + } + const data = event.data; + if (!data || data.source !== PROBE_EVENT_SOURCE) { + return; + } + + const kind = typeof data.kind === "string" ? data.kind : "unknown"; + const payload = data.payload && typeof data.payload === "object" ? data.payload : {}; + const candidateUrls = collectProbeCandidateUrls(payload); + if (candidateUrls.length) { + captureUrls(candidateUrls); + } + logDebug("Probe event: " + kind, payload); + }); + + probeListenerInstalled = true; +} + +function injectPageProbeScript() { + if (document.getElementById("s3-capture-page-probe")) { + return; + } + + const root = document.head || document.documentElement; + if (!root) { + return; + } + + const script = document.createElement("script"); + script.id = "s3-capture-page-probe"; + script.src = browser.runtime.getURL("page-probe.js"); + script.async = false; + script.onload = () => { + script.remove(); + logDebug("Injected page probe."); + }; + script.onerror = () => { + logDebug("Failed to inject page probe."); + }; + root.appendChild(script); +} + +function init() { + if (!isDomainAllowed(window.location.href)) { + return; + } + + installPageProbeListener(); + injectPageProbeScript(); + scanNodeForUrls(document); + patchFetch(); + patchXhr(); + capturePerformanceResourceUrls(); + startPerformanceObserver(); + window.setInterval(capturePerformanceResourceUrls, 2000); + injectControls(); + startUiObserver(); + startCaptureObserver(); +} + +init(); diff --git a/history.css b/history.css new file mode 100644 index 0000000..e11983b --- /dev/null +++ b/history.css @@ -0,0 +1,58 @@ +body { + margin: 0; + font-family: Arial, sans-serif; + background: #0e1117; + color: #e6edf7; +} + +.history-page { + max-width: 960px; + margin: 0 auto; + padding: 24px; +} + +h1 { + margin: 0 0 8px; +} + +p { + color: #aeb7cc; +} + +#historyList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.history-item { + border: 1px solid #2a2f40; + border-radius: 8px; + padding: 12px; + background: #151a26; +} + +.history-url { + margin: 0 0 8px; + word-break: break-all; + color: #d2d9ea; + font-size: 13px; +} + +.history-meta { + margin: 0 0 10px; + color: #98a5c0; + font-size: 12px; +} + +button { + border: 1px solid #2f66ff; + background: #2f66ff; + color: #fff; + border-radius: 6px; + cursor: pointer; + padding: 6px 10px; +} diff --git a/history.html b/history.html new file mode 100644 index 0000000..62946e9 --- /dev/null +++ b/history.html @@ -0,0 +1,23 @@ + + + + + + + S3 Audio Capture History + + + + +
+
+

S3 Audio Capture History

+

All captured Splice S3 URLs.

+
+

Loading history...

+ +
+ + + + \ No newline at end of file diff --git a/history.js b/history.js new file mode 100644 index 0000000..a5dc90c --- /dev/null +++ b/history.js @@ -0,0 +1,70 @@ +"use strict"; + +const statusEl = document.getElementById("status"); +const listEl = document.getElementById("historyList"); + +function formatDate(ms) { + try { + return new Date(ms).toLocaleString(); + } catch (error) { + return "unknown time"; + } +} + +function buildItem(entry) { + const li = document.createElement("li"); + li.className = "history-item"; + + const url = document.createElement("p"); + url.className = "history-url"; + url.textContent = entry.fullUrl || entry.previewUrl || entry.url || ""; + + const meta = document.createElement("p"); + meta.className = "history-meta"; + const mode = entry.fullUrl ? "full candidate" : "preview fallback"; + meta.textContent = `Captured: ${formatDate(entry.capturedAt)} | Mode: ${mode} | Source: ${entry.source || "unknown"}`; + + const download = document.createElement("button"); + download.type = "button"; + download.textContent = "Download"; + download.addEventListener("click", async () => { + const result = await browser.runtime.sendMessage({ + type: "DOWNLOAD_ENTRY", + entry + }); + if (!result || !result.ok) { + window.alert((result && result.error) || "Download failed."); + return; + } + statusEl.textContent = result.mode === "full" ? "Downloaded full file URL." : "Downloaded decoded preview fallback."; + }); + + li.appendChild(url); + li.appendChild(meta); + li.appendChild(download); + return li; +} + +async function loadHistory() { + const response = await browser.runtime.sendMessage({ type: "GET_HISTORY" }); + if (!response || !response.ok) { + statusEl.textContent = "Unable to load history."; + return; + } + + const rows = Array.isArray(response.history) ? response.history : []; + if (!rows.length) { + statusEl.textContent = "No captured S3 URLs yet."; + return; + } + + statusEl.textContent = `Total captured URLs: ${rows.length}`; + listEl.innerHTML = ""; + for (const entry of rows) { + listEl.appendChild(buildItem(entry)); + } +} + +loadHistory().catch(() => { + statusEl.textContent = "Unexpected error while loading history."; +}); diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..b289479 --- /dev/null +++ b/manifest.json @@ -0,0 +1,48 @@ +{ + "manifest_version": 2, + "name": "Splice Downloader", + "version": "1.0.0", + "description": "Capture Splice S3 audio links on matching pages and download them.", + "permissions": [ + "tabs", + "storage", + "downloads", + "webRequest", + "*://*.splice.com/*", + "*://*.amazonaws.com/*", + "*://*.amazonaws.com.cn/*", + "webNavigation" + ], + "background": { + "scripts": [ + "background.js" + ] + }, + "content_scripts": [ + { + "matches": [ + "*://*.splice.com/*" + ], + "js": [ + "content-script.js" + ], + "run_at": "document_idle" + } + ], + "browser_action": { + "default_title": "S3 Audio Capture", + "default_popup": "popup.html" + }, + "web_accessible_resources": [ + "history.html", + "history.js", + "history.css", + "page-probe.js" + ], + "applications": { + "gecko": { + "id": "s3-audio-capture@splice.local", + "strict_min_version": "109.0" + } + } +} \ No newline at end of file diff --git a/page-probe.js b/page-probe.js new file mode 100644 index 0000000..1af7842 --- /dev/null +++ b/page-probe.js @@ -0,0 +1,182 @@ +"use strict"; + +(function initPageProbe() { + const EVENT_SOURCE = "S3_CAPTURE_PROBE"; + let lastAudioResponseUrl = ""; + + function emit(kind, payload) { + try { + window.postMessage( + { + source: EVENT_SOURCE, + kind, + payload: payload || {} + }, + "*" + ); + } catch (error) { + // Ignore probe emission errors. + } + } + + function safeString(value) { + return typeof value === "string" ? value : ""; + } + + function markLastAudioUrl(url, contentType) { + const maybeUrl = safeString(url); + const maybeType = safeString(contentType).toLowerCase(); + if (!maybeUrl) { + return; + } + if (maybeType.startsWith("audio/") || maybeUrl.includes("audio_samples/")) { + lastAudioResponseUrl = maybeUrl; + } + } + + function patchFetch() { + if (typeof window.fetch !== "function") { + return; + } + const currentFetch = window.fetch; + if (currentFetch.__s3CapturePatched) { + return; + } + + const wrappedFetch = function wrappedFetch(input, init) { + const requestUrl = + typeof input === "string" + ? input + : input && typeof input.url === "string" + ? input.url + : ""; + + emit("fetch-request", { requestUrl }); + + return currentFetch.call(this, input, init).then( + (response) => { + const responseUrl = response && typeof response.url === "string" ? response.url : ""; + const contentType = response && response.headers ? response.headers.get("content-type") : ""; + markLastAudioUrl(responseUrl, contentType); + emit("fetch-response", { + requestUrl, + responseUrl, + status: response ? response.status : 0, + contentType: contentType || "" + }); + return response; + }, + (error) => { + emit("fetch-error", { + requestUrl, + error: error && error.message ? error.message : "fetch failed" + }); + throw error; + } + ); + }; + + wrappedFetch.__s3CapturePatched = true; + window.fetch = wrappedFetch; + } + + function patchXhr() { + if (!window.XMLHttpRequest || !window.XMLHttpRequest.prototype) { + return; + } + + const proto = window.XMLHttpRequest.prototype; + if (proto.open.__s3CapturePatched) { + return; + } + + const originalOpen = proto.open; + const originalSend = proto.send; + + proto.open = function patchedOpen(method, url) { + this.__s3CaptureRequestUrl = safeString(url); + emit("xhr-open", { + method: safeString(method), + requestUrl: this.__s3CaptureRequestUrl + }); + return originalOpen.apply(this, arguments); + }; + proto.open.__s3CapturePatched = true; + + proto.send = function patchedSend() { + const xhr = this; + xhr.addEventListener( + "loadend", + function onLoadEnd() { + const responseUrl = safeString(xhr.responseURL); + const contentType = safeString(xhr.getResponseHeader("content-type")); + markLastAudioUrl(responseUrl || xhr.__s3CaptureRequestUrl, contentType); + emit("xhr-response", { + requestUrl: safeString(xhr.__s3CaptureRequestUrl), + responseUrl, + status: xhr.status || 0, + contentType + }); + }, + { once: true } + ); + return originalSend.apply(this, arguments); + }; + } + + function patchCreateObjectURL() { + if (!window.URL || typeof window.URL.createObjectURL !== "function") { + return; + } + const current = window.URL.createObjectURL; + if (current.__s3CapturePatched) { + return; + } + + const wrapped = function wrappedCreateObjectURL(blob) { + const objectUrl = current.call(this, blob); + const blobType = blob && typeof blob.type === "string" ? blob.type : ""; + const blobSize = blob && typeof blob.size === "number" ? blob.size : 0; + emit("blob-created", { + blobUrl: objectUrl, + blobType, + blobSize, + linkedUrl: lastAudioResponseUrl + }); + return objectUrl; + }; + + wrapped.__s3CapturePatched = true; + window.URL.createObjectURL = wrapped; + } + + function patchDecodeAudioData(ctor) { + if (!ctor || !ctor.prototype || typeof ctor.prototype.decodeAudioData !== "function") { + return; + } + const current = ctor.prototype.decodeAudioData; + if (current.__s3CapturePatched) { + return; + } + + const wrapped = function wrappedDecodeAudioData(audioData) { + const byteLength = + audioData && typeof audioData.byteLength === "number" ? audioData.byteLength : 0; + emit("decode-audio-data", { + byteLength, + linkedUrl: lastAudioResponseUrl + }); + return current.apply(this, arguments); + }; + + wrapped.__s3CapturePatched = true; + ctor.prototype.decodeAudioData = wrapped; + } + + patchFetch(); + patchXhr(); + patchCreateObjectURL(); + patchDecodeAudioData(window.AudioContext); + patchDecodeAudioData(window.OfflineAudioContext); + emit("probe-ready", { href: window.location.href }); +})(); diff --git a/popup.css b/popup.css new file mode 100644 index 0000000..fbfde40 --- /dev/null +++ b/popup.css @@ -0,0 +1,74 @@ +body { + margin: 0; + font-family: Arial, sans-serif; + background: #111319; + color: #e9ecf1; +} + +.popup { + width: 380px; + max-height: 520px; + padding: 12px; + box-sizing: border-box; +} + +.popup-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.popup-header h1 { + margin: 0; + font-size: 15px; +} + +button { + border: 1px solid #2f66ff; + background: #2f66ff; + color: white; + border-radius: 6px; + cursor: pointer; + padding: 6px 9px; + font-size: 12px; +} + +button.secondary { + border-color: #445; + background: #1e2230; +} + +.status { + margin: 12px 0; + color: #adb6cb; + font-size: 12px; +} + +.url-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.url-item { + border: 1px solid #2a2f40; + border-radius: 8px; + background: #171b26; + padding: 8px; +} + +.url-text { + margin: 0 0 8px; + font-size: 11px; + word-break: break-all; + color: #c6d0e5; +} + +.actions { + display: flex; + gap: 8px; +} diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..ad75418 --- /dev/null +++ b/popup.html @@ -0,0 +1,22 @@ + + + + + + S3 Audio Capture + + + +
+ +
+

Loading captured URLs...

+
    +
    +
    + + + diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..703034a --- /dev/null +++ b/popup.js @@ -0,0 +1,98 @@ +"use strict"; + +const statusEl = document.getElementById("status"); +const listEl = document.getElementById("urlList"); +const openHistoryBtn = document.getElementById("openHistoryBtn"); + +function setStatus(message) { + statusEl.textContent = message; +} + +function entryLabel(entry) { + return entry && entry.fullUrl ? "full candidate" : "preview fallback"; +} + +function buildRow(entry) { + const li = document.createElement("li"); + li.className = "url-item"; + + const p = document.createElement("p"); + p.className = "url-text"; + p.textContent = entry.previewUrl || entry.fullUrl || ""; + + const status = document.createElement("p"); + status.className = "status"; + status.textContent = `Source: ${entry.source || "unknown"} | Mode: ${entryLabel(entry)}`; + + const actions = document.createElement("div"); + actions.className = "actions"; + + const download = document.createElement("button"); + download.type = "button"; + download.textContent = "Download"; + download.addEventListener("click", async () => { + const result = await browser.runtime.sendMessage({ type: "DOWNLOAD_ENTRY", entry }); + if (!result || !result.ok) { + window.alert((result && result.error) || "Download failed."); + return; + } + setStatus(result.mode === "full" ? "Downloaded full file URL." : "Downloaded decoded preview fallback."); + }); + + const copy = document.createElement("button"); + copy.type = "button"; + copy.className = "secondary"; + copy.textContent = "Copy URL"; + copy.addEventListener("click", async () => { + const url = entry.fullUrl || entry.previewUrl || ""; + await navigator.clipboard.writeText(url); + }); + + actions.appendChild(download); + actions.appendChild(copy); + li.appendChild(p); + li.appendChild(status); + li.appendChild(actions); + return li; +} + +async function loadCapturedUrls() { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const activeTab = tabs[0]; + + if (!activeTab || typeof activeTab.id !== "number") { + setStatus("No active tab found."); + return; + } + + const response = await browser.runtime.sendMessage({ + type: "GET_TAB_URLS", + tabId: activeTab.id + }); + + if (!response || !response.ok) { + setStatus("Unable to load captured URLs."); + return; + } + + listEl.innerHTML = ""; + const entries = Array.isArray(response.entries) ? response.entries : []; + if (!entries.length) { + setStatus("No captured S3 URLs for this tab yet."); + return; + } + + setStatus(`Captured samples for this tab: ${entries.length}`); + for (const entry of entries) { + listEl.appendChild(buildRow(entry)); + } +} + +openHistoryBtn.addEventListener("click", async () => { + await browser.runtime.sendMessage({ type: "OPEN_HISTORY" }); + window.close(); +}); + +loadCapturedUrls().catch(() => { + setStatus("Unexpected error while loading URLs."); +}); diff --git a/test.mp3 b/test.mp3 new file mode 100644 index 0000000..138041e Binary files /dev/null and b/test.mp3 differ diff --git a/test2.mp3 b/test2.mp3 new file mode 100644 index 0000000..8d2634d Binary files /dev/null and b/test2.mp3 differ