diff --git a/CLAUDE.md b/CLAUDE.md index b6ad194..8ed8245 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ Media Bridge is a Manifest V3 Chrome extension. It has five distinct execution c 4. **Popup** (`src/popup/` → `dist/popup/`): Extension action UI — Videos tab (detected videos), Downloads tab (progress), Manifest tab (manual URL input with quality selector). -5. **Options Page** (`src/options/` → `dist/options/`): FFmpeg timeout and max concurrent downloads configuration. +5. **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 `#history` anchor. ### Download Flow @@ -62,7 +62,7 @@ Download state is persisted in **IndexedDB** (not `chrome.storage`), in the `med - `downloads`: Full `DownloadState` objects keyed by `id` - `chunks`: Raw `Uint8Array` segments keyed by `[downloadId, index]` -Configuration (FFmpeg timeout, max concurrent) lives in `chrome.storage.local` via `ChromeStorage` (`core/storage/chrome-storage.ts`). +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`. @@ -86,7 +86,7 @@ Progress updates use two complementary channels: ### Message Protocol -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. +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). ### Build System @@ -123,6 +123,31 @@ The fix uses `chrome.declarativeNetRequest` dynamic rules (`src/core/downloader/ 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. +### Constants Ownership + +- `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. + +### Options Page Field Validation + +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 or `null` on failure. Calls `markInvalid` automatically. +- `markInvalid(input, message)` — adds `.invalid` class (red border) and inserts a `.form-error` div after the input. Registers a one-time `input` listener to auto-clear when the user edits. +- `clearInvalid(input)` — removes `.invalid` and the `.form-error` div. + +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. + +### History + +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`. + +### Post-Download Actions + +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`). + ### DASH-Specific Notes - No `-bsf:a aac_adtstoasc` bitstream filter — DASH segments are already in ISOBMF container format @@ -177,7 +202,8 @@ src/ │ │ ├── downloads.ts # storeDownload(), getDownload(), etc. │ │ └── chunks.ts # storeChunk(), deleteChunks(), getChunkCount() │ ├── storage/ -│ │ └── chrome-storage.ts +│ │ ├── chrome-storage.ts +│ │ └── settings.ts # AppSettings interface + loadSettings() — always use this │ ├── cloud/ # ⚠️ Planned — not wired up yet │ │ ├── google-auth.ts │ │ ├── google-drive.ts @@ -207,6 +233,7 @@ src/ │ └── utils.ts ├── options/ │ ├── options.ts / options.html +│ └── constants.ts # Options-page-only constants (UI bounds, toast duration) ├── offscreen/ │ ├── offscreen.ts / offscreen.html └── types/ diff --git a/README.md b/README.md index 0ddc8ad..7fd19dd 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ A Manifest V3 Chromium extension that detects and downloads videos from the web - **Partial Save on Cancel**: Save whatever segments were collected before cancellation - **AES-128 Decryption**: Decrypts encrypted HLS segments transparently - **Header Injection**: Injects `Origin`/`Referer` headers via `declarativeNetRequest` for CDNs that require them +- **Download History**: Completed, failed, and cancelled downloads are persisted and browsable in the options page History section with infinite scroll +- **Notifications**: Optional OS notification and auto-open file on download completion +- **Configurable Settings**: Recording poll intervals, fetch retry behaviour, detection cache sizes, IDB sync rate — all tunable from the options page ## ⚠️ Output File Size Limit @@ -103,8 +106,8 @@ Media Bridge has five distinct execution contexts that communicate via `chrome.r 1. **Service Worker** (`src/service-worker.ts`): Central orchestrator. Routes messages, manages download lifecycle, keeps itself alive via heartbeat. 2. **Content Script** (`src/content.ts`): Runs on all pages. Detects videos via DOM observation and network interception. Proxies fetch requests through the service worker to bypass CORS. 3. **Offscreen Document** (`src/offscreen/`): Hidden page that runs FFmpeg.wasm. Reads segment data from IndexedDB, muxes into MP4, returns a blob URL. -4. **Popup** (`src/popup/`): Extension action UI — Videos tab, Downloads tab, Manifest tab. -5. **Options Page** (`src/options/`): Configuration (FFmpeg timeout, max concurrent). +4. **Popup** (`src/popup/`): Extension action UI — Videos tab (detected videos), Downloads tab (in-progress only), Manifest tab (manual URL + quality selector). A History button opens the options page directly on the history section. +5. **Options Page** (`src/options/`): Full settings UI with sidebar navigation — Download, History, Google Drive, S3, Recording, Notifications, and Advanced sections. All settings changes are confirmed via a bottom toast notification. ### Download Flow @@ -123,8 +126,8 @@ Media Bridge has five distinct execution contexts that communicate via `chrome.r | Store | Data | Reason | |-------|------|--------| -| **IndexedDB** (`media-bridge` v3) | `downloads` (state), `chunks` (segments) | Survives restarts; supports large `ArrayBuffer` | -| **`chrome.storage.local`** | Config (FFmpeg timeout, concurrency) | Simple K/V; 10 MB quota | +| **IndexedDB** (`media-bridge` v3) | `downloads` (state + history), `chunks` (segments) | Survives restarts; supports large `ArrayBuffer` | +| **`chrome.storage.local`** | All config via `loadSettings()` / `AppSettings` | Simple K/V; 10 MB quota | ### Project Structure @@ -168,7 +171,8 @@ src/ │ │ ├── downloads.ts # Download state CRUD │ │ └── chunks.ts # Segment chunk storage │ ├── storage/ -│ │ └── chrome-storage.ts # Config via chrome.storage.local +│ │ ├── chrome-storage.ts # Raw chrome.storage.local access +│ │ └── settings.ts # AppSettings interface + loadSettings() — always use this │ ├── cloud/ # ⚠️ Planned — infrastructure exists, not yet wired up │ │ ├── google-auth.ts │ │ ├── google-drive.ts @@ -189,7 +193,7 @@ src/ │ ├── logger.ts │ └── url-utils.ts ├── popup/ # Popup UI (Videos / Downloads / Manifest tabs) -├── options/ # Options page (FFmpeg timeout, concurrency) +├── options/ # Options page (Download, History, Drive, S3, Recording, Notifications, Advanced) ├── offscreen/ # Offscreen document (FFmpeg.wasm processing) └── types/ └── mpd-parser.d.ts # Type declarations for mpd-parser @@ -251,7 +255,7 @@ npm run type-check ### FFmpeg Merge Fails - File may exceed the ~2 GB limit — try a shorter clip or lower quality -- Increase FFmpeg timeout in Options if processing is slow +- Increase FFmpeg timeout in **Options → Download Settings** if processing is slow - Check the offscreen document console for FFmpeg error output ### Extension Not Detecting Videos diff --git a/manifest.json b/manifest.json index 358c942..dc3b184 100644 --- a/manifest.json +++ b/manifest.json @@ -10,6 +10,7 @@ "unlimitedStorage", "storage", "downloads", + "notifications", "tabs", "activeTab", "offscreen", diff --git a/public/shared.css b/public/shared.css new file mode 100644 index 0000000..8a4756b --- /dev/null +++ b/public/shared.css @@ -0,0 +1,189 @@ +/* ---- Fonts ---- */ +@font-face { + font-family: 'Inter'; + font-weight: 400; + font-style: normal; + font-display: swap; + src: url('/fonts/inter-regular.woff2') format('woff2'); +} +@font-face { + font-family: 'Inter'; + font-weight: 500; + font-style: normal; + font-display: swap; + src: url('/fonts/inter-medium.woff2') format('woff2'); +} +@font-face { + font-family: 'Inter'; + font-weight: 600; + font-style: normal; + font-display: swap; + src: url('/fonts/inter-semibold.woff2') format('woff2'); +} + +/* ---- Design Tokens (Dark Default) ---- */ +:root { + /* Surfaces */ + --surface-0: #0f1117; + --surface-1: #181a22; + --surface-2: #1e2028; + --surface-3: #252830; + + /* Text */ + --text-primary: #e8eaf0; + --text-secondary: #8b8fa3; + --text-tertiary: #5c6070; + + /* Accent */ + --accent: #3b82f6; + --accent-hover: #2563eb; + --accent-subtle: rgba(59, 130, 246, 0.10); + + /* Semantic */ + --success: #34d399; + --warning: #fbbf24; + --error: #f87171; + --info: #60a5fa; + --recording: #ef4444; + + /* Borders */ + --border: rgba(255, 255, 255, 0.06); + --border-hover: rgba(255, 255, 255, 0.12); + + /* Spacing */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + + /* Radii */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-pill: 9999px; +} + +:root.light-mode { + --surface-0: #f4f5f7; + --surface-1: #ffffff; + --surface-2: #f0f1f4; + --surface-3: #e8e9ed; + + --text-primary: #1a1c24; + --text-secondary: #5c5f72; + --text-tertiary: #8b8fa3; + + --accent: #2563eb; + --accent-hover: #1d4ed8; + --accent-subtle: rgba(37, 99, 235, 0.08); + + --border: rgba(0, 0, 0, 0.08); + --border-hover: rgba(0, 0, 0, 0.15); +} + +/* ---- Reset ---- */ +* { margin: 0; padding: 0; box-sizing: border-box; } + +/* ---- Base ---- */ +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--surface-0); + color: var(--text-primary); +} + +/* ---- Scrollbar ---- */ +::-webkit-scrollbar { width: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: var(--surface-3); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--text-tertiary); +} + +/* ---- Badges ---- */ +.badge { + display: inline-flex; + align-items: center; + padding: 1px 7px; + border-radius: var(--radius-pill); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.01em; + white-space: nowrap; +} + +.badge-resolution { + background: rgba(59, 130, 246, 0.15); + color: var(--info); +} + +.badge-format { + background: rgba(139, 92, 246, 0.15); + color: #a78bfa; +} + +.badge-link-type { + background: rgba(251, 191, 36, 0.12); + color: var(--warning); +} + +.badge-duration { + color: var(--text-tertiary); + font-size: 10px; +} + +.badge-completed { + background: rgba(52, 211, 153, 0.12); + color: var(--success); +} + +.badge-failed { + background: rgba(248, 113, 113, 0.12); + color: var(--error); +} + +.badge-cancelled { + background: rgba(251, 191, 36, 0.12); + color: var(--warning); +} + +.badge-live { + background: rgba(248, 113, 113, 0.15); + color: #f87171; +} + +/* Light-mode badge overrides */ +:root.light-mode .badge-resolution { + background: rgba(37, 99, 235, 0.08); + color: #2563eb; +} + +:root.light-mode .badge-format { + background: rgba(124, 58, 237, 0.08); + color: #7c3aed; +} + +:root.light-mode .badge-link-type { + background: rgba(217, 119, 6, 0.08); + color: #d97706; +} + +:root.light-mode .badge-completed { + background: rgba(22, 163, 74, 0.08); + color: #16a34a; +} + +:root.light-mode .badge-failed { + background: rgba(220, 38, 38, 0.08); + color: #dc2626; +} + +:root.light-mode .badge-cancelled { + background: rgba(217, 119, 6, 0.08); + color: #d97706; +} diff --git a/src/content.ts b/src/content.ts index 1d3dd4d..9b76030 100644 --- a/src/content.ts +++ b/src/content.ts @@ -4,10 +4,11 @@ */ import { MessageType } from "./shared/messages"; -import { VideoMetadata, VideoFormat } from "./core/types"; +import { VideoMetadata, VideoFormat, StorageConfig } from "./core/types"; import { DetectionManager } from "./core/detection/detection-manager"; import { normalizeUrl } from "./core/utils/url-utils"; import { logger } from "./core/utils/logger"; +import { STORAGE_CONFIG_KEY } from "./shared/constants"; let detectedVideos: Record = {}; let detectionManager: DetectionManager; @@ -158,7 +159,7 @@ function addDetectedVideo(video: VideoMetadata) { * Initialize content script * Sets up detection manager, performs initial scan, and monitors DOM changes */ -function init() { +async function init() { // Reset icon to gray on page load (only from top frame) if (!inIframe) { safeSendMessage({ @@ -166,6 +167,9 @@ function init() { }); } + const stored = await chrome.storage.local.get(STORAGE_CONFIG_KEY); + const config: StorageConfig | undefined = stored[STORAGE_CONFIG_KEY]; + detectionManager = new DetectionManager({ onVideoDetected: (video) => { addDetectedVideo(video); @@ -173,6 +177,8 @@ function init() { onVideoRemoved: (url) => { removeDetectedVideo(url); }, + detectionCacheSize: config?.advanced?.detectionCacheSize, + masterPlaylistCacheSize: config?.advanced?.masterPlaylistCacheSize, }); // Initialize all detection mechanisms diff --git a/src/core/database/downloads.ts b/src/core/database/downloads.ts index eb776e1..88c36b3 100644 --- a/src/core/database/downloads.ts +++ b/src/core/database/downloads.ts @@ -205,6 +205,35 @@ export async function deleteDownload(id: string): Promise { } } +/** + * Delete multiple download states in a single transaction + */ +export async function bulkDeleteDownloads(ids: string[]): Promise { + if (ids.length === 0) return; + try { + const db = await openDatabase(); + const transaction = db.transaction([DOWNLOADS_STORE_NAME], "readwrite"); + const store = transaction.objectStore(DOWNLOADS_STORE_NAME); + + await Promise.all( + ids.map( + (id) => + new Promise((resolve, reject) => { + const request = store.delete(id); + request.onsuccess = () => resolve(); + request.onerror = () => + reject(new Error(`Failed to delete download: ${request.error}`)); + }), + ), + ); + + logger.debug(`Bulk deleted ${ids.length} download(s)`); + } catch (error) { + logger.error("Failed to bulk delete downloads:", error); + throw error; + } +} + /** * Clear all downloads */ diff --git a/src/core/detection/dash/dash-detection-handler.ts b/src/core/detection/dash/dash-detection-handler.ts index 29075ad..cd7a530 100644 --- a/src/core/detection/dash/dash-detection-handler.ts +++ b/src/core/detection/dash/dash-detection-handler.ts @@ -14,22 +14,25 @@ import { normalizeUrl } from "../../utils/url-utils"; import { logger } from "../../utils/logger"; import { extractThumbnail } from "../thumbnail-utils"; import { isLive, hasDrm } from "../../parsers/mpd-parser"; +import { DEFAULT_DETECTION_CACHE_SIZE } from "../../../shared/constants"; export interface DashDetectionHandlerOptions { onVideoDetected?: (video: VideoMetadata) => void; + /** Max distinct URL path keys tracked per page (default: 500) */ + detectionCacheSize?: number; } -const MAX_SEEN_PATH_KEYS = 500; - /** * DASH detection handler — detects .mpd manifest URLs */ export class DashDetectionHandler { private onVideoDetected?: (video: VideoMetadata) => void; private seenPathKeys: Set = new Set(); + private readonly maxSeenPathKeys: number; constructor(options: DashDetectionHandlerOptions = {}) { this.onVideoDetected = options.onVideoDetected; + this.maxSeenPathKeys = options.detectionCacheSize ?? DEFAULT_DETECTION_CACHE_SIZE; } destroy(): void { @@ -48,7 +51,7 @@ export class DashDetectionHandler { // Deduplicate by origin+pathname — ignores auth tokens in query params const pathKey = this.getPathKey(url); if (this.seenPathKeys.has(pathKey)) return null; - if (this.seenPathKeys.size >= MAX_SEEN_PATH_KEYS) { + if (this.seenPathKeys.size >= this.maxSeenPathKeys) { const first = this.seenPathKeys.values().next().value; if (first) this.seenPathKeys.delete(first); } diff --git a/src/core/detection/detection-manager.ts b/src/core/detection/detection-manager.ts index 0baffe1..1c7b951 100644 --- a/src/core/detection/detection-manager.ts +++ b/src/core/detection/detection-manager.ts @@ -33,6 +33,10 @@ export interface DetectionManagerOptions { onVideoDetected?: (video: VideoMetadata) => void; /** Optional callback for removed videos */ onVideoRemoved?: (url: string) => void; + /** Max distinct URL path keys tracked per page (default: 500) */ + detectionCacheSize?: number; + /** Max master playlists held in memory by HLS handler (default: 50) */ + masterPlaylistCacheSize?: number; } /** @@ -59,9 +63,12 @@ export class DetectionManager { this.hlsHandler = new HlsDetectionHandler({ onVideoDetected: (video) => this.handleVideoDetected(video), onVideoRemoved: (url) => this.handleVideoRemoved(url), + detectionCacheSize: options.detectionCacheSize, + masterPlaylistCacheSize: options.masterPlaylistCacheSize, }); this.dashHandler = new DashDetectionHandler({ onVideoDetected: (video) => this.handleVideoDetected(video), + detectionCacheSize: options.detectionCacheSize, }); } diff --git a/src/core/detection/hls/hls-detection-handler.ts b/src/core/detection/hls/hls-detection-handler.ts index b7c9067..e0c4225 100644 --- a/src/core/detection/hls/hls-detection-handler.ts +++ b/src/core/detection/hls/hls-detection-handler.ts @@ -36,6 +36,7 @@ import { normalizeUrl } from "../../utils/url-utils"; import { logger } from "../../utils/logger"; import { extractThumbnail } from "../thumbnail-utils"; import { hasDrm, canDecrypt } from "../../utils/drm-utils"; +import { DEFAULT_DETECTION_CACHE_SIZE, DEFAULT_MASTER_PLAYLIST_CACHE_SIZE } from "../../../shared/constants"; /** Configuration options for HlsDetectionHandler */ export interface HlsDetectionHandlerOptions { @@ -43,6 +44,10 @@ export interface HlsDetectionHandlerOptions { onVideoDetected?: (video: VideoMetadata) => void; /** Optional callback for removed videos */ onVideoRemoved?: (url: string) => void; + /** Max distinct URL path keys tracked per page (default: 500) */ + detectionCacheSize?: number; + /** Max master playlists held in memory (default: 50) */ + masterPlaylistCacheSize?: number; } /** Internal structure for tracking master playlist information */ @@ -52,9 +57,6 @@ interface MasterPlaylistInfo { variantPathKeys: Set; } -const MAX_SEEN_PATH_KEYS = 500; -const MAX_MASTER_PLAYLISTS = 50; - /** * HLS detection handler * Detects HLS master playlists and standalone media playlists @@ -66,6 +68,8 @@ export class HlsDetectionHandler { private masterPlaylists: Map = new Map(); // Deduplicate HLS URLs by origin+pathname (ignoring query params like tokens/timestamps) private seenPathKeys: Set = new Set(); + private readonly maxSeenPathKeys: number; + private readonly maxMasterPlaylists: number; /** * Create a new HlsDetectionHandler instance @@ -74,6 +78,8 @@ export class HlsDetectionHandler { constructor(options: HlsDetectionHandlerOptions = {}) { this.onVideoDetected = options.onVideoDetected; this.onVideoRemoved = options.onVideoRemoved; + this.maxSeenPathKeys = options.detectionCacheSize ?? DEFAULT_DETECTION_CACHE_SIZE; + this.maxMasterPlaylists = options.masterPlaylistCacheSize ?? DEFAULT_MASTER_PLAYLIST_CACHE_SIZE; } /** @@ -101,7 +107,7 @@ export class HlsDetectionHandler { return null; } // Evict oldest entries if over limit - if (this.seenPathKeys.size >= MAX_SEEN_PATH_KEYS) { + if (this.seenPathKeys.size >= this.maxSeenPathKeys) { const first = this.seenPathKeys.values().next().value; if (first) this.seenPathKeys.delete(first); } @@ -238,7 +244,7 @@ export class HlsDetectionHandler { }); // Evict oldest master playlists if over limit - if (this.masterPlaylists.size >= MAX_MASTER_PLAYLISTS) { + if (this.masterPlaylists.size >= this.maxMasterPlaylists) { const firstKey = this.masterPlaylists.keys().next().value; if (firstKey) this.masterPlaylists.delete(firstKey); } diff --git a/src/core/downloader/base-playlist-handler.ts b/src/core/downloader/base-playlist-handler.ts index 7318adf..94d8393 100644 --- a/src/core/downloader/base-playlist-handler.ts +++ b/src/core/downloader/base-playlist-handler.ts @@ -23,6 +23,13 @@ import { addHeaderRules, removeHeaderRules } from "./header-rules"; import { DEFAULT_MAX_CONCURRENT, DEFAULT_FFMPEG_TIMEOUT_MS, + DEFAULT_MAX_RETRIES, + INITIAL_RETRY_DELAY_MS, + RETRY_BACKOFF_FACTOR, + DEFAULT_DB_SYNC_INTERVAL_MS, + DEFAULT_MIN_POLL_MS, + DEFAULT_MAX_POLL_MS, + DEFAULT_POLL_FRACTION, MAX_FRAGMENT_FAILURE_RATE, SAVING_STAGE_PERCENTAGE, } from "../../shared/constants"; @@ -33,6 +40,14 @@ export interface BasePlaylistHandlerOptions { ffmpegTimeout?: number; shouldSaveOnCancel?: () => boolean; selectedBandwidth?: number; + maxRetries?: number; + retryDelayMs?: number; + retryBackoffFactor?: number; + fragmentFailureRate?: number; + dbSyncIntervalMs?: number; + minPollIntervalMs?: number; + maxPollIntervalMs?: number; + pollFraction?: number; } export abstract class BasePlaylistHandler { @@ -41,6 +56,13 @@ export abstract class BasePlaylistHandler { protected readonly ffmpegTimeout: number; protected readonly shouldSaveOnCancel?: () => boolean; protected readonly selectedBandwidth?: number; + protected readonly maxRetries: number; + protected readonly retryDelayMs: number; + protected readonly retryBackoffFactor: number; + protected readonly fragmentFailureRate: number; + protected readonly minPollIntervalMs: number; + protected readonly maxPollIntervalMs: number; + protected readonly pollFraction: number; protected downloadId: string = ""; protected bytesDownloaded: number = 0; @@ -52,7 +74,7 @@ export abstract class BasePlaylistHandler { private cachedState: DownloadState | null = null; private lastDbSyncTime: number = 0; private static readonly SPEED_EMA_ALPHA = 0.3; - private static readonly DB_SYNC_INTERVAL_MS = 500; + private readonly dbSyncIntervalMs: number; constructor(options: BasePlaylistHandlerOptions = {}) { this.onProgress = options.onProgress; @@ -60,6 +82,14 @@ export abstract class BasePlaylistHandler { this.ffmpegTimeout = options.ffmpegTimeout || DEFAULT_FFMPEG_TIMEOUT_MS; this.shouldSaveOnCancel = options.shouldSaveOnCancel; this.selectedBandwidth = options.selectedBandwidth; + this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; + this.retryDelayMs = options.retryDelayMs ?? INITIAL_RETRY_DELAY_MS; + this.retryBackoffFactor = options.retryBackoffFactor ?? RETRY_BACKOFF_FACTOR; + this.fragmentFailureRate = options.fragmentFailureRate ?? MAX_FRAGMENT_FAILURE_RATE; + this.dbSyncIntervalMs = options.dbSyncIntervalMs ?? DEFAULT_DB_SYNC_INTERVAL_MS; + this.minPollIntervalMs = options.minPollIntervalMs ?? DEFAULT_MIN_POLL_MS; + this.maxPollIntervalMs = options.maxPollIntervalMs ?? DEFAULT_MAX_POLL_MS; + this.pollFraction = options.pollFraction ?? DEFAULT_POLL_FRACTION; } // ---- Shared utility methods ---- @@ -133,7 +163,7 @@ export abstract class BasePlaylistHandler { state.progress.lastDownloaded = downloadedBytes; // Throttle DB writes to reduce I/O during fragment downloads - if (now - this.lastDbSyncTime >= BasePlaylistHandler.DB_SYNC_INTERVAL_MS) { + if (now - this.lastDbSyncTime >= this.dbSyncIntervalMs) { this.lastDbSyncTime = now; await storeDownload(state); } @@ -220,13 +250,13 @@ export abstract class BasePlaylistHandler { protected async fetchTextCancellable( url: string, - retries: number = 3, + retries?: number, ): Promise { if (!this.abortSignal) { throw new Error("AbortSignal is required for cancellable fetch"); } return cancelIfAborted( - fetchText(url, retries, this.abortSignal), + fetchText(url, retries ?? this.maxRetries, this.abortSignal, undefined, undefined, this.retryDelayMs, this.retryBackoffFactor), this.abortSignal, ); } @@ -234,19 +264,20 @@ export abstract class BasePlaylistHandler { protected async downloadFragment( fragment: Fragment, downloadId: string, - fetchAttempts: number = 3, + fetchAttempts?: number, ): Promise { if (!this.abortSignal) { throw new Error("AbortSignal is required for fragment download"); } + const attempts = fetchAttempts ?? this.maxRetries; const data = await cancelIfAborted( - fetchArrayBuffer(fragment.uri, fetchAttempts, this.abortSignal), + fetchArrayBuffer(fragment.uri, attempts, this.abortSignal, undefined, this.retryDelayMs, this.retryBackoffFactor), this.abortSignal, ); const decryptedData = await cancelIfAborted( - decryptFragment(fragment.key, data, fetchAttempts, this.abortSignal), + decryptFragment(fragment.key, data, attempts, this.abortSignal, undefined, this.retryDelayMs, this.retryBackoffFactor), this.abortSignal, ); @@ -418,8 +449,8 @@ export abstract class BasePlaylistHandler { logger.warn( `Downloaded ${downloadedFragments}/${totalFragments} fragments (${failedCount} failed).`, ); - // Abort if more than 10% of fragments failed — output would be too corrupted - if (failureRate > MAX_FRAGMENT_FAILURE_RATE) { + // Abort if too many fragments failed — output would be too corrupted + if (failureRate > this.fragmentFailureRate) { throw new Error( `Too many fragment failures: ${failedCount}/${totalFragments} failed (${Math.round(failureRate * 100)}%). Aborting to avoid corrupted output.`, ); diff --git a/src/core/downloader/crypto-utils.ts b/src/core/downloader/crypto-utils.ts index 0f84481..d9a17d7 100644 --- a/src/core/downloader/crypto-utils.ts +++ b/src/core/downloader/crypto-utils.ts @@ -59,6 +59,8 @@ export async function decryptFragment( fetchAttempts: number = 3, abortSignal?: AbortSignal, headers?: Record, + retryDelayMs?: number, + retryBackoffFactor?: number, ): Promise { // If no key URI or IV, fragment is not encrypted if (!key.uri || !key.iv) { @@ -67,7 +69,7 @@ export async function decryptFragment( try { // Fetch the encryption key - const keyArrayBuffer = await fetchArrayBuffer(key.uri, fetchAttempts, abortSignal, headers); + const keyArrayBuffer = await fetchArrayBuffer(key.uri, fetchAttempts, abortSignal, headers, retryDelayMs, retryBackoffFactor); // Convert IV from hex string to Uint8Array // IV should be 16 bytes for AES-128 diff --git a/src/core/downloader/dash/dash-recording-handler.ts b/src/core/downloader/dash/dash-recording-handler.ts index 1738a4b..10d6c55 100644 --- a/src/core/downloader/dash/dash-recording-handler.ts +++ b/src/core/downloader/dash/dash-recording-handler.ts @@ -43,7 +43,7 @@ export class DashRecordingHandler extends BaseRecordingHandler { url: string, abortSignal: AbortSignal, ): Promise<{ mediaUrl: string; finalUrl: string }> { - const { finalUrl } = await fetchTextWithFinalUrl(url, 1, abortSignal, false); + const { finalUrl } = await fetchTextWithFinalUrl(url, 1, abortSignal, false, this.retryDelayMs, this.retryBackoffFactor); return { mediaUrl: url, finalUrl }; } @@ -55,7 +55,7 @@ export class DashRecordingHandler extends BaseRecordingHandler { abortSignal: AbortSignal, seenUris: Set, ): Promise<{ fragments: Fragment[]; audioFragments?: Fragment[]; pollIntervalMs: number; ended: boolean }> { - const mpdText = await fetchText(mpdUrl, 3, abortSignal, true); + const mpdText = await fetchText(mpdUrl, this.maxRetries, abortSignal, true, undefined, this.retryDelayMs, this.retryBackoffFactor); const manifest = parseManifest(mpdText, mpdUrl); diff --git a/src/core/downloader/download-manager.ts b/src/core/downloader/download-manager.ts index c28be01..2d8dca0 100644 --- a/src/core/downloader/download-manager.ts +++ b/src/core/downloader/download-manager.ts @@ -37,6 +37,30 @@ export interface DownloadManagerOptions { /** When cancelled, save partial progress instead of discarding */ shouldSaveOnCancel?: () => boolean; + + /** Max segment/manifest fetch retries (default: 3) */ + maxRetries?: number; + + /** Initial retry backoff delay in ms (default: 100) */ + retryDelayMs?: number; + + /** Exponential backoff multiplier (default: 1.15) */ + retryBackoffFactor?: number; + + /** Max tolerated fragment failure rate 0–1 (default: 0.1) */ + fragmentFailureRate?: number; + + /** IDB write throttle during segment downloads in ms (default: 500) */ + dbSyncIntervalMs?: number; + + /** Minimum HLS live recording poll interval in ms (default: 1000) */ + minPollIntervalMs?: number; + + /** Maximum HLS live recording poll interval in ms (default: 10000) */ + maxPollIntervalMs?: number; + + /** Fraction of #EXT-X-TARGETDURATION used to compute HLS poll cadence (default: 0.5) */ + pollFraction?: number; } /** @@ -62,34 +86,34 @@ export class DownloadManager { this.uploadToDrive = options.uploadToDrive || false; const ffmpegTimeout = options.ffmpegTimeout || DEFAULT_FFMPEG_TIMEOUT_MS; + const sharedOptions = { + onProgress: this.onProgress, + maxConcurrent: this.maxConcurrent, + ffmpegTimeout, + shouldSaveOnCancel: options.shouldSaveOnCancel, + maxRetries: options.maxRetries, + retryDelayMs: options.retryDelayMs, + retryBackoffFactor: options.retryBackoffFactor, + fragmentFailureRate: options.fragmentFailureRate, + dbSyncIntervalMs: options.dbSyncIntervalMs, + minPollIntervalMs: options.minPollIntervalMs, + maxPollIntervalMs: options.maxPollIntervalMs, + pollFraction: options.pollFraction, + }; + // Initialize direct download handler this.directDownloadHandler = new DirectDownloadHandler({ onProgress: this.onProgress, }); // Initialize HLS download handler - this.hlsDownloadHandler = new HlsDownloadHandler({ - onProgress: this.onProgress, - maxConcurrent: this.maxConcurrent, - ffmpegTimeout, - shouldSaveOnCancel: options.shouldSaveOnCancel, - }); + this.hlsDownloadHandler = new HlsDownloadHandler(sharedOptions); // Initialize M3U8 download handler - this.m3u8DownloadHandler = new M3u8DownloadHandler({ - onProgress: this.onProgress, - maxConcurrent: this.maxConcurrent, - ffmpegTimeout, - shouldSaveOnCancel: options.shouldSaveOnCancel, - }); + this.m3u8DownloadHandler = new M3u8DownloadHandler(sharedOptions); // Initialize DASH download handler - this.dashDownloadHandler = new DashDownloadHandler({ - onProgress: this.onProgress, - maxConcurrent: this.maxConcurrent, - ffmpegTimeout, - shouldSaveOnCancel: options.shouldSaveOnCancel, - }); + this.dashDownloadHandler = new DashDownloadHandler(sharedOptions); } /** diff --git a/src/core/downloader/hls/hls-recording-handler.ts b/src/core/downloader/hls/hls-recording-handler.ts index a900806..4607386 100644 --- a/src/core/downloader/hls/hls-recording-handler.ts +++ b/src/core/downloader/hls/hls-recording-handler.ts @@ -21,23 +21,7 @@ import { import { logger } from "../../utils/logger"; import { MessageType } from "../../../shared/messages"; import { BaseRecordingHandler } from "../base-recording-handler"; - -const DEFAULT_POLL_INTERVAL_MS = 3000; -const MIN_POLL_INTERVAL_MS = 1000; -const MAX_POLL_INTERVAL_MS = 10000; -const POLL_INTERVAL_FRACTION = 0.5; - -/** - * Extract #EXT-X-TARGETDURATION from playlist text and compute a poll interval. - * Polls at half the target duration to avoid missing segments. - */ -function computePollInterval(playlistText: string): number { - const match = playlistText.match(/#EXT-X-TARGETDURATION:\s*(\d+(?:\.\d+)?)/); - if (!match) return DEFAULT_POLL_INTERVAL_MS; - const targetDuration = parseFloat(match[1]!) * 1000; - const interval = Math.round(targetDuration * POLL_INTERVAL_FRACTION); - return Math.max(MIN_POLL_INTERVAL_MS, Math.min(interval, MAX_POLL_INTERVAL_MS)); -} +import { DEFAULT_HLS_POLL_INTERVAL_MS } from "../../../shared/constants"; export class HlsRecordingHandler extends BaseRecordingHandler { /** @@ -49,7 +33,7 @@ export class HlsRecordingHandler extends BaseRecordingHandler { url: string, abortSignal: AbortSignal, ): Promise<{ mediaUrl: string; finalUrl: string }> { - const { text, finalUrl: masterFinalUrl } = await fetchTextWithFinalUrl(url, 3, abortSignal); + const { text, finalUrl: masterFinalUrl } = await fetchTextWithFinalUrl(url, this.maxRetries, abortSignal, undefined, this.retryDelayMs, this.retryBackoffFactor); if (!text.includes("#EXT-X-STREAM-INF")) { logger.info( @@ -85,17 +69,28 @@ export class HlsRecordingHandler extends BaseRecordingHandler { abortSignal: AbortSignal, seenUris: Set, ): Promise<{ fragments: Fragment[]; pollIntervalMs: number; ended: boolean }> { - const playlistText = await fetchText(url, 3, abortSignal, true); + const playlistText = await fetchText(url, this.maxRetries, abortSignal, true, undefined, this.retryDelayMs, this.retryBackoffFactor); const allFragments = parseLevelsPlaylist(parseMediaPlaylist(playlistText, url)); const newFragments = allFragments.filter((f) => !seenUris.has(f.uri)); const ended = playlistText.includes("#EXT-X-ENDLIST"); - const pollIntervalMs = computePollInterval(playlistText); + const pollIntervalMs = this.computePollInterval(playlistText); return { fragments: newFragments, pollIntervalMs, ended }; } + /** + * Compute HLS poll interval from #EXT-X-TARGETDURATION using configured fraction and clamps. + */ + private computePollInterval(playlistText: string): number { + const match = playlistText.match(/#EXT-X-TARGETDURATION:\s*(\d+(?:\.\d+)?)/); + if (!match) return DEFAULT_HLS_POLL_INTERVAL_MS; + const targetDuration = parseFloat(match[1]!) * 1000; + const interval = Math.round(targetDuration * this.pollFraction); + return Math.max(this.minPollIntervalMs, Math.min(interval, this.maxPollIntervalMs)); + } + /** * FFmpeg options for merging HLS recording segments (MPEG-TS → MP4). */ diff --git a/src/core/storage/settings.ts b/src/core/storage/settings.ts new file mode 100644 index 0000000..0d25cc3 --- /dev/null +++ b/src/core/storage/settings.ts @@ -0,0 +1,118 @@ +/** + * Resolved application settings. + * + * Always import settings via `loadSettings()` — never read StorageConfig directly. + * Every field here has a concrete type with defaults applied, so consumers never + * need null-checks or scattered `?? DEFAULT_X` fallbacks. + */ + +import { StorageConfig } from "../types"; +import { ChromeStorage } from "./chrome-storage"; +import { + DEFAULT_MAX_CONCURRENT, + DEFAULT_FFMPEG_TIMEOUT_MS, + DEFAULT_MAX_RETRIES, + INITIAL_RETRY_DELAY_MS, + RETRY_BACKOFF_FACTOR, + MAX_FRAGMENT_FAILURE_RATE, + DEFAULT_MIN_POLL_MS, + DEFAULT_MAX_POLL_MS, + DEFAULT_POLL_FRACTION, + DEFAULT_DETECTION_CACHE_SIZE, + DEFAULT_MASTER_PLAYLIST_CACHE_SIZE, + DEFAULT_DB_SYNC_INTERVAL_MS, + STORAGE_CONFIG_KEY, + DEFAULT_GOOGLE_DRIVE_FOLDER_NAME, +} from "../../shared/constants"; + +export interface AppSettings { + ffmpegTimeout: number; + maxConcurrent: number; + historyEnabled: boolean; + + googleDrive: { + enabled: boolean; + targetFolderId?: string; + createFolderIfNotExists: boolean; + folderName: string; + }; + + s3: { + enabled: boolean; + bucket?: string; + region?: string; + endpoint?: string; + accessKeyId?: string; + secretAccessKey?: string; + prefix?: string; + }; + + recording: { + minPollIntervalMs: number; + maxPollIntervalMs: number; + pollFraction: number; + }; + + notifications: { + notifyOnCompletion: boolean; + autoOpenFile: boolean; + }; + + advanced: { + maxRetries: number; + retryDelayMs: number; + retryBackoffFactor: number; + fragmentFailureRate: number; + detectionCacheSize: number; + masterPlaylistCacheSize: number; + dbSyncIntervalMs: number; + }; +} + +export async function loadSettings(): Promise { + const raw = await ChromeStorage.get(STORAGE_CONFIG_KEY); + + return { + ffmpegTimeout: raw?.ffmpegTimeout ?? DEFAULT_FFMPEG_TIMEOUT_MS, + maxConcurrent: raw?.maxConcurrent ?? DEFAULT_MAX_CONCURRENT, + historyEnabled: raw?.historyEnabled ?? true, + + googleDrive: { + enabled: raw?.googleDrive?.enabled ?? false, + targetFolderId: raw?.googleDrive?.targetFolderId, + createFolderIfNotExists: raw?.googleDrive?.createFolderIfNotExists ?? false, + folderName: raw?.googleDrive?.folderName ?? DEFAULT_GOOGLE_DRIVE_FOLDER_NAME, + }, + + s3: { + enabled: raw?.s3?.enabled ?? false, + bucket: raw?.s3?.bucket, + region: raw?.s3?.region, + endpoint: raw?.s3?.endpoint, + accessKeyId: raw?.s3?.accessKeyId, + secretAccessKey: raw?.s3?.secretAccessKey, + prefix: raw?.s3?.prefix, + }, + + recording: { + minPollIntervalMs: raw?.recording?.minPollIntervalMs ?? DEFAULT_MIN_POLL_MS, + maxPollIntervalMs: raw?.recording?.maxPollIntervalMs ?? DEFAULT_MAX_POLL_MS, + pollFraction: raw?.recording?.pollFraction ?? DEFAULT_POLL_FRACTION, + }, + + notifications: { + notifyOnCompletion: raw?.notifications?.notifyOnCompletion ?? false, + autoOpenFile: raw?.notifications?.autoOpenFile ?? false, + }, + + advanced: { + maxRetries: raw?.advanced?.maxRetries ?? DEFAULT_MAX_RETRIES, + retryDelayMs: raw?.advanced?.retryDelayMs ?? INITIAL_RETRY_DELAY_MS, + retryBackoffFactor: raw?.advanced?.retryBackoffFactor ?? RETRY_BACKOFF_FACTOR, + fragmentFailureRate: raw?.advanced?.fragmentFailureRate ?? MAX_FRAGMENT_FAILURE_RATE, + detectionCacheSize: raw?.advanced?.detectionCacheSize ?? DEFAULT_DETECTION_CACHE_SIZE, + masterPlaylistCacheSize: raw?.advanced?.masterPlaylistCacheSize ?? DEFAULT_MASTER_PLAYLIST_CACHE_SIZE, + dbSyncIntervalMs: raw?.advanced?.dbSyncIntervalMs ?? DEFAULT_DB_SYNC_INTERVAL_MS, + }, + }; +} diff --git a/src/core/types/index.ts b/src/core/types/index.ts index d7e3ecf..60e3645 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -88,6 +88,35 @@ export interface StorageConfig { folderName?: string; }; ffmpegTimeout?: number; // FFmpeg processing timeout in milliseconds (default: 15 minutes) + maxConcurrent?: number; // Max concurrent segment downloads (default: 3) + historyEnabled?: boolean; // Whether to persist completed/failed/cancelled downloads (default: true) + s3?: { + enabled: boolean; + bucket?: string; + region?: string; + endpoint?: string; // For S3-compatible providers (Cloudflare R2, Backblaze, etc.) + accessKeyId?: string; + secretAccessKey?: string; + prefix?: string; + }; + recording?: { + minPollIntervalMs?: number; // Minimum HLS poll interval (default: 1000ms) + maxPollIntervalMs?: number; // Maximum HLS poll interval (default: 10000ms) + pollFraction?: number; // Fraction of #EXT-X-TARGETDURATION used for poll cadence (default: 0.5) + }; + notifications?: { + notifyOnCompletion?: boolean; // Show OS notification when download finishes (default: false) + autoOpenFile?: boolean; // Open file in Finder/Explorer after download (default: false) + }; + advanced?: { + maxRetries?: number; // Max segment/manifest fetch retries (default: 3) + retryDelayMs?: number; // Initial retry backoff delay in ms (default: 100) + retryBackoffFactor?: number; // Exponential backoff multiplier (default: 1.15) + fragmentFailureRate?: number; // Max tolerated fragment failure rate 0–1 (default: 0.1) + detectionCacheSize?: number; // Max URL path keys tracked per page (default: 500) + masterPlaylistCacheSize?: number; // Max master playlists in memory (default: 50) + dbSyncIntervalMs?: number; // IDB write throttle during segment downloads (default: 500) + }; } export interface MessageRequest { diff --git a/src/core/utils/fetch-utils.ts b/src/core/utils/fetch-utils.ts index 345db80..f413015 100644 --- a/src/core/utils/fetch-utils.ts +++ b/src/core/utils/fetch-utils.ts @@ -112,12 +112,14 @@ export async function fetchResource( async function fetchWithRetry( fetchFn: FetchFn, attempts: number = 1, + retryDelayMs: number = INITIAL_RETRY_DELAY_MS, + retryBackoffFactor: number = RETRY_BACKOFF_FACTOR, ): Promise { if (attempts < 1) { throw new Error("Attempts less then 1"); } let countdown = attempts; - let retryTime = INITIAL_RETRY_DELAY_MS; + let retryTime = retryDelayMs; let lastError: unknown; while (countdown--) { try { @@ -130,7 +132,7 @@ async function fetchWithRetry( } if (countdown > 0) { await new Promise((resolve) => setTimeout(resolve, retryTime)); - retryTime *= RETRY_BACKOFF_FACTOR; + retryTime *= retryBackoffFactor; } } } @@ -143,10 +145,12 @@ export async function fetchText( signal?: AbortSignal, noCache?: boolean, headers?: Record, + retryDelayMs?: number, + retryBackoffFactor?: number, ) { const fetchFn: FetchFn = () => fetchResource(url, { signal, headers, cache: noCache ? "no-store" : undefined }).then((res) => res.text()); - return fetchWithRetry(fetchFn, attempts); + return fetchWithRetry(fetchFn, attempts, retryDelayMs, retryBackoffFactor); } /** @@ -159,6 +163,8 @@ export async function fetchTextWithFinalUrl( attempts: number = 1, signal?: AbortSignal, noCache?: boolean, + retryDelayMs?: number, + retryBackoffFactor?: number, ): Promise<{ text: string; finalUrl: string }> { if (isServiceWorkerContext()) { const fetchFn: FetchFn<{ text: string; finalUrl: string }> = async () => { @@ -172,10 +178,10 @@ export async function fetchTextWithFinalUrl( const text = await response.text(); return { text, finalUrl: response.url || url }; }; - return fetchWithRetry(fetchFn, attempts); + return fetchWithRetry(fetchFn, attempts, retryDelayMs, retryBackoffFactor); } // In content script context: response.url unavailable after proxy, fall back to original url - const text = await fetchText(url, attempts, signal, noCache); + const text = await fetchText(url, attempts, signal, noCache, undefined, retryDelayMs, retryBackoffFactor); return { text, finalUrl: url }; } @@ -184,10 +190,12 @@ export async function fetchArrayBuffer( attempts: number = 1, signal?: AbortSignal, headers?: Record, + retryDelayMs?: number, + retryBackoffFactor?: number, ) { const fetchFn: FetchFn = () => fetchResource(url, { signal, headers }).then((res) => res.arrayBuffer()); - return fetchWithRetry(fetchFn, attempts); + return fetchWithRetry(fetchFn, attempts, retryDelayMs, retryBackoffFactor); } export const FetchLoader = { diff --git a/src/options/constants.ts b/src/options/constants.ts new file mode 100644 index 0000000..9ce4197 --- /dev/null +++ b/src/options/constants.ts @@ -0,0 +1,40 @@ +/** + * Options-page-only constants. + * + * Values here are used exclusively within the options UI. + * Cross-module constants live in src/shared/constants.ts. + */ + +/** Duration toast notifications are visible (ms — internal only, not user-facing) */ +export const TOAST_DURATION_MS = 3_000; + +export const MS_PER_DAY = 86_400_000; + +// ---- FFmpeg timeout UI bounds (seconds) ---- +export const DEFAULT_FFMPEG_TIMEOUT_S = 900; // 15 min +export const MIN_FFMPEG_TIMEOUT_S = 300; // 5 min +export const MAX_FFMPEG_TIMEOUT_S = 3_600; // 60 min + +// ---- Recording settings validation bounds (seconds) ---- +export const MIN_POLL_MIN_S = 0.5; +export const MAX_POLL_MIN_S = 5; +export const MIN_POLL_MAX_S = 2; +export const MAX_POLL_MAX_S = 30; +export const MIN_POLL_FRACTION = 0.25; +export const MAX_POLL_FRACTION = 1.0; + +// ---- Advanced settings validation bounds ---- +export const MIN_MAX_RETRIES = 1; +export const MAX_MAX_RETRIES = 10; +export const MIN_RETRY_DELAY_S = 0.05; // 50 ms +export const MAX_RETRY_DELAY_S = 1; // 1 000 ms +export const MIN_RETRY_BACKOFF_FACTOR = 1.0; +export const MAX_RETRY_BACKOFF_FACTOR = 3.0; +export const MIN_FAILURE_RATE = 0.05; +export const MAX_FAILURE_RATE = 0.5; +export const MIN_DETECTION_CACHE_SIZE = 100; +export const MAX_DETECTION_CACHE_SIZE = 2_000; +export const MIN_MASTER_PLAYLIST_CACHE_SIZE = 10; +export const MAX_MASTER_PLAYLIST_CACHE_SIZE = 200; +export const MIN_DB_SYNC_S = 0.1; // 100 ms +export const MAX_DB_SYNC_S = 2; // 2 000 ms diff --git a/src/options/options.html b/src/options/options.html index b40df16..808d231 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -4,136 +4,166 @@ Media Bridge Settings + - +
+ + + + +
+ + +
+
+

Download Settings

+

Configure segment download concurrency and FFmpeg processing limits.

+
+ +
+

Concurrency

+
+ + +
Number of segments to download simultaneously (1–10).
+
+
+ +
+

FFmpeg

+
+ + +
Maximum time for FFmpeg to process a video (300–3 600 s). Increase for very large files.
+
+
+ +
+
-
+
+ diff --git a/src/options/options.ts b/src/options/options.ts index 5943a68..8751b16 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -1,280 +1,1229 @@ /** * Options page logic + * Sections: init/routing, download settings, cloud providers, history */ import { ChromeStorage } from "../core/storage/chrome-storage"; -import { GoogleAuth } from "../core/cloud/google-auth"; -import { StorageConfig } from "../core/types"; +import { loadSettings } from "../core/storage/settings"; +import { GoogleAuth, GOOGLE_DRIVE_SCOPES } from "../core/cloud/google-auth"; +import { StorageConfig, DownloadState, DownloadStage, VideoMetadata } from "../core/types"; +import { MessageType } from "../shared/messages"; +import { + getAllDownloads, + getDownload, + deleteDownload, + bulkDeleteDownloads, + clearAllDownloads, +} from "../core/database/downloads"; import { - DEFAULT_FFMPEG_TIMEOUT_MS, - DEFAULT_FFMPEG_TIMEOUT_MINUTES, - MIN_FFMPEG_TIMEOUT_MINUTES, - MAX_FFMPEG_TIMEOUT_MINUTES, - MS_PER_MINUTE, DEFAULT_MAX_CONCURRENT, STORAGE_CONFIG_KEY, - MAX_CONCURRENT_KEY, + DEFAULT_MAX_RETRIES, + DEFAULT_MIN_POLL_MS, + DEFAULT_MAX_POLL_MS, + DEFAULT_POLL_FRACTION, + DEFAULT_DETECTION_CACHE_SIZE, + DEFAULT_MASTER_PLAYLIST_CACHE_SIZE, + DEFAULT_DB_SYNC_INTERVAL_MS, + INITIAL_RETRY_DELAY_MS, + RETRY_BACKOFF_FACTOR, + MAX_FRAGMENT_FAILURE_RATE, + DEFAULT_GOOGLE_DRIVE_FOLDER_NAME, } from "../shared/constants"; -// DOM elements -const driveEnabled = document.getElementById( - "driveEnabled", -) as HTMLInputElement; -const driveSettings = document.getElementById( - "driveSettings", -) as HTMLDivElement; -const folderName = document.getElementById("folderName") as HTMLInputElement; -const folderId = document.getElementById("folderId") as HTMLInputElement; -const authStatus = document.getElementById("authStatus") as HTMLSpanElement; -const authBtn = document.getElementById("authBtn") as HTMLButtonElement; -const signOutBtn = document.getElementById("signOutBtn") as HTMLButtonElement; -const maxConcurrent = document.getElementById( - "maxConcurrent", -) as HTMLInputElement; -const ffmpegTimeout = document.getElementById( - "ffmpegTimeout", -) as HTMLInputElement; -const saveBtn = document.getElementById("saveBtn") as HTMLButtonElement; -const statusMessage = document.getElementById( - "statusMessage", -) as HTMLDivElement; -const themeToggle = document.getElementById("themeToggle") as HTMLButtonElement; -const themeIcon = document.getElementById("themeIcon") as unknown as SVGElement; - -const STATUS_MESSAGE_DURATION_MS = 5000; +import { + TOAST_DURATION_MS, + MS_PER_DAY, + DEFAULT_FFMPEG_TIMEOUT_S, + MIN_FFMPEG_TIMEOUT_S, + MAX_FFMPEG_TIMEOUT_S, + MIN_POLL_MIN_S, + MAX_POLL_MIN_S, + MIN_POLL_MAX_S, + MAX_POLL_MAX_S, + MIN_POLL_FRACTION, + MAX_POLL_FRACTION, + MIN_MAX_RETRIES, + MAX_MAX_RETRIES, + MIN_RETRY_DELAY_S, + MAX_RETRY_DELAY_S, + MIN_RETRY_BACKOFF_FACTOR, + MAX_RETRY_BACKOFF_FACTOR, + MIN_FAILURE_RATE, + MAX_FAILURE_RATE, + MIN_DETECTION_CACHE_SIZE, + MAX_DETECTION_CACHE_SIZE, + MIN_MASTER_PLAYLIST_CACHE_SIZE, + MAX_MASTER_PLAYLIST_CACHE_SIZE, + MIN_DB_SYNC_S, + MAX_DB_SYNC_S, +} from "./constants"; -/** - * Initialize options page - */ -async function init() { - await loadSettings(); - await checkAuthStatus(); - await loadTheme(); +const FINISHED_STAGES = new Set([ + DownloadStage.COMPLETED, + DownloadStage.FAILED, + DownloadStage.CANCELLED, +]); + +// ───────────────────────────────────────────── +// Section: Init & Routing +// ───────────────────────────────────────────── - // Event listeners - driveEnabled.addEventListener("change", () => { - driveSettings.style.display = driveEnabled.checked ? "block" : "none"; +function init(): void { + loadTheme(); + setupNavigation(); + setupThemeToggle(); + + // Check URL hash to navigate directly to a view (e.g. opened via history button) + const hash = location.hash.slice(1); + const validViews = new Set(["history", "cloud-providers", "recording", "notifications", "advanced"]); + switchView(validViews.has(hash) ? hash : "download-settings"); +} + +function setupNavigation(): void { + document.querySelectorAll(".nav-item").forEach((btn) => { + btn.addEventListener("click", () => { + const viewId = btn.dataset.view; + if (viewId) switchView(viewId); + }); }); +} - authBtn.addEventListener("click", handleAuth); - signOutBtn.addEventListener("click", handleSignOut); - saveBtn.addEventListener("click", handleSave); +const initializedViews = new Set(); - if (themeToggle) { - themeToggle.addEventListener("click", toggleTheme); - } +function switchView(viewId: string): void { + document.querySelectorAll(".view").forEach((v) => v.classList.remove("active")); + document.querySelectorAll(".nav-item").forEach((b) => b.classList.remove("active")); - // Show drive settings if enabled - if (driveEnabled.checked) { - driveSettings.style.display = "block"; + const view = document.getElementById(`view-${viewId}`); + if (view) view.classList.add("active"); + + const navBtn = document.querySelector( + `.nav-item[data-view="${viewId}"]`, + ); + if (navBtn) navBtn.classList.add("active"); + + // Keep URL in sync so refresh restores the same section + history.replaceState(null, "", `#${viewId}`); + + if (initializedViews.has(viewId)) return; + initializedViews.add(viewId); + + // Lazy-load view content on first activation + if (viewId === "download-settings") loadDownloadSettings(); + if (viewId === "history") loadHistory(); + if (viewId === "cloud-providers") { + loadDriveSettings(); + loadS3Settings(); + setupCloudProviderTabs(); } + if (viewId === "recording") loadRecordingSettings(); + if (viewId === "notifications") loadNotificationSettings(); + if (viewId === "advanced") loadAdvancedSettings(); } -/** - * Load settings from storage - */ -async function loadSettings() { - const config = await ChromeStorage.get(STORAGE_CONFIG_KEY); +// ───────────────────────────────────────────── +// Section: Theme +// ───────────────────────────────────────────── + +function setupThemeToggle(): void { + const btn = document.getElementById("theme-toggle"); + if (btn) btn.addEventListener("click", toggleTheme); +} - if (config?.googleDrive) { - driveEnabled.checked = config.googleDrive.enabled || false; - folderName.value = config.googleDrive.folderName || "MediaBridge Uploads"; - folderId.value = config.googleDrive.targetFolderId || ""; +async function loadTheme(): Promise { + const theme = await ChromeStorage.get("theme"); + applyTheme(theme === "light"); +} + +function applyTheme(isLight: boolean): void { + document.documentElement.classList.toggle("light-mode", isLight); + updateThemeIcon(isLight); +} + +function updateThemeIcon(isLight: boolean): void { + const icon = document.getElementById("theme-icon"); + if (!icon) return; + if (isLight) { + icon.innerHTML = ``; + } else { + icon.innerHTML = ` + + + + + + + + + `; } +} + +async function toggleTheme(): Promise { + const isLight = document.documentElement.classList.contains("light-mode"); + await ChromeStorage.set("theme", isLight ? "dark" : "light"); + applyTheme(!isLight); +} + +// ───────────────────────────────────────────── +// Section: Download Settings View +// ───────────────────────────────────────────── + +async function loadDownloadSettings(): Promise { + const config = await loadSettings(); + + const maxInput = document.getElementById("max-concurrent") as HTMLInputElement; + const timeoutInput = document.getElementById("ffmpeg-timeout") as HTMLInputElement; + + maxInput.value = config.maxConcurrent.toString(); + timeoutInput.value = Math.round(config.ffmpegTimeout / 1000).toString(); + + document + .getElementById("save-download-settings") + ?.addEventListener("click", saveDownloadSettings); +} + +async function saveDownloadSettings(): Promise { + const btn = document.getElementById("save-download-settings") as HTMLButtonElement; + const maxInput = document.getElementById("max-concurrent") as HTMLInputElement; + const timeoutInput = document.getElementById("ffmpeg-timeout") as HTMLInputElement; + + const maxConcurrent = validateField(maxInput, 1, 10, true); + const timeoutSeconds = validateField(timeoutInput, MIN_FFMPEG_TIMEOUT_S, MAX_FFMPEG_TIMEOUT_S, true); + if (maxConcurrent === null || timeoutSeconds === null) return; - const maxConcurrentValue = await ChromeStorage.get(MAX_CONCURRENT_KEY); - if (maxConcurrentValue) { - maxConcurrent.value = maxConcurrentValue.toString(); + btn.disabled = true; + btn.textContent = "Saving…"; + + try { + const config = (await ChromeStorage.get(STORAGE_CONFIG_KEY)) ?? {}; + config.ffmpegTimeout = timeoutSeconds * 1000; + config.maxConcurrent = maxConcurrent; + await ChromeStorage.set(STORAGE_CONFIG_KEY, config); + showStatus("Settings saved.", "success"); + } catch (err) { + showStatus(`Save failed: ${errorMsg(err)}`, "error"); + } finally { + btn.disabled = false; + btn.textContent = "Save Settings"; } +} + +// ───────────────────────────────────────────── +// Section: Cloud Providers View +// ───────────────────────────────────────────── + +let cloudTabsInitialized = false; + +function setupCloudProviderTabs(): void { + if (cloudTabsInitialized) return; + cloudTabsInitialized = true; - // Load FFmpeg timeout (stored internally as milliseconds, convert to minutes for UI) - const ffmpegTimeoutMs = config?.ffmpegTimeout || DEFAULT_FFMPEG_TIMEOUT_MS; - ffmpegTimeout.value = Math.round(ffmpegTimeoutMs / MS_PER_MINUTE).toString(); + document.querySelectorAll(".provider-tab").forEach((tab) => { + tab.addEventListener("click", () => { + const provider = tab.dataset.provider; + if (!provider) return; + + document + .querySelectorAll(".provider-tab") + .forEach((t) => t.classList.remove("active")); + document + .querySelectorAll(".provider-panel") + .forEach((p) => p.classList.remove("active")); + + tab.classList.add("active"); + document.getElementById(`provider-${provider}`)?.classList.add("active"); + }); + }); } -/** - * Check authentication status - */ -async function checkAuthStatus() { +// -- Google Drive -- + +async function loadDriveSettings(): Promise { + const config = await loadSettings(); + + const enabledCb = document.getElementById("drive-enabled") as HTMLInputElement; + const folderNameIn = document.getElementById("drive-folder-name") as HTMLInputElement; + const folderIdIn = document.getElementById("drive-folder-id") as HTMLInputElement; + + enabledCb.checked = config.googleDrive.enabled; + folderNameIn.value = config.googleDrive.folderName; + folderIdIn.value = config.googleDrive.targetFolderId ?? ""; + + const driveSettingsEl = document.getElementById("drive-settings"); + if (driveSettingsEl) + driveSettingsEl.style.display = enabledCb.checked ? "block" : "none"; + + enabledCb.addEventListener("change", () => { + if (driveSettingsEl) + driveSettingsEl.style.display = enabledCb.checked ? "block" : "none"; + }); + + await checkAuthStatus(); + + document.getElementById("auth-btn")?.addEventListener("click", handleAuth); + document.getElementById("sign-out-btn")?.addEventListener("click", handleSignOut); + document + .getElementById("save-drive-settings") + ?.addEventListener("click", saveDriveSettings); +} + +async function checkAuthStatus(): Promise { const isAuth = await GoogleAuth.isAuthenticated(); + const statusEl = document.getElementById("auth-status") as HTMLSpanElement; + const authBtn = document.getElementById("auth-btn") as HTMLButtonElement; + const signOutBtn = document.getElementById("sign-out-btn") as HTMLButtonElement; if (isAuth) { - authStatus.textContent = "Authenticated"; - authStatus.className = "auth-status authenticated"; + statusEl.textContent = "Authenticated"; + statusEl.className = "auth-status authenticated"; authBtn.style.display = "none"; - signOutBtn.style.display = "inline-block"; + signOutBtn.style.display = "inline-flex"; } else { - authStatus.textContent = "Not Authenticated"; - authStatus.className = "auth-status not-authenticated"; - authBtn.style.display = "inline-block"; + statusEl.textContent = "Not Authenticated"; + statusEl.className = "auth-status not-authenticated"; + authBtn.style.display = "inline-flex"; signOutBtn.style.display = "none"; } } -/** - * Handle authentication - */ -async function handleAuth() { - authBtn.disabled = true; - authBtn.textContent = "Authenticating..."; - +async function handleAuth(): Promise { + const btn = document.getElementById("auth-btn") as HTMLButtonElement; + btn.disabled = true; + btn.textContent = "Authenticating…"; try { - const { GOOGLE_DRIVE_SCOPES } = await import("../core/cloud/google-auth"); await GoogleAuth.authenticate(GOOGLE_DRIVE_SCOPES); - - showStatus("Successfully authenticated with Google!", "success"); + showStatus("Authenticated with Google.", "success"); await checkAuthStatus(); - } catch (error) { - console.error("Authentication failed:", error); - showStatus( - `Authentication failed: ${ - error instanceof Error ? error.message : String(error) - }`, - "error", - ); + } catch (err) { + showStatus(`Authentication failed: ${errorMsg(err)}`, "error"); } finally { - authBtn.disabled = false; - authBtn.textContent = "Sign in with Google"; + btn.disabled = false; + btn.textContent = "Sign in with Google"; } } -/** - * Handle sign out - */ -async function handleSignOut() { +async function handleSignOut(): Promise { try { await GoogleAuth.signOut(); - showStatus("Signed out successfully", "success"); + showStatus("Signed out.", "success"); await checkAuthStatus(); - } catch (error) { - console.error("Sign out failed:", error); - showStatus( - `Sign out failed: ${ - error instanceof Error ? error.message : String(error) - }`, - "error", - ); + } catch (err) { + showStatus(`Sign out failed: ${errorMsg(err)}`, "error"); } } -/** - * Handle save - */ -async function handleSave() { - saveBtn.disabled = true; - saveBtn.textContent = "Saving..."; +async function saveDriveSettings(): Promise { + const btn = document.getElementById("save-drive-settings") as HTMLButtonElement; + btn.disabled = true; + btn.textContent = "Saving…"; try { - const config: StorageConfig = { - googleDrive: { - enabled: driveEnabled.checked, - folderName: folderName.value || "MediaBridge Uploads", - targetFolderId: folderId.value || undefined, - createFolderIfNotExists: true, - }, + const enabledCb = document.getElementById("drive-enabled") as HTMLInputElement; + const folderNameIn = document.getElementById("drive-folder-name") as HTMLInputElement; + const folderIdIn = document.getElementById("drive-folder-id") as HTMLInputElement; + + const config = (await ChromeStorage.get(STORAGE_CONFIG_KEY)) ?? {}; + config.googleDrive = { + enabled: enabledCb.checked, + folderName: folderNameIn.value || DEFAULT_GOOGLE_DRIVE_FOLDER_NAME, + targetFolderId: folderIdIn.value || undefined, + createFolderIfNotExists: true, }; + await ChromeStorage.set(STORAGE_CONFIG_KEY, config); + showStatus("Settings saved.", "success"); + } catch (err) { + showStatus(`Save failed: ${errorMsg(err)}`, "error"); + } finally { + btn.disabled = false; + btn.textContent = "Save Settings"; + } +} - // Store FFmpeg timeout in config (convert from minutes to milliseconds for internal storage) - const timeoutMinutes = parseInt(ffmpegTimeout.value) || DEFAULT_FFMPEG_TIMEOUT_MINUTES; - const timeoutMs = Math.max(MIN_FFMPEG_TIMEOUT_MINUTES, Math.min(MAX_FFMPEG_TIMEOUT_MINUTES, timeoutMinutes)) * MS_PER_MINUTE; - config.ffmpegTimeout = timeoutMs; // Stored internally as milliseconds +// -- S3 -- - await ChromeStorage.set(STORAGE_CONFIG_KEY, config); - await ChromeStorage.set( - MAX_CONCURRENT_KEY, - parseInt(maxConcurrent.value) || DEFAULT_MAX_CONCURRENT, - ); +async function loadS3Settings(): Promise { + const { s3 } = await loadSettings(); - showStatus("Settings saved successfully!", "success"); - } catch (error) { - console.error("Save failed:", error); - showStatus( - `Failed to save settings: ${ - error instanceof Error ? error.message : String(error) - }`, - "error", - ); + const get = (id: string) => document.getElementById(id) as HTMLInputElement; + + get("s3-enabled").checked = s3.enabled; + get("s3-bucket").value = s3.bucket ?? ""; + get("s3-region").value = s3.region ?? ""; + get("s3-endpoint").value = s3.endpoint ?? ""; + get("s3-access-key").value = s3.accessKeyId ?? ""; + get("s3-secret-key").value = s3.secretAccessKey ?? ""; + get("s3-prefix").value = s3.prefix ?? ""; + + document.getElementById("save-s3-settings")?.addEventListener("click", saveS3Settings); +} + +async function saveS3Settings(): Promise { + const btn = document.getElementById("save-s3-settings") as HTMLButtonElement; + btn.disabled = true; + btn.textContent = "Saving…"; + + try { + const get = (id: string) => + (document.getElementById(id) as HTMLInputElement).value.trim(); + const checked = (id: string) => + (document.getElementById(id) as HTMLInputElement).checked; + + const config = (await ChromeStorage.get(STORAGE_CONFIG_KEY)) ?? {}; + config.s3 = { + enabled: checked("s3-enabled"), + bucket: get("s3-bucket") || undefined, + region: get("s3-region") || undefined, + endpoint: get("s3-endpoint") || undefined, + accessKeyId: get("s3-access-key") || undefined, + secretAccessKey: get("s3-secret-key") || undefined, + prefix: get("s3-prefix") || undefined, + }; + await ChromeStorage.set(STORAGE_CONFIG_KEY, config); + showStatus("Settings saved.", "success"); + } catch (err) { + showStatus(`Save failed: ${errorMsg(err)}`, "error"); } finally { - saveBtn.disabled = false; - saveBtn.textContent = "Save Settings"; + btn.disabled = false; + btn.textContent = "Save Settings"; } } -/** - * Show status message - */ -function showStatus(message: string, type: "success" | "error" | "info") { - statusMessage.className = `status status-${type}`; - statusMessage.textContent = message; - statusMessage.style.display = "block"; +// ───────────────────────────────────────────── +// Section: History View +// ───────────────────────────────────────────── + +const PAGE_SIZE = 50; +let allHistory: DownloadState[] = []; +const selectedIds = new Set(); +let currentFiltered: DownloadState[] = []; +let currentPage = 0; +let historyObserver: IntersectionObserver | null = null; +let historySentinel: HTMLElement | null = null; +let historyMessageListener: ((msg: unknown) => void) | null = null; - setTimeout(() => { - statusMessage.style.display = "none"; - }, STATUS_MESSAGE_DURATION_MS); +function isProgressMsg( + msg: unknown, +): msg is { type: MessageType.DOWNLOAD_PROGRESS; payload: { id: string; progress: { stage: string } } } { + return ( + typeof msg === "object" && + msg !== null && + (msg as { type?: unknown }).type === MessageType.DOWNLOAD_PROGRESS && + typeof (msg as { payload?: { id?: unknown } }).payload?.id === "string" + ); } -/** - * Load theme from storage and apply it - */ -async function loadTheme() { - const theme = await ChromeStorage.get("theme"); - const isLightMode = theme === "light"; - applyTheme(isLightMode); +function registerHistoryListener(): void { + if (historyMessageListener) return; + historyMessageListener = (msg) => { + if (!isProgressMsg(msg)) return; + if (!FINISHED_STAGES.has(msg.payload.progress.stage as DownloadStage)) return; + handleFinishedDownload(msg.payload.id); + }; + chrome.runtime.onMessage.addListener(historyMessageListener); } -/** - * Apply theme to the page - */ -function applyTheme(isLightMode: boolean) { - const root = document.documentElement; - if (isLightMode) { - root.classList.add("light-mode"); +function unregisterHistoryListener(): void { + if (!historyMessageListener) return; + chrome.runtime.onMessage.removeListener(historyMessageListener); + historyMessageListener = null; +} + +async function handleFinishedDownload(id: string): Promise { + const state = await getDownload(id); + if (!state) return; + + const idx = allHistory.findIndex((d) => d.id === id); + if (idx !== -1) { + allHistory[idx] = state; } else { - root.classList.remove("light-mode"); + allHistory.unshift(state); + } + + const list = document.getElementById("history-list"); + if (!list) return; + + const existing = list.querySelector(`[data-id="${id}"]`); + if (existing) { + const el = renderHistoryItem(state); + flashItem(el); + list.replaceChild(el, existing); + return; } - updateThemeIcon(isLightMode); + + currentFiltered = applyFilters(allHistory); + currentPage = 0; + renderHistoryList(); + flashItem(list.querySelector(`[data-id="${id}"]`)); } -/** - * Update theme icon based on current theme - */ -function updateThemeIcon(isLightMode: boolean) { - if (!themeIcon) return; - - if (isLightMode) { - // Moon icon for light mode (to switch to dark) - themeIcon.innerHTML = ` - - `; +function flashItem(el: HTMLElement | null): void { + if (!el) return; + el.classList.add("history-item--new"); + el.addEventListener("animationend", () => el.classList.remove("history-item--new"), { once: true }); +} + +async function loadHistory(): Promise { + const config = await loadSettings(); + + const historyEnabledCb = document.getElementById( + "history-enabled", + ) as HTMLInputElement; + historyEnabledCb.checked = config.historyEnabled; + + historyEnabledCb.addEventListener("change", onHistoryEnabledChange); + + // Wire up filter inputs + document + .getElementById("history-search") + ?.addEventListener("input", rerenderHistory); + document + .getElementById("filter-format") + ?.addEventListener("change", rerenderHistory); + document + .getElementById("filter-status") + ?.addEventListener("change", rerenderHistory); + document + .getElementById("filter-date") + ?.addEventListener("change", rerenderHistory); + + // Select all checkbox + document.getElementById("select-all")?.addEventListener("change", (e) => { + const checked = (e.target as HTMLInputElement).checked; + const visible = applyFilters(allHistory); + if (checked) { + visible.forEach((d) => selectedIds.add(d.id)); + } else { + selectedIds.clear(); + } + syncBulkBar(); + rerenderHistory(); + }); + + // Bulk delete + document.getElementById("bulk-delete")?.addEventListener("click", async () => { + if (selectedIds.size === 0) return; + await bulkDeleteDownloads([...selectedIds]); + allHistory = allHistory.filter((d) => !selectedIds.has(d.id)); + selectedIds.clear(); + syncBulkBar(); + rerenderHistory(); + }); + + if (!config.historyEnabled) { + showHistoryDisabled(); + return; + } + + registerHistoryListener(); + await fetchAndRenderHistory(); +} + +async function onHistoryEnabledChange(): Promise { + const cb = document.getElementById("history-enabled") as HTMLInputElement; + const enabled = cb.checked; + const config = (await ChromeStorage.get(STORAGE_CONFIG_KEY)) ?? {}; + config.historyEnabled = enabled; + await ChromeStorage.set(STORAGE_CONFIG_KEY, config); + + if (!enabled) { + unregisterHistoryListener(); + await clearAllDownloads(); + allHistory = []; + selectedIds.clear(); + showHistoryDisabled(); } else { - // Sun icon for dark mode (to switch to light) - themeIcon.innerHTML = ` - - - - - - - - - - `; + registerHistoryListener(); + await fetchAndRenderHistory(); + } +} + +async function fetchAndRenderHistory(): Promise { + const all = await getAllDownloads(); + allHistory = all.filter((d) => FINISHED_STAGES.has(d.progress.stage)); + rerenderHistory(); +} + +function rerenderHistory(): void { + currentFiltered = applyFilters(allHistory); + currentPage = 0; + renderHistoryList(); +} + +function applyFilters(all: DownloadState[]): DownloadState[] { + const searchEl = document.getElementById("history-search") as HTMLInputElement; + const formatEl = document.getElementById("filter-format") as HTMLSelectElement; + const statusEl = document.getElementById("filter-status") as HTMLSelectElement; + const dateEl = document.getElementById("filter-date") as HTMLSelectElement; + + const search = searchEl?.value.toLowerCase() ?? ""; + const format = formatEl?.value ?? "all"; + const status = statusEl?.value ?? "all"; + const date = dateEl?.value ?? "all"; + + return all + .filter( + (d) => + !search || + d.metadata.title?.toLowerCase().includes(search) || + d.url.toLowerCase().includes(search), + ) + .filter((d) => format === "all" || d.metadata.format === format) + .filter((d) => status === "all" || d.progress.stage === status) + .filter((d) => dateInRange(d.createdAt, date)) + .sort((a, b) => b.createdAt - a.createdAt); +} + +function dateInRange(ts: number, range: string): boolean { + if (range === "all") return true; + const now = Date.now(); + if (range === "today") return ts >= now - MS_PER_DAY; + if (range === "week") return ts >= now - 7 * MS_PER_DAY; + if (range === "month") return ts >= now - 30 * MS_PER_DAY; + return true; +} + +function renderHistoryList(): void { + const list = document.getElementById("history-list")!; + const emptyEl = document.getElementById("history-empty")!; + const disabledEl = document.getElementById("history-disabled")!; + + disconnectHistoryObserver(); + disabledEl.classList.remove("visible"); + list.innerHTML = ""; + + if (currentFiltered.length === 0) { + emptyEl.classList.add("visible"); + return; + } + + emptyEl.classList.remove("visible"); + currentFiltered.slice(0, PAGE_SIZE).forEach((s) => list.appendChild(renderHistoryItem(s))); + currentPage = 1; + + if (currentFiltered.length > PAGE_SIZE) { + setupHistorySentinel(); + } +} + +function appendHistoryBatch(): void { + const list = document.getElementById("history-list")!; + const start = currentPage * PAGE_SIZE; + const batch = currentFiltered.slice(start, start + PAGE_SIZE); + if (batch.length === 0) { disconnectHistoryObserver(); return; } + batch.forEach((s) => list.appendChild(renderHistoryItem(s))); + currentPage++; + if (currentPage * PAGE_SIZE >= currentFiltered.length) disconnectHistoryObserver(); +} + +function setupHistorySentinel(): void { + disconnectHistoryObserver(); + historySentinel = document.createElement("div"); + document.getElementById("history-list")!.after(historySentinel); + const root = document.querySelector(".content") ?? null; + historyObserver = new IntersectionObserver( + (entries) => { if (entries[0].isIntersecting) appendHistoryBatch(); }, + { root, rootMargin: "120px" }, + ); + historyObserver.observe(historySentinel); +} + +function disconnectHistoryObserver(): void { + historyObserver?.disconnect(); + historyObserver = null; + historySentinel?.remove(); + historySentinel = null; +} + +function showHistoryDisabled(): void { + disconnectHistoryObserver(); + const list = document.getElementById("history-list")!; + const emptyEl = document.getElementById("history-empty")!; + const disabledEl = document.getElementById("history-disabled")!; + list.innerHTML = ""; + emptyEl.classList.remove("visible"); + disabledEl.classList.add("visible"); +} + +function renderHistoryItem(state: DownloadState): HTMLElement { + const item = document.createElement("div"); + item.className = "history-item" + (selectedIds.has(state.id) ? " selected" : ""); + item.dataset.id = state.id; + + // Checkbox + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.className = "history-item-checkbox"; + cb.checked = selectedIds.has(state.id); + cb.addEventListener("change", () => { + if (cb.checked) selectedIds.add(state.id); + else selectedIds.delete(state.id); + item.classList.toggle("selected", cb.checked); + syncBulkBar(); + }); + item.appendChild(cb); + + // Thumbnail + const thumbEl = createThumb(state.metadata.thumbnail); + item.appendChild(thumbEl); + + // Info column + const info = document.createElement("div"); + info.className = "history-info"; + + const title = document.createElement("div"); + title.className = "history-title"; + title.title = state.metadata.title || state.url; + title.textContent = state.metadata.title || truncateUrl(state.url); + info.appendChild(title); + + const url = document.createElement("div"); + url.className = "history-url"; + url.title = state.url; + url.textContent = state.url; + info.appendChild(url); + + const badges = document.createElement("div"); + badges.className = "history-badges"; + badges.appendChild(makeBadge(state.metadata.format, "badge-format")); + if (state.metadata.isLive) badges.appendChild(makeBadge("live", "badge-live")); + if (state.metadata.resolution || state.metadata.quality) { + badges.appendChild( + makeBadge((state.metadata.resolution || state.metadata.quality)!, "badge-resolution"), + ); + } + badges.appendChild(makeStageBadge(state.progress.stage)); + info.appendChild(badges); + + item.appendChild(info); + + // Actions column + const actions = document.createElement("div"); + actions.className = "history-actions"; + + const date = document.createElement("span"); + date.className = "history-date"; + date.title = new Date(state.createdAt).toLocaleString(); + date.textContent = relativeTime(state.createdAt); + actions.appendChild(date); + + // Actions menu + const menuWrap = document.createElement("div"); + menuWrap.className = "history-menu-wrap"; + + const menuBtn = document.createElement("button"); + menuBtn.className = "history-menu-btn"; + menuBtn.title = "Actions"; + menuBtn.textContent = "···"; + + const menu = document.createElement("div"); + menu.className = "history-menu"; + + function closeMenu(): void { + menu.classList.remove("open"); + document.removeEventListener("click", onOutsideClick); + } + + function onOutsideClick(e: MouseEvent): void { + if (!menuWrap.contains(e.target as Node)) closeMenu(); + } + + menuBtn.addEventListener("click", (e) => { + e.stopPropagation(); + const isOpen = menu.classList.contains("open"); + // Close any other open menus + document.querySelectorAll(".history-menu.open").forEach((m) => m.classList.remove("open")); + if (!isOpen) { + menu.classList.add("open"); + document.addEventListener("click", onOutsideClick); + } + }); + + function makeMenuItem(svgContent: string, label: string, onClick: () => void, extraClass?: string): HTMLButtonElement { + const btn = document.createElement("button"); + btn.className = "history-menu-item" + (extraClass ? ` ${extraClass}` : ""); + btn.innerHTML = `${svgContent}${label}`; + btn.addEventListener("click", () => { closeMenu(); onClick(); }); + return btn; + } + + // Open file (completed with known path only) + if (state.progress.stage === DownloadStage.COMPLETED && state.localPath) { + const localPath = state.localPath; + menu.appendChild(makeMenuItem(iconFolder(), "Open file", async () => { + const filename = localPath.split(/[/\\]/).pop(); + if (!filename) return; + const results = await new Promise((resolve) => + chrome.downloads.search({ filenameRegex: filename }, resolve), + ); + if (results.length > 0) { + chrome.downloads.show(results[0].id); + } else { + chrome.downloads.showDefaultFolder(); + } + })); + } + + menu.appendChild(makeMenuItem(iconDownload(), "Re-download", () => redownload(state.url, state.metadata))); + menu.appendChild(makeMenuItem(iconCopy(), "Copy URL", async () => { + await navigator.clipboard.writeText(state.url); + showToast("URL copied to clipboard", "success"); + })); + + menu.appendChild(makeMenuItem(iconLink(), "Check manifest", () => checkManifest(state.url))); + + menu.appendChild(makeMenuItem(iconTrash(), "Delete", async () => { + await deleteDownload(state.id); + allHistory = allHistory.filter((d) => d.id !== state.id); + selectedIds.delete(state.id); + syncBulkBar(); + rerenderHistory(); + }, "danger")); + + menuWrap.appendChild(menuBtn); + menuWrap.appendChild(menu); + actions.appendChild(menuWrap); + + item.appendChild(actions); + + return item; +} + +function createThumb(url?: string): HTMLElement { + if (url) { + const wrapper = document.createElement("div"); + wrapper.className = "history-thumb"; + const img = document.createElement("img"); + img.src = url; + img.alt = ""; + img.addEventListener("error", () => { + wrapper.outerHTML = thumbPlaceholder().outerHTML; + }); + wrapper.appendChild(img); + return wrapper; + } + return thumbPlaceholder(); +} + +function thumbPlaceholder(): HTMLElement { + const el = document.createElement("div"); + el.className = "history-thumb-placeholder"; + el.innerHTML = ``; + return el; +} + +function makeBadge(text: string, cls: string): HTMLElement { + const b = document.createElement("span"); + b.className = `badge ${cls}`; + b.textContent = text.toUpperCase(); + return b; +} + +function makeStageBadge(stage: DownloadStage): HTMLElement { + const map: Record = { + [DownloadStage.COMPLETED]: "badge-completed", + [DownloadStage.FAILED]: "badge-failed", + [DownloadStage.CANCELLED]: "badge-cancelled", + }; + return makeBadge(stage, map[stage] ?? ""); +} + + +function syncBulkBar(): void { + const bar = document.getElementById("bulk-bar")!; + const count = document.getElementById("bulk-count")!; + const selectAll = document.getElementById("select-all") as HTMLInputElement; + + bar.classList.toggle("visible", selectedIds.size > 0); + count.textContent = `${selectedIds.size} selected`; + + const visible = applyFilters(allHistory); + selectAll.checked = visible.length > 0 && visible.every((d) => selectedIds.has(d.id)); + selectAll.indeterminate = + !selectAll.checked && visible.some((d) => selectedIds.has(d.id)); +} + +async function redownload(url: string, metadata?: VideoMetadata): Promise { + const resolvedMetadata: VideoMetadata = metadata ?? { url, format: "unknown" as any, pageUrl: url }; + const isLive = resolvedMetadata.isLive === true; + + let website: string | undefined; + try { + website = new URL(resolvedMetadata.pageUrl ?? url).hostname.replace(/^www\./, ""); + } catch {} + + try { + const response = await chrome.runtime.sendMessage({ + type: isLive ? MessageType.START_RECORDING : MessageType.DOWNLOAD_REQUEST, + payload: { url, metadata: resolvedMetadata, tabTitle: metadata?.title, website }, + }); + if (response?.error) return showToast(response.error, "error"); + showToast(isLive ? "Recording started" : "Download queued", "success"); + await fetchAndRenderHistory(); + } catch { + showToast("Failed to start download", "error"); } } +let toastTimer: ReturnType | null = null; + +function showToast(message: string, type: "success" | "error" | "warning"): void { + const toast = document.getElementById("toast"); + if (!toast) return; + + const icons: Record = { + success: iconCheck(), + error: iconX(), + warning: iconQuestion(), + }; + + toast.className = type; + toast.innerHTML = `${icons[type]}${message}`; + + if (toastTimer) clearTimeout(toastTimer); + // Force reflow so the transition triggers even when re-showing + toast.classList.remove("show"); + void toast.offsetWidth; + toast.classList.add("show"); + toastTimer = setTimeout(() => toast.classList.remove("show"), TOAST_DURATION_MS); +} + +async function checkManifest(url: string): Promise { + showToast("Checking…", "warning"); + + const result = await chrome.runtime.sendMessage({ + type: MessageType.CHECK_URL, + payload: { url }, + }); + + if (!result || result.status === 0) { + showToast("Manifest unreachable or CORS blocked", "warning"); + } else if (result.ok) { + showToast("Manifest is live", "success"); + } else { + showToast(`Manifest returned ${result.status}`, "error"); + } +} + +// ───────────────────────────────────────────── +// Section: Utilities +// ───────────────────────────────────────────── + +function showStatus(message: string, type: "success" | "error" | "warning" | "info"): void { + showToast(message, type === "info" ? "warning" : type); +} + +// ───────────────────────────────────────────── +// Section: Field Validation +// ───────────────────────────────────────────── + /** - * Toggle theme between light and dark + * Validate a numeric input against [min, max]. + * Marks the field invalid and shows an inline error if out of range or not a number. + * Returns the parsed value on success, null on failure. */ -async function toggleTheme() { - const root = document.documentElement; - const isLightMode = root.classList.contains("light-mode"); - const newTheme = isLightMode ? "dark" : "light"; +function validateField( + input: HTMLInputElement, + min: number, + max: number, + isInteger = false, +): number | null { + const raw = input.value.trim(); + const val = isInteger ? parseInt(raw, 10) : parseFloat(raw); + + if (isNaN(val) || val < min || val > max) { + const lo = isInteger ? min : min; + const hi = isInteger ? max : max; + markInvalid( + input, + isNaN(val) + ? "Must be a number" + : `Must be between ${lo} and ${hi}`, + ); + return null; + } + + clearInvalid(input); + return val; +} + +function markInvalid(input: HTMLInputElement, message: string): void { + input.classList.add("invalid"); + + // Remove any existing error for this field + const next = input.nextElementSibling; + if (next?.classList.contains("form-error")) next.remove(); + + const err = document.createElement("div"); + err.className = "form-error"; + err.textContent = message; + input.insertAdjacentElement("afterend", err); + + // Clear on next user edit + input.addEventListener( + "input", + () => clearInvalid(input), + { once: true }, + ); +} + +function clearInvalid(input: HTMLInputElement): void { + input.classList.remove("invalid"); + const next = input.nextElementSibling; + if (next?.classList.contains("form-error")) next.remove(); +} + +function errorMsg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} - await ChromeStorage.set("theme", newTheme); - applyTheme(!isLightMode); +function truncateUrl(url: string, max = 60): string { + try { + const u = new URL(url); + const host = u.hostname + u.pathname; + return host.length > max ? host.slice(0, max) + "…" : host; + } catch { + return url.length > max ? url.slice(0, max) + "…" : url; + } +} + +function relativeTime(ts: number): string { + const diff = Date.now() - ts; + if (diff < 60_000) return "just now"; + if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < MS_PER_DAY) return `${Math.floor(diff / 3_600_000)}h ago`; + return `${Math.floor(diff / MS_PER_DAY)}d ago`; +} + +// ───────────────────────────────────────────── +// SVG icon snippets (inner SVG path strings) +// ───────────────────────────────────────────── + +function iconFolder(): string { + return ``; +} + +function iconDownload(): string { + return ``; +} + +function iconCopy(): string { + return ``; +} + +function iconLink(): string { + return ``; +} + +function iconTrash(): string { + return ``; +} + +function iconCheck(): string { + return ``; +} + +function iconX(): string { + return ``; +} + +function iconQuestion(): string { + return ``; +} + + +// ───────────────────────────────────────────── +// Section: Recording View +// ───────────────────────────────────────────── + +async function loadRecordingSettings(): Promise { + const { recording } = await loadSettings(); + + const get = (id: string) => document.getElementById(id) as HTMLInputElement; + + get("poll-min").value = (recording.minPollIntervalMs / 1000).toString(); + get("poll-max").value = (recording.maxPollIntervalMs / 1000).toString(); + get("poll-fraction").value = recording.pollFraction.toString(); + + document + .getElementById("save-recording-settings") + ?.addEventListener("click", saveRecordingSettings); +} + +async function saveRecordingSettings(): Promise { + const btn = document.getElementById("save-recording-settings") as HTMLButtonElement; + const get = (id: string) => document.getElementById(id) as HTMLInputElement; + + const pollMinS = validateField(get("poll-min"), MIN_POLL_MIN_S, MAX_POLL_MIN_S); + const pollMaxS = validateField(get("poll-max"), MIN_POLL_MAX_S, MAX_POLL_MAX_S); + const pollFraction = validateField(get("poll-fraction"), MIN_POLL_FRACTION, MAX_POLL_FRACTION); + if (pollMinS === null || pollMaxS === null || pollFraction === null) return; + + if (pollMinS >= pollMaxS) { + markInvalid(get("poll-min"), "Must be less than the maximum poll interval"); + return; + } + + btn.disabled = true; + btn.textContent = "Saving…"; + + try { + const config = (await ChromeStorage.get(STORAGE_CONFIG_KEY)) ?? {}; + config.recording = { + minPollIntervalMs: pollMinS * 1000, + maxPollIntervalMs: pollMaxS * 1000, + pollFraction, + }; + await ChromeStorage.set(STORAGE_CONFIG_KEY, config); + showStatus("Settings saved.", "success"); + } catch (err) { + showStatus(`Save failed: ${errorMsg(err)}`, "error"); + } finally { + btn.disabled = false; + btn.textContent = "Save Settings"; + } +} + +// ───────────────────────────────────────────── +// Section: Notifications View +// ───────────────────────────────────────────── + +async function loadNotificationSettings(): Promise { + const { notifications } = await loadSettings(); + + const notifyCb = document.getElementById("notify-on-completion") as HTMLInputElement; + const autoOpenCb = document.getElementById("auto-open-file") as HTMLInputElement; + + notifyCb.checked = notifications.notifyOnCompletion; + autoOpenCb.checked = notifications.autoOpenFile; + + document + .getElementById("save-notification-settings") + ?.addEventListener("click", saveNotificationSettings); } -// Initialize when DOM is ready +async function saveNotificationSettings(): Promise { + const btn = document.getElementById("save-notification-settings") as HTMLButtonElement; + btn.disabled = true; + btn.textContent = "Saving…"; + + try { + const notifyCb = document.getElementById("notify-on-completion") as HTMLInputElement; + const autoOpenCb = document.getElementById("auto-open-file") as HTMLInputElement; + + const config = (await ChromeStorage.get(STORAGE_CONFIG_KEY)) ?? {}; + config.notifications = { + notifyOnCompletion: notifyCb.checked, + autoOpenFile: autoOpenCb.checked, + }; + await ChromeStorage.set(STORAGE_CONFIG_KEY, config); + showStatus("Settings saved.", "success"); + } catch (err) { + showStatus(`Save failed: ${errorMsg(err)}`, "error"); + } finally { + btn.disabled = false; + btn.textContent = "Save Settings"; + } +} + +// ───────────────────────────────────────────── +// Section: Advanced View +// ───────────────────────────────────────────── + +async function loadAdvancedSettings(): Promise { + const { advanced } = await loadSettings(); + + const get = (id: string) => document.getElementById(id) as HTMLInputElement; + + get("max-retries").value = advanced.maxRetries.toString(); + get("retry-delay").value = (advanced.retryDelayMs / 1000).toString(); + get("retry-backoff").value = advanced.retryBackoffFactor.toString(); + get("failure-rate").value = Math.round(advanced.fragmentFailureRate * 100).toString(); + get("detection-cache-size").value = advanced.detectionCacheSize.toString(); + get("master-playlist-cache-size").value = advanced.masterPlaylistCacheSize.toString(); + get("db-sync-interval").value = (advanced.dbSyncIntervalMs / 1000).toString(); + + document + .getElementById("save-advanced-settings") + ?.addEventListener("click", saveAdvancedSettings); + document + .getElementById("reset-advanced-settings") + ?.addEventListener("click", resetAdvancedSettings); +} + +async function saveAdvancedSettings(): Promise { + const btn = document.getElementById("save-advanced-settings") as HTMLButtonElement; + const get = (id: string) => document.getElementById(id) as HTMLInputElement; + + const maxRetries = validateField(get("max-retries"), MIN_MAX_RETRIES, MAX_MAX_RETRIES, true); + const retryDelayS = validateField(get("retry-delay"), MIN_RETRY_DELAY_S, MAX_RETRY_DELAY_S); + const retryBackoff = validateField(get("retry-backoff"), MIN_RETRY_BACKOFF_FACTOR, MAX_RETRY_BACKOFF_FACTOR); + const failureRatePct = validateField(get("failure-rate"), MIN_FAILURE_RATE * 100, MAX_FAILURE_RATE * 100, true); + const detectionCache = validateField(get("detection-cache-size"), MIN_DETECTION_CACHE_SIZE, MAX_DETECTION_CACHE_SIZE, true); + const masterCache = validateField(get("master-playlist-cache-size"),MIN_MASTER_PLAYLIST_CACHE_SIZE,MAX_MASTER_PLAYLIST_CACHE_SIZE, true); + const dbSyncS = validateField(get("db-sync-interval"), MIN_DB_SYNC_S, MAX_DB_SYNC_S); + + if ( + maxRetries === null || retryDelayS === null || retryBackoff === null || + failureRatePct === null || detectionCache === null || masterCache === null || dbSyncS === null + ) return; + + btn.disabled = true; + btn.textContent = "Saving…"; + + try { + const config = (await ChromeStorage.get(STORAGE_CONFIG_KEY)) ?? {}; + config.advanced = { + maxRetries, + retryDelayMs: retryDelayS * 1000, + retryBackoffFactor: retryBackoff, + fragmentFailureRate: failureRatePct / 100, + detectionCacheSize: detectionCache, + masterPlaylistCacheSize: masterCache, + dbSyncIntervalMs: dbSyncS * 1000, + }; + await ChromeStorage.set(STORAGE_CONFIG_KEY, config); + showStatus("Settings saved.", "success"); + } catch (err) { + showStatus(`Save failed: ${errorMsg(err)}`, "error"); + } finally { + btn.disabled = false; + btn.textContent = "Save Settings"; + } +} + +async function resetAdvancedSettings(): Promise { + const btn = document.getElementById("reset-advanced-settings") as HTMLButtonElement; + btn.disabled = true; + + try { + const config = (await ChromeStorage.get(STORAGE_CONFIG_KEY)) ?? {}; + delete config.advanced; + await ChromeStorage.set(STORAGE_CONFIG_KEY, config); + + // Re-render inputs with defaults + const get = (id: string) => document.getElementById(id) as HTMLInputElement; + get("max-retries").value = DEFAULT_MAX_RETRIES.toString(); + get("retry-delay").value = (INITIAL_RETRY_DELAY_MS / 1000).toString(); + get("retry-backoff").value = RETRY_BACKOFF_FACTOR.toString(); + get("failure-rate").value = Math.round(MAX_FRAGMENT_FAILURE_RATE * 100).toString(); + get("detection-cache-size").value = DEFAULT_DETECTION_CACHE_SIZE.toString(); + get("master-playlist-cache-size").value = DEFAULT_MASTER_PLAYLIST_CACHE_SIZE.toString(); + get("db-sync-interval").value = (DEFAULT_DB_SYNC_INTERVAL_MS / 1000).toString(); + + showStatus("Reset to defaults.", "success"); + } catch (err) { + showStatus(`Reset failed: ${errorMsg(err)}`, "error"); + } finally { + btn.disabled = false; + } +} + +// ───────────────────────────────────────────── +// Bootstrap +// ───────────────────────────────────────────── + if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { diff --git a/src/popup/popup.html b/src/popup/popup.html index 1cb10b3..a5cddd8 100644 --- a/src/popup/popup.html +++ b/src/popup/popup.html @@ -4,96 +4,12 @@ Media Bridge + @@ -930,6 +790,12 @@
Media Bridge
+ - -
- `; - } else if (isFailed) { - actionButtons = ` -
- - -
- `; - } else if (isRecording) { + if (isRecording) { actionButtons = `
@@ -347,14 +329,8 @@ export function renderDownloads(forceFullRebuild = false): void { d.progress.stage !== DownloadStage.FAILED && d.progress.stage !== DownloadStage.CANCELLED, ); - const completed = downloadStates.filter( - (d) => d.progress.stage === DownloadStage.COMPLETED, - ); - const failed = downloadStates.filter( - (d) => d.progress.stage === DownloadStage.FAILED || d.progress.stage === DownloadStage.CANCELLED, - ); - if (inProgress.length === 0 && completed.length === 0 && failed.length === 0) { + if (inProgress.length === 0) { downloadsList.innerHTML = `
@@ -394,11 +370,8 @@ export function renderDownloads(forceFullRebuild = false): void { downloadsList.innerHTML = ""; renderedDownloadCards.clear(); - const hasTerminal = completed.length > 0 || failed.length > 0; const sections: Array<{ label: string; items: DownloadState[]; showClear: boolean }> = []; if (inProgress.length > 0) sections.push({ label: "In Progress", items: inProgress, showClear: false }); - if (completed.length > 0) sections.push({ label: "Completed", items: completed, showClear: hasTerminal }); - if (failed.length > 0) sections.push({ label: "Failed", items: failed, showClear: hasTerminal && completed.length === 0 }); for (const section of sections) { const sectionEl = createSectionHeader(section.label, section.items.length, section.showClear); diff --git a/src/popup/state.ts b/src/popup/state.ts index 44acab8..8603394 100644 --- a/src/popup/state.ts +++ b/src/popup/state.ts @@ -31,6 +31,7 @@ export const dom = { closeNoVideoNoticeBtn: null as HTMLButtonElement | null, noVideoNotice: null as HTMLDivElement | null, settingsBtn: null as HTMLButtonElement | null, + historyBtn: null as HTMLButtonElement | null, autoDetectTab: null as HTMLButtonElement | null, manifestTab: null as HTMLButtonElement | null, downloadsTab: null as HTMLButtonElement | null, diff --git a/src/service-worker.ts b/src/service-worker.ts index 4f6d1f7..c866b45 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -14,6 +14,7 @@ import { deleteDownload, } from "./core/database/downloads"; import { ChromeStorage } from "./core/storage/chrome-storage"; +import { loadSettings } from "./core/storage/settings"; import { MessageType } from "./shared/messages"; import { DownloadState, @@ -34,14 +35,18 @@ import { generateFilenameWithExtension, generateFilenameFromTabInfo, } from "./core/utils/file-utils"; -import { deleteChunks, getChunkCount, getAllChunkDownloadIds } from "./core/database/chunks"; -import { createOffscreenDocument, closeOffscreenDocument } from "./core/ffmpeg/offscreen-manager"; import { - DEFAULT_MAX_CONCURRENT, - DEFAULT_FFMPEG_TIMEOUT_MS, + deleteChunks, + getChunkCount, + getAllChunkDownloadIds, +} from "./core/database/chunks"; +import { + createOffscreenDocument, + closeOffscreenDocument, +} from "./core/ffmpeg/offscreen-manager"; +import { KEEPALIVE_INTERVAL_MS, STORAGE_CONFIG_KEY, - MAX_CONCURRENT_KEY, } from "./shared/constants"; const activeDownloads = new Map>(); @@ -54,19 +59,25 @@ const savePartialDownloads = new Set(); * Keep-alive heartbeat mechanism to prevent service worker termination * Calls chrome.runtime.getPlatformInfo() every 20 seconds to keep worker alive * during long-running operations like downloads and FFmpeg processing - * + * * Source: https://stackoverflow.com/a/66618269 */ -const keepAlive = ((i?: ReturnType | 0) => (state: boolean) => { - if (state && !i) { - // If service worker has been running for more than 20 seconds, call immediately - if (performance.now() > KEEPALIVE_INTERVAL_MS) chrome.runtime.getPlatformInfo(); - i = setInterval(() => chrome.runtime.getPlatformInfo(), KEEPALIVE_INTERVAL_MS); - } else if (!state && i) { - clearInterval(i); - i = 0; +const keepAlive = ( + (i?: ReturnType | 0) => (state: boolean) => { + if (state && !i) { + // If service worker has been running for more than 20 seconds, call immediately + if (performance.now() > KEEPALIVE_INTERVAL_MS) + chrome.runtime.getPlatformInfo(); + i = setInterval( + () => chrome.runtime.getPlatformInfo(), + KEEPALIVE_INTERVAL_MS, + ); + } else if (!state && i) { + clearInterval(i); + i = 0; + } } -})(); +)(); /** * Initialize service worker @@ -82,7 +93,7 @@ async function init() { } /** - * Remove chunks whose download no longer exists or is in a terminal state. + * Remove chunks whose download no longer exists or is in a finished state. * Runs once at startup to reclaim IndexedDB storage from crashed downloads. */ async function cleanupStaleChunks(): Promise { @@ -138,16 +149,11 @@ async function handleDownloadRequestMessage(payload: { }): Promise<{ success: boolean; error?: string }> { try { const downloadResult = await handleDownloadRequest(payload); - if (downloadResult && downloadResult.error) { - return { success: false, error: downloadResult.error }; - } + if (downloadResult?.error) return { success: false, error: downloadResult.error }; return { success: true }; } catch (error) { logger.error("Download request error:", error); - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; + return { success: false, error: error instanceof Error ? error.message : String(error) }; } } @@ -391,6 +397,25 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { handleStopAndSaveMessage(message.payload).then(sendResponse); return true; + case MessageType.CHECK_URL: { + const checkUrl = async () => { + try { + const format = detectFormatFromUrl(message.payload.url); + const isManifest = + format === VideoFormat.DASH || format === VideoFormat.HLS; + const res = await fetch(message.payload.url, { + method: isManifest ? "GET" : "HEAD", + signal: AbortSignal.timeout(5000), + }); + return { ok: res.ok, status: res.status }; + } catch { + return { ok: false, status: 0 }; + } + }; + checkUrl().then(sendResponse); + return true; + } + case MessageType.OFFSCREEN_PROCESS_HLS_RESPONSE: case MessageType.OFFSCREEN_PROCESS_M3U8_RESPONSE: case MessageType.OFFSCREEN_PROCESS_DASH_RESPONSE: @@ -504,15 +529,19 @@ async function handleDownloadRequest(payload: { }; } - const config = await ChromeStorage.get(STORAGE_CONFIG_KEY); - const maxConcurrent = - (await ChromeStorage.get(MAX_CONCURRENT_KEY)) || DEFAULT_MAX_CONCURRENT; - // FFmpeg timeout is stored in milliseconds (converted from minutes in settings UI) - const ffmpegTimeout = config?.ffmpegTimeout || DEFAULT_FFMPEG_TIMEOUT_MS; + const config = await loadSettings(); const downloadManager = new DownloadManager({ - maxConcurrent, - ffmpegTimeout, + maxConcurrent: config.maxConcurrent, + ffmpegTimeout: config.ffmpegTimeout, + maxRetries: config.advanced.maxRetries, + retryDelayMs: config.advanced.retryDelayMs, + retryBackoffFactor: config.advanced.retryBackoffFactor, + fragmentFailureRate: config.advanced.fragmentFailureRate, + dbSyncIntervalMs: config.advanced.dbSyncIntervalMs, + minPollIntervalMs: config.recording.minPollIntervalMs, + maxPollIntervalMs: config.recording.maxPollIntervalMs, + pollFraction: config.recording.pollFraction, shouldSaveOnCancel: () => savePartialDownloads.has(normalizedUrl), onProgress: async (state) => { // Use the pre-normalized URL from the outer scope instead of re-normalizing @@ -521,7 +550,9 @@ async function handleDownloadRequest(payload: { // Get abort controller and store signal reference ONCE to avoid stale reference issues const controller = downloadAbortControllers.get(normalizedUrlForProgress); // Allow progress updates through if we're in stop-and-save mode (partial save in progress) - const isSavingPartial = savePartialDownloads.has(normalizedUrlForProgress); + const isSavingPartial = savePartialDownloads.has( + normalizedUrlForProgress, + ); // If no controller exists (already cleaned up) or signal is aborted, skip update // BUT allow updates through if we're saving a partial download @@ -586,9 +617,15 @@ async function handleDownloadRequest(payload: { if (activeDownloads.size === 1) { keepAlive(true); // Pre-warm FFmpeg for HLS/M3U8/DASH downloads while segments download - if (metadata.format === VideoFormat.HLS || metadata.format === VideoFormat.M3U8 || metadata.format === VideoFormat.DASH) { + if ( + metadata.format === VideoFormat.HLS || + metadata.format === VideoFormat.M3U8 || + metadata.format === VideoFormat.DASH + ) { createOffscreenDocument() - .then(() => chrome.runtime.sendMessage({ type: MessageType.WARMUP_FFMPEG })) + .then(() => + chrome.runtime.sendMessage({ type: MessageType.WARMUP_FFMPEG }), + ) .catch((err) => logger.error("FFmpeg pre-warm failed:", err)); } } @@ -596,6 +633,11 @@ async function handleDownloadRequest(payload: { downloadPromise .then(async () => { await cleanupDownloadResources(normalizedUrl); + const cfg = await loadSettings(); + if (!cfg.historyEnabled) { + const completed = await getDownloadByUrl(normalizedUrl); + if (completed) await deleteDownload(completed.id); + } }) .catch(async (error: unknown) => { // Only log error if not cancelled @@ -609,12 +651,17 @@ async function handleDownloadRequest(payload: { logger.error(`Download failed for ${url}:`, String(error)); } await cleanupDownloadResources(normalizedUrl); + const cfg = await loadSettings(); + if (!cfg.historyEnabled) { + const failed = await getDownloadByUrl(normalizedUrl); + if (failed) await deleteDownload(failed.id); + } }) .finally(() => { // Ensure promise is removed from activeDownloads when it completes // This handles both success and failure cases activeDownloads.delete(normalizedUrl); - + // Stop keep-alive if no more active downloads if (activeDownloads.size === 0) { keepAlive(false); @@ -645,6 +692,36 @@ function sendDownloadComplete(downloadId: string): void { } catch (error) { // Ignore errors - popup/content script might not be listening } + // Fire post-download actions (notifications, auto-open) if configured + handlePostDownloadActions(downloadId); +} + +async function handlePostDownloadActions(downloadId: string): Promise { + try { + const { notifications } = await loadSettings(); + if (!notifications.notifyOnCompletion && !notifications.autoOpenFile) return; + + const state = await getDownload(downloadId); + if (!state) return; + + const title = state.metadata.title || "Media Bridge"; + const filename = state.localPath?.split(/[/\\]/).pop() ?? "Download"; + + if (notifications.notifyOnCompletion) { + chrome.notifications.create(`download-complete-${downloadId}`, { + type: "basic", + iconUrl: "icons/icon-48.png", + title: "Download complete", + message: `${title}\n${filename}`, + }); + } + + if (notifications.autoOpenFile && state.chromeDownloadId != null) { + chrome.downloads.show(state.chromeDownloadId); + } + } catch (err) { + logger.warn("handlePostDownloadActions failed:", err); + } } /** @@ -718,6 +795,25 @@ async function cancelChromeDownloads(download: DownloadState): Promise { }); } +function resolveFilename( + url: string, + metadata: VideoMetadata, + filename?: string, + tabTitle?: string, + website?: string, +): string { + if (filename) return filename; + const extension = + metadata.format === VideoFormat.HLS || + metadata.format === VideoFormat.M3U8 || + metadata.format === VideoFormat.DASH + ? "mp4" + : metadata.fileExtension || "mp4"; + return tabTitle || website + ? generateFilenameFromTabInfo(tabTitle, website, extension) + : generateFilenameWithExtension(url, extension); +} + /** * Execute download using download manager * Sends completion or failure notifications to popup @@ -738,27 +834,7 @@ async function startDownload( abortSignal?: AbortSignal, ): Promise { try { - // Generate filename if not provided - let finalFilename = filename; - if (!finalFilename) { - // HLS/M3U8/DASH formats always produce MP4 after FFmpeg processing - const extension = - metadata.format === VideoFormat.HLS || - metadata.format === VideoFormat.M3U8 || - metadata.format === VideoFormat.DASH - ? "mp4" - : metadata.fileExtension || "mp4"; - // Use tab info if available, otherwise fall back to URL-based generation - if (tabTitle || website) { - finalFilename = generateFilenameFromTabInfo( - tabTitle, - website, - extension, - ); - } else { - finalFilename = generateFilenameWithExtension(url, extension); - } - } + const finalFilename = resolveFilename(url, metadata, filename, tabTitle, website); const downloadState = await downloadManager.download( url, @@ -798,7 +874,10 @@ async function handleStopAndSaveMessage(payload: { const normalizedUrl = normalizeUrl(payload.url); const controller = downloadAbortControllers.get(normalizedUrl); if (!controller) { - return { success: false, error: "No active download found for this URL." }; + return { + success: false, + error: "No active download found for this URL.", + }; } savePartialDownloads.add(normalizedUrl); controller.abort(); @@ -889,125 +968,176 @@ async function handleStartRecordingMessage(payload: { selectedBandwidth?: number; }): Promise<{ success: boolean; error?: string }> { try { - const { url, metadata, filename, tabTitle, website } = payload; - const normalizedUrl = normalizeUrl(url); + return await handleStartRecording(payload); + } catch (error) { + logger.error("Start recording error:", error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +} - if (activeDownloads.has(normalizedUrl)) { - return { success: false, error: "Recording already in progress for this URL." }; - } +/** + * Start live HLS/DASH recording (core logic) + */ +async function handleStartRecording(payload: { + url: string; + metadata: VideoMetadata; + filename?: string; + tabTitle?: string; + website?: string; + selectedBandwidth?: number; +}): Promise<{ success: boolean; error?: string }> { + const { url, metadata, filename, tabTitle, website } = payload; + const normalizedUrl = normalizeUrl(url); - const config = await ChromeStorage.get(STORAGE_CONFIG_KEY); - const maxConcurrent = - (await ChromeStorage.get(MAX_CONCURRENT_KEY)) || DEFAULT_MAX_CONCURRENT; - const ffmpegTimeout = config?.ffmpegTimeout || DEFAULT_FFMPEG_TIMEOUT_MS; - - // Build initial download state - const stateId = generateDownloadId(normalizedUrl); - const initialState: DownloadState = { - id: stateId, - url, - metadata, - progress: { - url, - stage: DownloadStage.RECORDING, - segmentsCollected: 0, - message: "Recording...", - }, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - await storeDownload(initialState); - - const abortController = new AbortController(); - downloadAbortControllers.set(normalizedUrl, abortController); - - const onProgress = async (state: DownloadState) => { - const controller = downloadAbortControllers.get(normalizeUrl(state.url)); - // Allow progress updates through when in post-recording stages (MERGING, SAVING, COMPLETED) - // since the abort signal is used to stop the recording loop, not to cancel the merge - const isPostRecording = - state.progress.stage === DownloadStage.MERGING || - state.progress.stage === DownloadStage.SAVING || - state.progress.stage === DownloadStage.COMPLETED; - if (!isPostRecording && (!controller || controller.signal.aborted)) return; - await storeDownload(state); - try { - chrome.runtime.sendMessage( - { - type: MessageType.DOWNLOAD_PROGRESS, - payload: { id: state.id, progress: state.progress }, - }, - () => { if (chrome.runtime.lastError) {} }, - ); - } catch (_) {} + // Clean up finished entry so re-recording starts fresh + const existing = await getDownloadByUrl(normalizedUrl); + if ( + existing && + (existing.progress.stage === DownloadStage.COMPLETED || + existing.progress.stage === DownloadStage.FAILED || + existing.progress.stage === DownloadStage.CANCELLED) + ) { + await deleteDownload(existing.id); + } + + if (activeDownloads.has(normalizedUrl)) { + return { + success: false, + error: "Recording already in progress for this URL.", }; + } - const handler = - metadata.format === VideoFormat.DASH - ? new DashRecordingHandler({ onProgress, maxConcurrent, ffmpegTimeout, selectedBandwidth: payload.selectedBandwidth }) - : new HlsRecordingHandler({ onProgress, maxConcurrent, ffmpegTimeout }); - - // Resolve filename - let finalFilename = filename; - if (!finalFilename) { - // HLS/M3U8/DASH formats always produce MP4 after FFmpeg processing - const extension = - metadata.format === VideoFormat.HLS || - metadata.format === VideoFormat.M3U8 || - metadata.format === VideoFormat.DASH - ? "mp4" - : metadata.fileExtension || "mp4"; - if (tabTitle || website) { - finalFilename = generateFilenameFromTabInfo(tabTitle, website, extension); - } else { - finalFilename = generateFilenameWithExtension(url, extension); - } - } + const config = await loadSettings(); + const recordingHandlerOptions = { + maxConcurrent: config.maxConcurrent, + ffmpegTimeout: config.ffmpegTimeout, + maxRetries: config.advanced.maxRetries, + retryDelayMs: config.advanced.retryDelayMs, + retryBackoffFactor: config.advanced.retryBackoffFactor, + fragmentFailureRate: config.advanced.fragmentFailureRate, + dbSyncIntervalMs: config.advanced.dbSyncIntervalMs, + minPollIntervalMs: config.recording.minPollIntervalMs, + maxPollIntervalMs: config.recording.maxPollIntervalMs, + pollFraction: config.recording.pollFraction, + }; - const recordingPromise = handler - .record(url, finalFilename, stateId, abortController.signal, metadata.pageUrl) - .then(async () => { - await cleanupDownloadResources(normalizedUrl); - sendDownloadComplete(stateId); - }) - .catch(async (error: unknown) => { - // Persist FAILED state to IndexedDB so the downloads tab reflects the real status - const failedState = await getDownload(stateId); - if (failedState && failedState.progress.stage !== DownloadStage.COMPLETED) { - failedState.progress.stage = DownloadStage.FAILED; - failedState.progress.message = - error instanceof Error ? error.message : String(error); - failedState.updatedAt = Date.now(); - await storeDownload(failedState); - } - if (!(error instanceof CancellationError)) { - logger.error(`Recording failed for ${url}:`, error); - sendDownloadFailed(url, error instanceof Error ? error.message : String(error)); - } - await cleanupDownloadResources(normalizedUrl); - }) - .finally(() => { - activeDownloads.delete(normalizedUrl); - if (activeDownloads.size === 0) keepAlive(false); - }); + // Build initial download state + const stateId = generateDownloadId(normalizedUrl); + const initialState: DownloadState = { + id: stateId, + url, + metadata, + progress: { + url, + stage: DownloadStage.RECORDING, + segmentsCollected: 0, + message: "Recording...", + }, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + await storeDownload(initialState); - activeDownloads.set(normalizedUrl, recordingPromise); - if (activeDownloads.size === 1) { - keepAlive(true); - // Pre-warm FFmpeg — recordings always need FFmpeg for the merge phase - createOffscreenDocument() - .then(() => chrome.runtime.sendMessage({ type: MessageType.WARMUP_FFMPEG })) - .catch((err) => logger.error("FFmpeg pre-warm failed for recording:", err)); - } + const abortController = new AbortController(); + downloadAbortControllers.set(normalizedUrl, abortController); - return { success: true }; - } catch (error) { - logger.error("Start recording error:", error); - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; + const onProgress = async (state: DownloadState) => { + const controller = downloadAbortControllers.get(normalizeUrl(state.url)); + // Allow progress updates through when in post-recording stages (MERGING, SAVING, COMPLETED) + // since the abort signal is used to stop the recording loop, not to cancel the merge + const isPostRecording = + state.progress.stage === DownloadStage.MERGING || + state.progress.stage === DownloadStage.SAVING || + state.progress.stage === DownloadStage.COMPLETED; + if (!isPostRecording && (!controller || controller.signal.aborted)) return; + await storeDownload(state); + try { + chrome.runtime.sendMessage( + { + type: MessageType.DOWNLOAD_PROGRESS, + payload: { id: state.id, progress: state.progress }, + }, + () => { + if (chrome.runtime.lastError) { + } + }, + ); + } catch (_) {} + }; + + const handler = + metadata.format === VideoFormat.DASH + ? new DashRecordingHandler({ + onProgress, + selectedBandwidth: payload.selectedBandwidth, + ...recordingHandlerOptions, + }) + : new HlsRecordingHandler({ onProgress, ...recordingHandlerOptions }); + + const finalFilename = resolveFilename(url, metadata, filename, tabTitle, website); + + const recordingPromise = handler + .record( + url, + finalFilename, + stateId, + abortController.signal, + metadata.pageUrl, + ) + .then(async () => { + await cleanupDownloadResources(normalizedUrl); + sendDownloadComplete(stateId); + const cfg = await loadSettings(); + if (!cfg.historyEnabled) await deleteDownload(stateId); + }) + .catch(async (error: unknown) => { + // Persist FAILED state to IndexedDB so the downloads tab reflects the real status + const failedState = await getDownload(stateId); + if ( + failedState && + failedState.progress.stage !== DownloadStage.COMPLETED + ) { + failedState.progress.stage = DownloadStage.FAILED; + failedState.progress.message = + error instanceof Error ? error.message : String(error); + failedState.updatedAt = Date.now(); + await storeDownload(failedState); + } + if (!(error instanceof CancellationError)) { + logger.error(`Recording failed for ${url}:`, error); + sendDownloadFailed( + url, + error instanceof Error ? error.message : String(error), + ); + } + await cleanupDownloadResources(normalizedUrl); + const cfg = await loadSettings(); + if (!cfg.historyEnabled) await deleteDownload(stateId); + }) + .finally(() => { + activeDownloads.delete(normalizedUrl); + if (activeDownloads.size === 0) { + keepAlive(false); + closeOffscreenDocument().catch((err) => + logger.error("Failed to close offscreen document:", err), + ); + } + }); + + activeDownloads.set(normalizedUrl, recordingPromise); + if (activeDownloads.size === 1) { + keepAlive(true); + // Pre-warm FFmpeg — recordings always need FFmpeg for the merge phase + createOffscreenDocument() + .then(() => + chrome.runtime.sendMessage({ type: MessageType.WARMUP_FFMPEG }), + ) + .catch((err) => + logger.error("FFmpeg pre-warm failed for recording:", err), + ); } + + return { success: true }; } /** @@ -1020,7 +1150,10 @@ async function handleStopRecordingMessage(payload: { const normalizedUrl = normalizeUrl(payload.url); const abortController = downloadAbortControllers.get(normalizedUrl); if (!abortController) { - return { success: false, error: "No active recording found for this URL." }; + return { + success: false, + error: "No active recording found for this URL.", + }; } abortController.abort(); logger.info(`Stopped recording for ${normalizedUrl}`); diff --git a/src/shared/constants.ts b/src/shared/constants.ts index d881b55..696d84c 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -13,18 +13,6 @@ export const DEFAULT_MAX_CONCURRENT = 3; /** Default FFmpeg processing timeout in milliseconds (configurable in settings) */ export const DEFAULT_FFMPEG_TIMEOUT_MS = 900_000; // 15 minutes -/** Default FFmpeg timeout expressed in minutes (for the settings UI) */ -export const DEFAULT_FFMPEG_TIMEOUT_MINUTES = 15; - -/** Minimum FFmpeg timeout in minutes (settings UI clamp) */ -export const MIN_FFMPEG_TIMEOUT_MINUTES = 5; - -/** Maximum FFmpeg timeout in minutes (settings UI clamp) */ -export const MAX_FFMPEG_TIMEOUT_MINUTES = 60; - -/** Milliseconds per minute — avoids bare 60000 literals */ -export const MS_PER_MINUTE = 60_000; - // ---- Download pipeline ---- /** Fragment failure rate above which the download is aborted */ @@ -46,7 +34,39 @@ export const INITIAL_RETRY_DELAY_MS = 100; /** Exponential backoff multiplier applied after each fetch retry */ export const RETRY_BACKOFF_FACTOR = 1.15; +// ---- Fetch retry (configurable via Advanced settings) ---- + +/** Maximum number of retry attempts for segment/manifest fetches */ +export const DEFAULT_MAX_RETRIES = 3; + +// ---- Live recording polling (configurable via Recording settings) ---- + +/** Default HLS poll interval before #EXT-X-TARGETDURATION is known */ +export const DEFAULT_HLS_POLL_INTERVAL_MS = 3_000; + +/** Minimum allowed HLS poll interval */ +export const DEFAULT_MIN_POLL_MS = 1_000; + +/** Maximum allowed HLS poll interval */ +export const DEFAULT_MAX_POLL_MS = 10_000; + +/** Fraction of #EXT-X-TARGETDURATION used to compute poll cadence */ +export const DEFAULT_POLL_FRACTION = 0.5; + +// ---- Detection deduplication caches (configurable via Advanced settings) ---- + +/** Max distinct URL path keys tracked by HLS/DASH detection handlers */ +export const DEFAULT_DETECTION_CACHE_SIZE = 500; + +/** Max master playlists held in memory by HLS detection handler */ +export const DEFAULT_MASTER_PLAYLIST_CACHE_SIZE = 50; + +/** IDB write interval during segment downloads (configurable via Advanced settings) */ +export const DEFAULT_DB_SYNC_INTERVAL_MS = 500; + // ---- Chrome storage keys ---- export const STORAGE_CONFIG_KEY = "storage_config"; -export const MAX_CONCURRENT_KEY = "max_concurrent"; + +/** Default Google Drive upload folder name */ +export const DEFAULT_GOOGLE_DRIVE_FOLDER_NAME = "MediaBridge Uploads"; diff --git a/src/shared/messages.ts b/src/shared/messages.ts index 6df1096..9060335 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -66,6 +66,9 @@ export enum MessageType { // FFmpeg pre-warm WARMUP_FFMPEG = "WARMUP_FFMPEG", + + // URL health check (options page manifest check feature) + CHECK_URL = "CHECK_URL", } export interface BaseMessage {