Muhammad Atif
Command Palette

Search for a command to run...

Blog

How to Build a Video Downloader Chrome Extension

How to Build a Video Downloader Chrome Extension

A technical walkthrough of building a video downloader browser extension - detecting media, handling HLS and blob URLs, joining segments, saving files reliably, and staying inside Chrome Web Store policy.

A video downloader sounds like a small project until you build one. The download itself is trivial. Everything around it - finding the media, dealing with streams that are not files, saving without asking for scary permissions, and staying inside store policy - is where the work actually is.

I have shipped several media tools, including Kick Video Downloader and VidPulse for Facebook video on the Chrome Web Store, plus GripVid on Google Play. This is what I have learned about doing it properly.

Start with policy, not code

It is worth being blunt about this first, because it determines whether your work ever reaches users.

The Chrome Web Store does not ban video downloaders. It bans downloading from services whose terms forbid it - YouTube being the obvious example that gets extensions removed. Google enforces this consistently, and "but other extensions do it" is not a defence that survives review.

Practical rules I work to:

  • Target platforms whose terms do not prohibit saving your own or publicly downloadable media.
  • Do not advertise support for a platform you know forbids it, even if the code technically works.
  • Be precise in your store listing. Vague claims invite a closer look.
  • Never bundle a downloader with unrelated permissions. It reads as a bait-and-switch.

Getting this wrong means removal, and removals are hard to appeal. Decide your target platforms deliberately.

How video actually arrives in a browser

There are three broadly different cases, and they need different handling.

Progressive files

The simplest case. The page references an .mp4 and the browser streams it over HTTP with range requests. You can find the URL, and downloading it is a normal fetch.

const video = document.querySelector("video")
console.log(video.currentSrc) // https://cdn.example.com/clip.mp4

If currentSrc gives you an https:// URL ending in a media extension, you have the easy case.

Blob URLs

Far more common on modern sites. video.currentSrc looks like:

blob:https://example.com/8f2c4e19-...

A blob URL is a reference to data the page already holds in memory, created via Media Source Extensions. You cannot fetch it from another context - it is scoped to that page. Content scripts share the page's DOM but the blob is still bound to the page's origin context, and the service worker cannot touch it at all.

The blob is a symptom: the site is feeding segments into a MediaSource. The real source is a manifest and a series of segment files.

HLS and DASH streams

This is what most streaming platforms serve. Instead of one file there is a playlist - .m3u8 for HLS, .mpd for DASH - listing segments:

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXTINF:6.000,
segment-000.ts
#EXTINF:6.000,
segment-001.ts
#EXTINF:6.000,
segment-002.ts
#EXT-X-ENDLIST

To produce a playable file you fetch every segment and join them. A two-hour stream can be well over a thousand segments.

Master playlists add another layer: they list variant playlists at different bitrates, so you first choose a quality, then fetch that variant's segment list.

Detecting media without guessing

Reading video.currentSrc only helps in the easy case. The reliable approach is to watch network traffic for manifests and media segments.

Manifest V3 removed blocking webRequest, but the observational API still exists, which is all you need:

chrome.webRequest.onBeforeSendHeaders.addListener(
  (details) => {
    if (/\.m3u8(\?|$)/.test(details.url)) {
      recordManifest(details.tabId, details.url, details.requestHeaders)
    }
  },
  { urls: ["<all_urls>"] },
  ["requestHeaders"]
)

Two things matter here.

Capture the headers, not just the URL. Many CDNs reject requests without the right Referer, Origin or a signed cookie. If you fetch the manifest with a bare request it returns 403. Store the headers from the original request and replay them.

Scope by tab. Keep a per-tab record so the popup or side panel shows media for the page the user is actually looking at, and clear it on navigation:

chrome.tabs.onUpdated.addListener((tabId, info) => {
  if (info.status === "loading") clearMedia(tabId)
})

Otherwise the list fills with stale entries from tabs the user closed an hour ago.

Parsing a playlist

A minimal HLS parser is short. You need enough to resolve segment URLs and detect encryption:

function parsePlaylist(text, baseUrl) {
  const lines = text.split("\n").map((l) => l.trim())
  const segments = []
  let encrypted = false
 
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i]
 
    if (line.startsWith("#EXT-X-KEY") && !line.includes("METHOD=NONE")) {
      encrypted = true
    }
 
    if (line && !line.startsWith("#")) {
      segments.push(new URL(line, baseUrl).href)
    }
  }
 
  return { segments, encrypted }
}

Relative URLs are the norm, so resolving against the manifest URL with new URL(line, baseUrl) is not optional.

If #EXT-X-KEY indicates AES-128 with a fetchable key, the segments are merely encrypted in transit and can be decrypted with Web Crypto. If you see Widevine or PlayReady signalling, stop - that is DRM, and circumventing it is both illegal in many jurisdictions and an instant store removal. Detect it and tell the user the video is protected.

Downloading segments without melting the tab

Fetch segments with bounded concurrency. Firing a thousand parallel requests will get you rate-limited and can lock up the page:

async function fetchSegments(urls, headers, onProgress, concurrency = 6) {
  const buffers = new Array(urls.length)
  let index = 0
  let done = 0
 
  async function worker() {
    while (index < urls.length) {
      const i = index++
      const res = await fetch(urls[i], { headers })
      if (!res.ok) throw new Error(`Segment ${i} failed: ${res.status}`)
      buffers[i] = new Uint8Array(await res.arrayBuffer())
      onProgress(++done, urls.length)
    }
  }
 
  await Promise.all(
    Array.from({ length: Math.min(concurrency, urls.length) }, worker)
  )
 
  return buffers
}

Around six concurrent requests is a good default. Writing results into a pre-sized array by index keeps ordering correct regardless of completion order - a subtle bug if you push to an array instead.

Retries matter. On a long stream, one segment will fail. Retry two or three times with a short backoff before giving up on the whole download.

Joining segments

For MPEG-TS segments, concatenating the bytes produces a valid .ts file that most players open:

function concat(buffers) {
  const total = buffers.reduce((n, b) => n + b.length, 0)
  const out = new Uint8Array(total)
  let offset = 0
  for (const b of buffers) {
    out.set(b, offset)
    offset += b.length
  }
  return new Blob([out], { type: "video/mp2t" })
}

For fragmented MP4, the first segment is an initialisation segment containing the moov box; it must come first and must not be dropped.

Producing a clean .mp4 container is harder and needs real remuxing. You have two options:

  1. Ship ffmpeg.wasm. Fully client-side, no server. The cost is bundle size - tens of megabytes - and slow start-up.
  2. Remux on a server. Smaller extension, better performance, but now you are handling user media and you need to be explicit about it in your privacy policy.

For GripVid I do the segment joining in a background isolate on-device, so the media never leaves the phone. On the extension side, keeping processing in the browser is worth real effort: it makes the privacy story simple and true, and it keeps your API free of user content.

Doing the work off the main thread

Joining a large stream in the page will freeze it. Move it into a worker:

// worker.js
self.onmessage = async ({ data }) => {
  const { urls, headers } = data
  const buffers = await fetchSegments(urls, headers, (done, total) =>
    self.postMessage({ type: "progress", done, total })
  )
  self.postMessage({ type: "done", blob: concat(buffers) }, [])
}

Progress reporting is not decorative. A download with no feedback for four minutes reads as broken, and users uninstall.

Saving the file

Use the downloads API rather than a synthetic anchor click. It gives you a real progress entry, resumability and a filename the user can change:

const url = URL.createObjectURL(blob)
 
chrome.downloads.download(
  { url, filename: sanitise(title) + ".mp4", saveAs: false },
  (downloadId) => {
    chrome.downloads.onChanged.addListener(function done(delta) {
      if (delta.id === downloadId && delta.state?.current === "complete") {
        URL.revokeObjectURL(url)
        chrome.downloads.onChanged.removeListener(done)
      }
    })
  }
)

Revoking the object URL matters. Forget it and a few large downloads will hold hundreds of megabytes for the rest of the session.

Sanitise filenames properly. Video titles contain slashes, colons and emoji, and Chrome will reject or mangle them:

const sanitise = (name) =>
  name.replace(/[<>:"/\\|?*\x00-\x1F]/g, "").trim().slice(0, 120) || "video"

Permissions: ask for less

Downloaders attract scrutiny because they legitimately need broad access. Reduce it anyway:

  • Prefer activeTab over blanket host permissions where the flow allows it.
  • Use optional_host_permissions and request at runtime when the user first downloads from a site.
  • Justify every permission in the listing. Reviewers read these.

On Android, GripVid writes through MediaStore, which means it needs no storage permission at all - one runtime permission in the entire app, no ads and no tracking. The same instinct applies in a browser: every permission you avoid is one fewer reason to distrust you, and one fewer question at review.

Letting the user choose quality

A master playlist lists several variants. Presenting them well is most of the perceived quality of the extension.

function parseMaster(text, baseUrl) {
  const lines = text.split("\n")
  const variants = []
 
  for (let i = 0; i < lines.length; i++) {
    if (!lines[i].startsWith("#EXT-X-STREAM-INF")) continue
 
    const resolution = /RESOLUTION=(\d+x\d+)/.exec(lines[i])?.[1]
    const bandwidth = Number(/BANDWIDTH=(\d+)/.exec(lines[i])?.[1] ?? 0)
    const url = new URL(lines[i + 1].trim(), baseUrl).href
 
    variants.push({ resolution, bandwidth, url })
  }
 
  return variants.sort((a, b) => b.bandwidth - a.bandwidth)
}

Two details make this feel finished. Label variants by height - "1080p", not "1920x1080" - because that is how people think about video. And estimate the file size from the bandwidth and the playlist duration:

const estimatedBytes = (bandwidth / 8) * durationSeconds

It is approximate, but a user deciding between a 90 MB and a 1.4 GB download wants that number before committing, not after.

Default to the highest variant. Users who want smaller will change it; users who want best quality should not have to.

Testing a downloader

Downloaders are awkward to test because the inputs belong to someone else and change without notice. What works:

  • Keep sample manifests as fixtures. Save real .m3u8 files and unit-test the parser against them. Parsing bugs are the easiest class to catch and the most annoying to debug live.
  • Test the failure paths deliberately. Force a segment to 403, kill the network mid-download, point at a live stream, feed it a DRM-protected manifest. These are the paths users hit; they are rarely the ones developers exercise.
  • Watch memory. Run a long download with the task manager open. If memory climbs and never falls, you are holding buffers you should have released.
  • Re-check target sites on a schedule. A site redesign breaks detection silently. Finding out from a one-star review is the expensive way.

Error messages deserve real attention here. "Download failed" tells a user nothing. "This video is protected and cannot be downloaded" and "The link expired - reload the page and try again" are actionable, and they cut support volume sharply.

Things that will bite you

A short list of what has actually cost me time:

  • Expiring URLs. Signed CDN links time out. Do not capture a URL and use it ten minutes later without re-fetching the manifest.
  • Live streams. A live playlist has no #EXT-X-ENDLIST and keeps growing. Detect it and behave differently rather than looping forever.
  • Audio and video as separate tracks. DASH usually splits them. Downloading only the video track produces a silent file and a confused user.
  • Memory. A 4K stream held entirely in memory can exhaust the tab. Stream to disk for large downloads.
  • Site redesigns. Keep every selector in one module so a breakage is a one-file fix - the same discipline I described in the Manifest V3 guide.

Wrapping up

A production downloader is mostly defensive engineering. The happy path is twenty lines; the other ninety percent is expired URLs, failed segments, DRM detection, memory limits and filename edge cases.

Two principles carry most of the weight. Keep processing on the user's machine - it makes the privacy story honest and keeps you out of the business of storing other people's media. And be deliberate about which platforms you support, because that decision, not your code, determines whether the extension stays published.

If you want to charge for a tool like this, monetising a Chrome extension with Lemon Squeezy and Cloudflare Workers covers licensing without collecting more data than you need. For getting through review, see publishing on the Chrome Web Store.

More of my work is at orbitexaio.com and on GitHub.

Command Palette

Search for a command to run...