This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
# Development build with watch mode (rebuilds on file changes)
npm run dev
# Production build (minified, no sourcemaps)
npm run build
# TypeScript type checking only (no emit)
npm run type-checkThere are no tests in this project. After building, load the dist/ directory as an unpacked extension in Chrome (chrome://extensions/ → Developer mode → Load unpacked).
All HLS, M3U8, and DASH downloads are processed by the muxer running inside the browser. Output files are limited to approximately 2 GB — files beyond this will exhaust browser memory during the merge stage.
src/core/cloud/ contains the full provider abstraction:
base-cloud-provider.ts— abstractBaseCloudProviderwithid: CloudProviderandupload(blob, filename, onProgress?, signal?): Promise<string>google-auth.ts—GoogleAuthstatic class; OAuth viachrome.identity.launchWebAuthFlow()with user-provided client ID (stored inchrome.storage.localundergoogle_client_id). Nooauth2manifest key needed.google-drive.ts—GoogleDriveClient extends BaseCloudProvider; simple multipart for files ≤ 5 MB, resumable chunked upload for larger filess3-client.ts—S3Client extends BaseCloudProvider; SigV4-signed PUT for files < 10 MB, multipart for ≥ 10 MB. Persists in-flight multipartuploadIdtochrome.storage.local(s3_pending_uploads) for crash-resilient cleanup.upload-manager.ts—Map<CloudProvider, BaseCloudProvider>registry; routesuploadBlob()throughclient.upload();isConfigured()checksproviders.size > 0
Upload is manual only. Completed downloads show an "Upload to cloud" action in both the History page (Options → History → item menu) and the popup Downloads tab. Clicking it opens a file picker (showOpenFilePicker); the user selects the local video file, which is stored in IDB (since chrome.runtime.sendMessage uses JSON serialization that destroys ArrayBuffer), then an UPLOAD_REQUEST message is sent to the service worker. If both providers are configured, a provider picker dialog appears. There is no automatic upload on download completion.
Upload progress is tracked via DownloadStage.UPLOADING and displayed with a water-fill cloud icon in history. Uploads can be cancelled via an AbortController; cancellation prompts a confirmation dialog. On cancel, AbortError (DOMException) is expected and handled silently — not logged as an error.
- S3 multipart: In-flight
(key, uploadId)pairs are persisted tochrome.storage.local. On service worker startup,S3Client.cleanupOrphanedUploads()aborts any orphaned uploads and clears the list. - Stuck uploads: Downloads in
UPLOADINGstage after a crash are restored toCOMPLETEDbycleanupStaleUploads()ininit().
Users must create their own OAuth credentials (free). The options page shows step-by-step instructions:
- Enable Google Drive API in Google Cloud Console
- Create OAuth 2.0 Client ID (type: Web application)
- Add the extension's redirect URI (
chrome.identity.getRedirectURL()) as an authorized redirect URI - Paste the client ID into the options page
- Clicking "Sign in with Google" auto-saves settings with
enabled: true
The chromiumapp.org redirect works for unpacked extensions — Chrome intercepts it internally without needing the Chrome Web Store.
Create a class extending BaseCloudProvider, add its key to the CloudProvider union in shared/messages.ts, instantiate and register it in the UploadManager constructor — no other code needs to change.
The bucket must have a CORS policy whitelisting the extension origin. The options page S3 section generates the correct JSON dynamically (using chrome.runtime.id) and provides a "Copy CORS Config" button. Paste it into S3 → bucket → Permissions → Cross-origin resource sharing (CORS) → Edit. Without it the browser will block upload requests from the chrome-extension:// origin.
The S3 secret access key can be encrypted at rest with AES-GCM via SecureStorage. The user sets a passphrase in the options page; the key is stored as an EncryptedBlob in chrome.storage.local. On upload, resolveS3Secret() in the service worker decrypts it using the passphrase from session storage.
Media Bridge is a Manifest V3 Chrome extension. It has five distinct execution contexts that communicate via chrome.runtime.sendMessage:
-
Service Worker (
src/service-worker.ts→dist/background.js): The central orchestrator. Handles all download lifecycle management, routes messages from popup/content scripts, maintains download state, and keeps itself alive during long operations usingchrome.runtime.getPlatformInfo()heartbeats. Intercepts.m3u8and.mpdnetwork requests viachrome.webRequest.onCompleted. -
Content Script (
src/content.ts→dist/content.js): Built separately as IIFE (content scripts cannot use ES modules). Runs on all pages, usesDetectionManagerto find videos via DOM observation and network request interception, and injects download buttons. Proxies fetch requests through the service worker to bypass CORS. -
Offscreen Document (
src/offscreen/→dist/offscreen/): A hidden page created on demand for FFmpeg.wasm processing. Reads raw segment chunks from IndexedDB, concatenates them, runs FFmpeg to mux into MP4, and returns a blob URL. Communicates with the service worker via messages since it can't use the Chrome downloads API directly. FFmpeg.wasm is single-threaded — all processing calls are serialized through a promise-basedenqueue()queue to prevent concurrentffmpeg.exec()from corrupting shared WASM state. Intermediate filenames are prefixed withdownloadId(e.g.,${downloadId}_video.ts) to avoid collisions in FFmpeg's virtual filesystem. -
Popup (
src/popup/→dist/popup/): Extension action UI — Videos tab (detected videos), Downloads tab (progress), Manifest tab (manual URL input with quality selector). -
Options Page (
src/options/→dist/options/): Full settings UI with sidebar navigation. Sections: Download (FFmpeg timeout, max concurrent), History (completed/failed/cancelled download log with infinite scroll), Google Drive, S3, Recording (HLS poll interval tuning), Notifications, and Advanced (retries, backoff, cache sizes, fragment failure rate, IDB sync interval). All settings changes notify via bottom toast. History button in the popup header opens the options page directly on the#historyanchor.
For HLS/M3U8 downloads:
- Service worker creates a
DownloadManager, which delegates toHlsDownloadHandlerorM3u8DownloadHandler - Handler parses the playlist, downloads segments concurrently (up to
maxConcurrent, default 3), stores raw chunks in IndexedDB (core/database/chunks.ts) - Handler sends
OFFSCREEN_PROCESS_HLSorOFFSCREEN_PROCESS_M3U8message to offscreen document - Offscreen document enqueues the job — if another FFmpeg job is running, it waits. Once dequeued, it concatenates chunks from IndexedDB, runs FFmpeg, and returns a blob URL
- Service worker triggers Chrome download from the blob URL and saves the MP4
For DASH downloads: same flow but via DashDownloadHandler and OFFSCREEN_PROCESS_DASH. No -bsf:a aac_adtstoasc bitstream filter (DASH segments are already ISOBMF). Intermediate files use .mp4 extension instead of .ts.
For direct downloads: the service worker uses chrome.downloads.download() directly — no FFmpeg.
For live recording (HLS or DASH): the recording handler polls the media playlist/MPD at the stream's native interval (derived from #EXT-X-TARGETDURATION for HLS, minimumUpdatePeriod for DASH), collecting new segments as they appear. Aborting triggers the merge phase rather than a discard.
Download state is persisted in IndexedDB (not chrome.storage), in the media-bridge database (version 3) with two object stores:
downloads: FullDownloadStateobjects keyed byidchunks: RawUint8Arraysegments keyed by[downloadId, index]
Configuration lives in chrome.storage.local under the storage_config key (StorageConfig type). Always access config through loadSettings() (core/storage/settings.ts) which returns a fully-typed AppSettings object with all defaults applied — never read StorageConfig directly. AppSettings covers: ffmpegTimeout, maxConcurrent, historyEnabled, googleDrive, s3, recording, notifications, and advanced.
IndexedDB is used as the shared state store because the five execution contexts don't share memory. The service worker writes state via storeDownload() (core/database/downloads.ts), which is a single IDB put upsert keyed by id. The popup reads the full list via getAllDownloads() on open. The offscreen document reads raw chunks from the chunks store during FFmpeg processing. chrome.storage is only used for config because it has a 10 MB quota and can't store ArrayBuffer.
Progress updates use two complementary channels:
- IndexedDB — durable source of truth; survives popup close/reopen and service worker restarts. Popup reads this on mount.
chrome.runtime.sendMessage(DOWNLOAD_PROGRESS) — low-latency live updates broadcast by the service worker while the popup is open. Fire-and-forget; missed if popup is closed.
updateProgress() (core/downloader/base-playlist-handler.ts) is the hot-path progress method called after every segment download. It uses two optimizations to avoid overwhelming the service worker event loop:
-
cachedState— a class field holding theDownloadStateobject read from IDB on the first call. Every subsequent call mutates this same in-memory object directly (updatingdownloaded,total,percentage,speed, etc.) — zero DB reads. The cache is invalidated (cachedState = null) only onresetDownloadState()(new download) andupdateStage()(stage transition), which forces a fresh IDB read to pick up any external changes. -
DB_SYNC_INTERVAL_MS = 500msthrottle —storeDownload()is only called if at least 500ms have elapsed since the last write. The popup still receives every update vianotifyProgress()(which fires unconditionally), but IDB writes are capped at ~2/second regardless of segment download frequency.
updateStage() bypasses both optimizations — it always does a full IDB read + write because stage transitions are rare and need to reflect the true persisted state.
HlsRecordingHandler.updateRecordingProgress() and DashRecordingHandler.updateRecordingProgress() also always do a full IDB read + write, but are naturally rate-limited to once per poll cycle (every 1–10 seconds).
Do not add getDownload() calls inside updateProgress() or the onProgress callback — that was the root cause of the UI freezing bug fixed in commit 9f2a21e. With 3 concurrent downloads each firing per segment, even one extra IDB read per callback produces dozens of blocking reads per second that queue up behind user interaction messages in the service worker event loop.
All inter-component communication uses the MessageType enum in src/shared/messages.ts. When adding new message types, add them to this enum and handle them in the service worker's onMessage listener switch statement. CHECK_URL is used by the options page manifest-check feature to probe a URL's content-type via the service worker (bypassing CORS).
The Vite config (vite.config.ts) has two important quirks:
- Content script is built in a separate Vite sub-build (IIFE format,
inlineDynamicImports: true) triggered by thebuild-content-script-as-iifeplugin. This avoids ES module restrictions for content scripts. - HTML files are post-processed by the
move-html-filesplugin to fix script src paths from absolute to relative after Vite moves them.
FFmpeg WASM files are served from public/ffmpeg/ and copied to dist/ffmpeg/ at build time. They are explicitly excluded from Vite's dependency optimization.
@ resolves to src/. Use @/core/types instead of relative paths when importing from deep nesting.
VideoFormat is a string enum (VideoFormat.DIRECT | HLS | M3U8 | DASH | UNKNOWN). The distinctions matter:
HLS— master playlist with#EXT-X-STREAM-INFquality variants →HlsDownloadHandlerM3U8— direct media playlist with segments →M3u8DownloadHandlerDASH— MPEG-DASH.mpdmanifest →DashDownloadHandler
Use enum values everywhere; the underlying strings are lowercase for IndexedDB backward compatibility.
Both HLS and DASH support live recording via HlsRecordingHandler and DashRecordingHandler, which extend the shared BaseRecordingHandler. The recording handler polls the media playlist/MPD at a fixed interval, downloads new segments as they appear, and merges them into an MP4 when the user stops recording or the stream ends naturally (#EXT-X-ENDLIST for HLS; type="dynamic" → type="static" transition for DASH). Controlled via AbortSignal — aborting triggers the merge phase, not a discard. The popup UI shows a REC button (only for live streams) and a dedicated RECORDING stage with segment count.
Origin and Referer are forbidden headers — browsers silently strip them from fetch() calls, even in service worker context. CDNs that require these headers will 404 without them.
The fix uses chrome.declarativeNetRequest dynamic rules (src/core/downloader/header-rules.ts) to inject these headers at the network layer. Rules are scoped to the specific CDN path prefix and initiatorDomains: [chrome.runtime.id] so they only affect extension requests. Each download handler calls addHeaderRules() before downloading and removeHeaderRules() in its finally block. Do not attempt to set Origin/Referer via fetch() headers — it won't work.
HLS, M3U8, and DASH handlers support saving partial downloads when cancelled. If shouldSaveOnCancel() returns true, the handler transitions to the MERGING stage with whatever chunks were collected, runs FFmpeg, and saves a partial MP4. The abort signal is cleared before FFmpeg processing to prevent immediate rejection.
src/shared/constants.ts— only constants used across multiple modules (runtime defaults, pipeline values, storage keys)src/options/constants.ts— constants used exclusively within the options UI (toast duration, UI bounds for all settings inputs in seconds, validation clamp values)
Time representation: All runtime/storage values use milliseconds (StorageConfig, AppSettings, all handlers). The options UI uses seconds exclusively. Conversion happens only in options.ts: divide by 1000 on load, multiply by 1000 on save.
All numeric inputs are validated before saving via three helpers in options.ts:
validateField(input, min, max, isInteger?)— parses the value, returns the number on success ornullon failure. CallsmarkInvalidautomatically.markInvalid(input, message)— adds.invalidclass (red border) and inserts a.form-errordiv after the input. Registers a one-timeinputlistener to auto-clear when the user edits.clearInvalid(input)— removes.invalidand the.form-errordiv.
Each save handler validates all fields upfront and returns early if any are invalid — the button is never disabled and no write is attempted. Cross-field constraints (e.g. pollMin < pollMax) call markInvalid on the relevant field directly rather than relying on the toast. The toast is reserved for storage/network errors.
Completed, failed, and cancelled downloads are persisted in IndexedDB when historyEnabled (default true) is set. The options page History section renders all finished downloads with infinite scroll (IntersectionObserver). From history, users can re-download (reuses stored metadata for filename), copy the original URL, or delete entries. bulkDeleteDownloads() (core/database/downloads.ts) handles batch removal. The popup "History" button navigates to options.html#history.
After a download completes, handlePostDownloadActions() in the service worker reads AppSettings.notifications and optionally fires an OS notification (notifyOnCompletion) or opens the file in Finder/Explorer (autoOpenFile).
- No
-bsf:a aac_adtstoascbitstream filter — DASH segments are already in ISOBMF container format - Intermediate files use
.mp4extension (not.ts) - Live detection:
type="dynamic"attribute in the MPD root element - Poll interval:
minimumUpdatePeriodattribute in the MPD - DRM detection: presence of
<ContentProtection>elements in anyAdaptationSet - mpd-parser v1.3.1 is used; type declarations are in
src/types/mpd-parser.d.ts(no@typespackage available)
src/
├── service-worker.ts # Central orchestrator
├── content.ts # Content script (IIFE)
├── shared/
│ ├── messages.ts # MessageType enum
│ └── constants.ts # DEFAULT_MAX_CONCURRENT, DEFAULT_FFMPEG_TIMEOUT_MS, etc.
├── core/
│ ├── types/
│ │ └── index.ts # VideoFormat, DownloadState, DownloadStage, Fragment, Level
│ ├── detection/
│ │ ├── detection-manager.ts
│ │ ├── thumbnail-utils.ts
│ │ ├── direct/direct-detection-handler.ts
│ │ ├── hls/hls-detection-handler.ts
│ │ └── dash/dash-detection-handler.ts
│ ├── downloader/
│ │ ├── download-manager.ts
│ │ ├── base-playlist-handler.ts # Hot-path progress, cachedState, 500ms throttle
│ │ ├── base-recording-handler.ts # Shared polling loop for live streams
│ │ ├── concurrent-workers.ts
│ │ ├── crypto-utils.ts # AES-128 decryption
│ │ ├── header-rules.ts # DNR Origin/Referer injection
│ │ ├── types.ts
│ │ ├── direct/direct-download-handler.ts
│ │ ├── hls/hls-download-handler.ts
│ │ ├── hls/hls-recording-handler.ts
│ │ ├── m3u8/m3u8-download-handler.ts
│ │ ├── dash/dash-download-handler.ts
│ │ └── dash/dash-recording-handler.ts
│ ├── parsers/
│ │ ├── m3u8-parser.ts # HLS parsing (wraps m3u8-parser)
│ │ ├── mpd-parser.ts # DASH parsing (wraps mpd-parser)
│ │ └── playlist-utils.ts # ParsedPlaylist, ParsedSegment, parseLevelsPlaylist()
│ ├── ffmpeg/
│ │ ├── ffmpeg-bridge.ts
│ │ ├── ffmpeg-singleton.ts
│ │ └── offscreen-manager.ts
│ ├── database/
│ │ ├── connection.ts # IDB init (media-bridge v3)
│ │ ├── downloads.ts # storeDownload(), getDownload(), etc.
│ │ └── chunks.ts # storeChunk(), deleteChunks(), getChunkCount()
│ ├── storage/
│ │ ├── chrome-storage.ts
│ │ ├── secure-storage.ts # AES-GCM encrypt/decrypt for S3 secret key
│ │ └── settings.ts # AppSettings interface + loadSettings() — always use this
│ ├── cloud/
│ │ ├── base-cloud-provider.ts # Abstract base + ProgressCallback type
│ │ ├── google-auth.ts # OAuth via launchWebAuthFlow (user-provided client ID)
│ │ ├── google-drive.ts # GoogleDriveClient extends BaseCloudProvider
│ │ ├── s3-client.ts # S3Client extends BaseCloudProvider + orphaned upload cleanup
│ │ └── upload-manager.ts # Provider registry (Map<CloudProvider, BaseCloudProvider>)
│ ├── metadata/
│ │ └── metadata-extractor.ts
│ └── utils/
│ ├── blob-utils.ts
│ ├── cancellation.ts
│ ├── download-utils.ts
│ ├── drm-utils.ts
│ ├── errors.ts # MediaBridgeError hierarchy
│ ├── fetch-utils.ts
│ ├── file-utils.ts
│ ├── format-utils.ts
│ ├── id-utils.ts
│ ├── logger.ts
│ └── url-utils.ts
├── popup/
│ ├── popup.ts / popup.html
│ ├── state.ts
│ ├── tabs.ts
│ ├── render-downloads.ts
│ ├── render-videos.ts
│ ├── render-manifest.ts
│ ├── download-actions.ts
│ └── utils.ts
├── options/
│ ├── options.ts / options.html
│ └── constants.ts # Options-page-only constants (UI bounds, toast duration)
├── offscreen/
│ ├── offscreen.ts / offscreen.html
└── types/
└── mpd-parser.d.ts