README.md

August 25, 2026 · View on GitHub

Blob Downloader

Finds every blob: URL a page makes — and every MediaSource stream, which is the one you actually wanted — and saves it.

No account, no API key, nothing to sign into.

Why

Right-clicking a blob: URL and choosing Save fails, and the reason is that "blob URL" means two unrelated things.

Sometimes there is a real Blob behind it — a canvas export, a generated file, a decrypted attachment. That one can be read, but only from inside the page that made it, and only until the page calls revokeObjectURL, which well-behaved pages do on the very next line.

Sometimes there is a MediaSource behind it. That is nearly every streaming video on the web. There is no Blob, fetch() on the URL fails, and the data does not exist yet — it arrives later, in pieces, as the player appends it. No amount of copying the URL will ever get you the file.

This handles both.

How it works

Getting into the right world. A page's Blob objects live in the page's own JavaScript world, and a blob: URL is scoped to the origin that minted it, so an ordinary content script sees the URL string and nothing behind it. The capture runs as a world: "MAIN" content script at document_start — before the page's own scripts can take a reference to URL.createObjectURL and slip past a later patch. The price is that chrome.* does not exist there at all, so a second, ordinary content script acts as a bridge.

Real Blobs. URL.createObjectURL is wrapped, and the Blob is kept, not just its URL. Keeping it is the whole trick: a page that revokes the URL immediately has destroyed the only public handle to its own data, and our reference is what is left. Those rows say URL revoked; saving from our own reference.

Streams. MediaSource.prototype.addSourceBuffer and SourceBuffer.prototype.appendBuffer are wrapped, and every appended segment is copied — copied, because players reuse one scratch buffer for every fetch, so keeping the caller's view leaves you holding whichever segment was last. Saving concatenates them in append order.

Saving. The first design clicked an <a download> in the page, where the bytes already are, and needed no permissions at all. It works exactly once: a download a page starts without a user gesture trips Chrome's automatic-downloads block, and every file after the first is dropped silently. Measured — five saves, one file on disk, no error anywhere.

So the page hands over a freshly minted URL instead, and chrome.downloads fetches it. It is exempt from that block, and it resolves a URL belonging to the page perfectly well. The bytes still never move: nothing is base64'd through the service worker, and there is no offscreen document.

FileRole
src/entrypoints/hook.content.tsMAIN world: installs the patches, answers for the page
src/entrypoints/bridge.content.tsThe pipe between the page's world and the extension
src/entrypoints/background.tsAggregates frames, drives chrome.downloads, paints the badge
src/entrypoints/popup/The list, and the Save buttons
src/lib/blob-registry.tsThe patches and everything they record
src/lib/segment-store.tsMediaSource segments, and the cap on them
src/lib/format.tsNaming a file for bytes that arrived without a name

Setup

bun install
bun run build

Load .output/chrome-mv3 unpacked. There is nothing to configure. The toolbar badge counts what the current page is holding; the popup lists it.

For development, bun run dev launches a browser with the extension loaded.

test/blob-test.html exercises every case — a kept blob, a revoked one, a named File, a PNG in an <img>, and a hand-driven MediaSource. Serve it over HTTP rather than opening the file directly, since content scripts do not run on file:// without the file-access opt-in:

python3 -m http.server 8765 --directory test

Reading what the popup tells you

Rows carry a one-line qualification, and the ones in orange are the ones that affect whether the file will be usable:

  • capture began mid-stream — the file may not play. The extension was installed or reloaded while the video was already running, so the initialisation segment — the header that makes every later fragment interpretable — went past before we were watching. Reload the page and play it again.
  • track N of M — separate files, mux them with ffmpeg. Streaming video usually arrives as one SourceBuffer for video and another for audio. They save as two files: ffmpeg -i video.mp4 -i audio.mp4 -c copy out.mp4.
  • hit the size cap; the file ends early. 512 MB per track, discarded from the end so that what you get still starts with its header and plays up to the cut.
  • nothing captured yet — press play. A MediaSource exists but the player has not asked for any data. There is nothing to save until it does.

Limits

  • Segments are stored in append order, not timeline order. Watch a video through from the start and those are the same thing. Seek backwards and forwards and they are not, and the file will stutter or refuse to play entirely. For a clean capture: fresh page, press play, leave it alone.
  • A stream is held in the page's memory while it records, up to 512 MB per track, and retained Blobs up to 1 GB per frame. Past those the row says so rather than failing at the click.
  • video.srcObject = mediaSource streams are captured but harder to label — they never mint a URL, so the <video> is matched by object identity instead.
  • SourceBuffer.changeType is not tracked. A stream that switches container mid-playback would produce a file with two different headers spliced together. Codec switches within one container — the common case — are fine.
  • Blobs are page-lifetime. Reloading the page loses everything it was holding, which is also why the inventory is kept in session storage and not on disk.