From 24740c8e2e0caac0b2fd263d2ab2e30cb26a3b24 Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Mon, 2 Mar 2026 23:51:57 -0500 Subject: [PATCH 01/21] feat: redesign options page with sidebar nav, history view, and S3 config - Replace flat options layout with a sidebar + multi-view layout (Download Settings, Cloud Providers, History) - Add History view with search/format/status/date filters, bulk delete, per-item actions (re-download, copy URL, manifest health check, delete) - Add S3/compatible cloud provider form alongside existing Google Drive panel - Add history button (clock icon) in popup top bar that opens options directly to History view - Add CHECK_URL message type so options page can probe manifest URLs via service worker (bypasses CORS) - Add bulkDeleteDownloads() IDB utility for efficient multi-item deletion - Extend StorageConfig with historyEnabled and s3 fields - Auto-delete terminal downloads from IDB when historyEnabled is false - All views centered; CSS tooltips on history action icon buttons Co-Authored-By: Claude Sonnet 4.6 --- src/core/database/downloads.ts | 29 + src/core/types/index.ts | 10 + src/options/options.html | 989 ++++++++++++++++++++++++++++----- src/options/options.ts | 920 +++++++++++++++++++++++------- src/popup/popup.html | 6 + src/popup/popup.ts | 8 + src/popup/state.ts | 1 + src/service-worker.ts | 30 + src/shared/messages.ts | 3 + 9 files changed, 1658 insertions(+), 338 deletions(-) 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/types/index.ts b/src/core/types/index.ts index d7e3ecf..88b8bc2 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -88,6 +88,16 @@ export interface StorageConfig { folderName?: string; }; ffmpegTimeout?: number; // FFmpeg processing timeout in milliseconds (default: 15 minutes) + 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; + }; } export interface MessageRequest { diff --git a/src/options/options.html b/src/options/options.html index b40df16..abb86df 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -54,6 +54,8 @@ --radius-sm: 6px; --radius-md: 10px; --radius-pill: 9999px; + + --sidebar-width: 220px; } :root.light-mode { @@ -77,63 +79,168 @@ /* ---- Reset & Base ---- */ * { margin: 0; padding: 0; box-sizing: border-box; } + html, body { + height: 100%; + overflow: hidden; + } + body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; background: var(--surface-0); color: var(--text-primary); - min-height: 100vh; - padding-bottom: 80px; } - /* ---- Header ---- */ - .page-header { + /* ---- App Layout ---- */ + .app { + display: flex; + height: 100vh; + overflow: hidden; + } + + /* ---- Sidebar ---- */ + .sidebar { + width: var(--sidebar-width); + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--surface-1); + border-right: 1px solid var(--border); + overflow: hidden; + } + + .sidebar-header { display: flex; align-items: center; - justify-content: space-between; - padding: 20px 24px; - max-width: 640px; - margin: 0 auto; + gap: 10px; + padding: 20px 16px 16px; + border-bottom: 1px solid var(--border); } - .page-header-left h1 { - font-size: 20px; + .sidebar-logo { + width: 24px; + height: 24px; + flex-shrink: 0; + color: var(--accent); + } + + .sidebar-title { + font-size: 15px; font-weight: 600; letter-spacing: -0.01em; - margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; } - .page-header-left p { - font-size: 13px; - color: var(--text-secondary); + .sidebar-nav { + flex: 1; + padding: 8px; + display: flex; + flex-direction: column; + gap: 2px; } - .header-btn { - background: var(--surface-2); - border: 1px solid var(--border); + .nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + border-radius: var(--radius-sm); + border: none; + background: transparent; color: var(--text-secondary); + font-family: inherit; + font-size: 13px; + font-weight: 500; cursor: pointer; - padding: 8px; - border-radius: var(--radius-sm); + transition: all 0.15s; + text-align: left; + width: 100%; + white-space: nowrap; + overflow: hidden; + } + + .nav-item:hover { + background: var(--surface-2); + color: var(--text-primary); + } + + .nav-item.active { + background: var(--accent-subtle); + color: var(--accent); + } + + .nav-item svg { + width: 16px; + height: 16px; + flex-shrink: 0; + } + + .nav-item span { + overflow: hidden; + text-overflow: ellipsis; + } + + .sidebar-footer { + padding: 12px; + border-top: 1px solid var(--border); + } + + .theme-toggle { display: flex; align-items: center; justify-content: center; + width: 34px; + height: 34px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text-secondary); + cursor: pointer; transition: all 0.15s; } - .header-btn:hover { + .theme-toggle:hover { background: var(--surface-3); color: var(--text-primary); border-color: var(--border-hover); } - .header-btn svg { width: 18px; height: 18px; } + .theme-toggle svg { width: 16px; height: 16px; } - /* ---- Content ---- */ + /* ---- Main Content ---- */ .content { - max-width: 640px; + flex: 1; + overflow-y: auto; + background: var(--surface-0); + } + + /* ---- Views ---- */ + .view { + display: none; + padding: 28px 32px; + max-width: 680px; margin: 0 auto; - padding: 0 24px; + } + + .view.active { + display: block; + } + + .view-header { + margin-bottom: 24px; + } + + .view-header h1 { + font-size: 20px; + font-weight: 600; + letter-spacing: -0.01em; + } + + .view-header p { + margin-top: 4px; + font-size: 13px; + color: var(--text-secondary); } /* ---- Section Card ---- */ @@ -146,7 +253,7 @@ } .section-title { - font-size: 15px; + font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 16px; @@ -157,6 +264,10 @@ margin-bottom: 16px; } + .form-group:last-child { + margin-bottom: 0; + } + .form-label { display: block; font-size: 13px; @@ -167,7 +278,7 @@ .form-input { width: 100%; - padding: 10px 12px; + padding: 9px 12px; border: 1px solid var(--border-hover); border-radius: var(--radius-sm); font-family: inherit; @@ -203,9 +314,24 @@ line-height: 1.4; } + /* ---- Toggle Row ---- */ + .toggle-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-secondary); + cursor: pointer; + margin-top: 8px; + } + + .toggle-row input[type="checkbox"] { + accent-color: var(--accent); + } + /* ---- Buttons ---- */ .btn { - padding: 10px 20px; + padding: 9px 18px; border: none; border-radius: var(--radius-sm); font-family: inherit; @@ -233,22 +359,82 @@ background: var(--surface-2); color: var(--text-primary); border: 1px solid var(--border-hover); - margin-left: 8px; } - .btn-secondary:hover { + .btn-secondary:hover:not(:disabled) { background: var(--surface-3); border-color: var(--accent); } - /* ---- Auth Status ---- */ - .auth-section { + .btn-secondary:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .btn-danger { + background: rgba(248, 113, 113, 0.12); + color: var(--error); + border: 1px solid rgba(248, 113, 113, 0.2); + } + + .btn-danger:hover:not(:disabled) { + background: rgba(248, 113, 113, 0.2); + } + + .btn-icon { + padding: 6px; + background: transparent; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-tertiary); + cursor: pointer; + transition: all 0.15s; + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + } + + .btn-icon:hover { + background: var(--surface-2); + color: var(--text-primary); + border-color: var(--border-hover); + } + + .btn-icon svg { width: 14px; height: 14px; } + + .btn-icon[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + background: var(--surface-3); + color: var(--text-primary); + font-size: 11px; + font-family: inherit; + padding: 4px 8px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-hover); + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 0.12s; + z-index: 100; + } + + .btn-icon[data-tooltip]:hover::after { opacity: 1; } + + .btn-row { + display: flex; + gap: 8px; margin-top: 16px; } + /* ---- Auth Status ---- */ .auth-status { display: inline-block; - padding: 3px 10px; + padding: 2px 8px; border-radius: var(--radius-pill); font-size: 11px; font-weight: 500; @@ -276,48 +462,345 @@ } /* ---- Status Messages ---- */ - .status { - padding: 12px 16px; + .status-msg { + padding: 11px 14px; border-radius: var(--radius-sm); margin-top: 12px; font-size: 13px; border-left: 3px solid transparent; + display: none; } - .status-success { + .status-msg.success { background: var(--surface-1); border-left-color: var(--success); color: var(--success); } - .status-error { + .status-msg.error { background: var(--surface-1); border-left-color: var(--error); color: var(--error); } - .status-info { + .status-msg.info { background: var(--surface-1); border-left-color: var(--info); color: var(--info); } - /* ---- Sticky Save Bar ---- */ - .save-bar { - position: fixed; - bottom: 0; - left: 0; - right: 0; + /* ---- Provider Tabs ---- */ + .provider-tabs { + display: flex; + gap: 4px; + padding: 4px; + background: var(--surface-2); + border-radius: var(--radius-sm); + margin-bottom: 20px; + width: fit-content; + } + + .provider-tab { + padding: 6px 14px; + border: none; + border-radius: calc(var(--radius-sm) - 2px); + font-family: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + background: transparent; + color: var(--text-secondary); + transition: all 0.15s; + } + + .provider-tab.active { background: var(--surface-1); - border-top: 1px solid var(--border); - padding: 16px 24px; + color: var(--text-primary); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + } + + .provider-panel { + display: none; + } + + .provider-panel.active { + display: block; + } + + .coming-soon-note { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 12px 14px; + background: rgba(251, 191, 36, 0.08); + border: 1px solid rgba(251, 191, 36, 0.2); + border-radius: var(--radius-sm); + color: var(--warning); + font-size: 12px; + margin-bottom: 16px; + line-height: 1.5; + } + + /* ---- History Filters ---- */ + .history-controls { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + gap: 10px; + flex-wrap: wrap; + } + + .history-filters { display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + flex: 1; + } + + .history-filters input[type="text"] { + flex: 1; + min-width: 180px; + padding: 7px 12px; + border: 1px solid var(--border-hover); + border-radius: var(--radius-sm); + font-family: inherit; + font-size: 13px; + background: var(--surface-2); + color: var(--text-primary); + } + + .history-filters input[type="text"]:focus { + outline: none; + border-color: var(--accent); + } + + .history-filters input[type="text"]::placeholder { + color: var(--text-tertiary); + } + + .history-filters select { + padding: 7px 10px; + border: 1px solid var(--border-hover); + border-radius: var(--radius-sm); + font-family: inherit; + font-size: 12px; + background: var(--surface-2); + color: var(--text-secondary); + cursor: pointer; + } + + .history-filters select:focus { + outline: none; + border-color: var(--accent); + } + + /* ---- Bulk Bar ---- */ + .bulk-bar { + display: none; + align-items: center; + gap: 12px; + padding: 9px 14px; + background: var(--accent-subtle); + border: 1px solid rgba(59, 130, 246, 0.2); + border-radius: var(--radius-sm); + margin-bottom: 10px; + font-size: 13px; + } + + .bulk-bar.visible { + display: flex; + } + + .bulk-bar .bulk-count { + color: var(--accent); + font-weight: 500; + flex: 1; + } + + /* ---- History List ---- */ + #history-list { + display: flex; + flex-direction: column; + gap: 6px; + } + + .history-item { + display: grid; + grid-template-columns: 20px 72px 1fr auto; + gap: 10px; + align-items: center; + padding: 10px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: var(--surface-1); + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + position: relative; + } + + .history-item:hover { + border-color: var(--border-hover); + background: var(--surface-2); + } + + .history-item.selected { + border-color: var(--accent); + background: var(--accent-subtle); + } + + .history-item-checkbox { + accent-color: var(--accent); + cursor: pointer; + } + + .history-thumb { + width: 72px; + height: 42px; + border-radius: 4px; + object-fit: cover; + background: var(--surface-3); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; + } + + .history-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .history-thumb-placeholder { + width: 72px; + height: 42px; + border-radius: 4px; + background: var(--surface-3); + display: flex; + align-items: center; justify-content: center; - z-index: 10; + color: var(--text-tertiary); + flex-shrink: 0; + } + + .history-thumb-placeholder svg { + width: 18px; + height: 18px; + } + + .history-info { + min-width: 0; + } + + .history-title { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; + } + + .history-url { + font-size: 11px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 5px; + } + + .history-badges { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + } + + .badge { + display: inline-flex; + align-items: center; + padding: 1px 7px; + border-radius: var(--radius-pill); + font-size: 10px; + font-weight: 500; + white-space: nowrap; } - .save-bar .btn-primary { - min-width: 200px; + .badge-format { + background: rgba(139, 92, 246, 0.15); + color: #a78bfa; + } + + .badge-resolution { + background: rgba(59, 130, 246, 0.15); + color: var(--info); + } + + .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); + } + + .history-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + } + + .history-date { + font-size: 11px; + color: var(--text-tertiary); + white-space: nowrap; + } + + /* Manifest check button states */ + .check-btn[data-state="checking"] svg { animation: spin 1s linear infinite; } + .check-btn[data-state="ok"] { color: var(--success); border-color: rgba(52, 211, 153, 0.3); } + .check-btn[data-state="dead"] { color: var(--error); border-color: rgba(248, 113, 113, 0.3); } + .check-btn[data-state="unknown"] { color: var(--warning); border-color: rgba(251, 191, 36, 0.3); } + + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + + /* ---- Empty States ---- */ + .empty-state { + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 48px 24px; + color: var(--text-tertiary); + font-size: 13px; + text-align: center; + } + + .empty-state.visible { + display: flex; + } + + .empty-state svg { + width: 40px; + height: 40px; + opacity: 0.4; } /* ---- Scrollbar ---- */ @@ -330,120 +813,342 @@ ::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); } + + /* ---- Responsive: icon-only sidebar ---- */ + @media (max-width: 899px) { + :root { --sidebar-width: 56px; } + + .sidebar-title { display: none; } + .sidebar-header { padding: 16px 0; justify-content: center; } + .sidebar-logo { margin: 0; } + + .nav-item { padding: 10px; justify-content: center; } + .nav-item span { display: none; } + + .theme-toggle { width: 34px; margin: 0 auto; display: flex; } + + .view { padding: 20px 16px; } + } + + /* ---- Responsive: bottom tab bar ---- */ + @media (max-width: 599px) { + .app { flex-direction: column; } + + .sidebar { + width: 100%; + height: auto; + border-right: none; + border-top: 1px solid var(--border); + order: 2; + } + + .sidebar-header { display: none; } + .sidebar-footer { display: none; } + + .sidebar-nav { + flex-direction: row; + justify-content: space-around; + padding: 6px; + gap: 0; + } + + .nav-item { + flex-direction: column; + gap: 3px; + padding: 8px 12px; + font-size: 10px; + align-items: center; + } + + .nav-item span { display: block; } + .nav-item svg { width: 18px; height: 18px; } + + .content { order: 1; } + .view { padding: 16px; } + } - +
+ + + + +
-
diff --git a/src/options/options.ts b/src/options/options.ts index 5943a68..4d1346a 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -1,10 +1,18 @@ /** * 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 { GoogleAuth, GOOGLE_DRIVE_SCOPES } from "../core/cloud/google-auth"; +import { StorageConfig, DownloadState, DownloadStage } from "../core/types"; +import { MessageType } from "../shared/messages"; +import { + getAllDownloads, + deleteDownload, + bulkDeleteDownloads, + clearAllDownloads, +} from "../core/database/downloads"; import { DEFAULT_FFMPEG_TIMEOUT_MS, DEFAULT_FFMPEG_TIMEOUT_MINUTES, @@ -16,265 +24,785 @@ import { MAX_CONCURRENT_KEY, } 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; - -/** - * Initialize options page - */ -async function init() { - await loadSettings(); - await checkAuthStatus(); - await loadTheme(); +const STATUS_MESSAGE_DURATION_MS = 4000; +const TERMINAL_STAGES = new Set([ + DownloadStage.COMPLETED, + DownloadStage.FAILED, + DownloadStage.CANCELLED, +]); + +// ───────────────────────────────────────────── +// Section: Init & Routing +// ───────────────────────────────────────────── + +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); + switchView(hash === "history" || hash === "cloud-providers" ? hash : "download-settings"); +} - // Event listeners - driveEnabled.addEventListener("change", () => { - driveSettings.style.display = driveEnabled.checked ? "block" : "none"; +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")); + + 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"); + + 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(); } +} + +// ───────────────────────────────────────────── +// Section: Theme +// ───────────────────────────────────────────── - // Show drive settings if enabled - if (driveEnabled.checked) { - driveSettings.style.display = "block"; +function setupThemeToggle(): void { + const btn = document.getElementById("theme-toggle"); + if (btn) btn.addEventListener("click", toggleTheme); +} + +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 = ` + + + + + + + + + `; } } -/** - * Load settings from storage - */ -async function loadSettings() { +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 ChromeStorage.get(STORAGE_CONFIG_KEY); + const maxConc = await ChromeStorage.get(MAX_CONCURRENT_KEY); - if (config?.googleDrive) { - driveEnabled.checked = config.googleDrive.enabled || false; - folderName.value = config.googleDrive.folderName || "MediaBridge Uploads"; - folderId.value = config.googleDrive.targetFolderId || ""; + const maxInput = document.getElementById("max-concurrent") as HTMLInputElement; + const timeoutInput = document.getElementById("ffmpeg-timeout") as HTMLInputElement; + + if (maxInput && maxConc) maxInput.value = maxConc.toString(); + if (timeoutInput) { + const ms = config?.ffmpegTimeout ?? DEFAULT_FFMPEG_TIMEOUT_MS; + timeoutInput.value = Math.round(ms / MS_PER_MINUTE).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; + + btn.disabled = true; + btn.textContent = "Saving…"; + + try { + const config = (await ChromeStorage.get(STORAGE_CONFIG_KEY)) ?? {}; + const timeoutMinutes = + parseInt(timeoutInput.value) || DEFAULT_FFMPEG_TIMEOUT_MINUTES; + const clampedMs = + Math.max( + MIN_FFMPEG_TIMEOUT_MINUTES, + Math.min(MAX_FFMPEG_TIMEOUT_MINUTES, timeoutMinutes), + ) * MS_PER_MINUTE; + + config.ffmpegTimeout = clampedMs; + await ChromeStorage.set(STORAGE_CONFIG_KEY, config); + await ChromeStorage.set( + MAX_CONCURRENT_KEY, + parseInt(maxInput.value) || DEFAULT_MAX_CONCURRENT, + ); + + showStatus("settings-status", "Settings saved.", "success"); + } catch (err) { + showStatus("settings-status", `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; + + 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"); + }); + }); +} + +// -- Google Drive -- + +async function loadDriveSettings(): Promise { + const config = await ChromeStorage.get(STORAGE_CONFIG_KEY); + + 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 maxConcurrentValue = await ChromeStorage.get(MAX_CONCURRENT_KEY); - if (maxConcurrentValue) { - maxConcurrent.value = maxConcurrentValue.toString(); + if (config?.googleDrive) { + enabledCb.checked = config.googleDrive.enabled ?? false; + folderNameIn.value = config.googleDrive.folderName ?? "MediaBridge Uploads"; + folderIdIn.value = config.googleDrive.targetFolderId ?? ""; } - // 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(); + 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); } -/** - * Check authentication status - */ -async function checkAuthStatus() { +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("drive-status", "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("drive-status", `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("drive-status", "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("drive-status", `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 || "MediaBridge Uploads", + targetFolderId: folderIdIn.value || undefined, + createFolderIfNotExists: true, }; + await ChromeStorage.set(STORAGE_CONFIG_KEY, config); + showStatus("drive-status", "Settings saved.", "success"); + } catch (err) { + showStatus("drive-status", `Save failed: ${errorMsg(err)}`, "error"); + } finally { + btn.disabled = false; + btn.textContent = "Save Settings"; + } +} + +// -- S3 -- + +async function loadS3Settings(): Promise { + const config = await ChromeStorage.get(STORAGE_CONFIG_KEY); + const s3 = config?.s3; + + const get = (id: string) => document.getElementById(id) as HTMLInputElement; + + if (s3) { + get("s3-enabled").checked = s3.enabled ?? false; + 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); +} - // 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 +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); - await ChromeStorage.set( - MAX_CONCURRENT_KEY, - parseInt(maxConcurrent.value) || DEFAULT_MAX_CONCURRENT, - ); + showStatus("s3-status", "Settings saved.", "success"); + } catch (err) { + showStatus("s3-status", `Save failed: ${errorMsg(err)}`, "error"); + } finally { + btn.disabled = false; + btn.textContent = "Save Settings"; + } +} - 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", +// ───────────────────────────────────────────── +// Section: History View +// ───────────────────────────────────────────── + +let allHistory: DownloadState[] = []; +const selectedIds = new Set(); + +async function loadHistory(): Promise { + const config = await ChromeStorage.get(STORAGE_CONFIG_KEY); + + const historyEnabledCb = document.getElementById( + "history-enabled", + ) as HTMLInputElement; + historyEnabledCb.checked = config?.historyEnabled !== false; + + 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 === false) { + showHistoryDisabled(); + return; + } + + 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) { + await clearAllDownloads(); + allHistory = []; + selectedIds.clear(); + showHistoryDisabled(); + } else { + await fetchAndRenderHistory(); + } +} + +async function fetchAndRenderHistory(): Promise { + const all = await getAllDownloads(); + allHistory = all.filter((d) => TERMINAL_STAGES.has(d.progress.stage)); + rerenderHistory(); +} + +function rerenderHistory(): void { + const filtered = applyFilters(allHistory); + renderHistoryList(filtered); +} + +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(); + const day = 86400_000; + if (range === "today") return ts >= now - day; + if (range === "week") return ts >= now - 7 * day; + if (range === "month") return ts >= now - 30 * day; + return true; +} + +function renderHistoryList(items: DownloadState[]): void { + const list = document.getElementById("history-list")!; + const emptyEl = document.getElementById("history-empty")!; + const disabledEl = document.getElementById("history-disabled")!; + + disabledEl.classList.remove("visible"); + list.innerHTML = ""; + + if (items.length === 0) { + emptyEl.classList.add("visible"); + return; + } + + emptyEl.classList.remove("visible"); + items.forEach((state) => { + list.appendChild(renderHistoryItem(state)); + }); +} + +function showHistoryDisabled(): void { + 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.resolution || state.metadata.quality) { + badges.appendChild( + makeBadge((state.metadata.resolution || state.metadata.quality)!, "badge-resolution"), ); - } finally { - saveBtn.disabled = false; - saveBtn.textContent = "Save Settings"; } + 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); + + // Re-download button + actions.appendChild( + makeIconBtn( + iconDownload(), + "Re-download", + () => redownload(state.url), + ), + ); + + // Copy URL button + actions.appendChild( + makeIconBtn(iconCopy(), "Copy URL", () => + navigator.clipboard.writeText(state.url), + ), + ); + + // Check manifest button + const checkBtn = makeIconBtn(iconLink(), "Check manifest"); + checkBtn.classList.add("check-btn"); + checkBtn.addEventListener("click", () => checkManifest(state.url, checkBtn)); + actions.appendChild(checkBtn); + + // Delete button + const deleteBtn = makeIconBtn(iconTrash(), "Delete", async () => { + await deleteDownload(state.id); + allHistory = allHistory.filter((d) => d.id !== state.id); + selectedIds.delete(state.id); + syncBulkBar(); + rerenderHistory(); + }); + actions.appendChild(deleteBtn); + + 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(); } -/** - * Show status message - */ -function showStatus(message: string, type: "success" | "error" | "info") { - statusMessage.className = `status status-${type}`; - statusMessage.textContent = message; - statusMessage.style.display = "block"; +function thumbPlaceholder(): HTMLElement { + const el = document.createElement("div"); + el.className = "history-thumb-placeholder"; + el.innerHTML = ``; + return el; +} - setTimeout(() => { - statusMessage.style.display = "none"; - }, STATUS_MESSAGE_DURATION_MS); +function makeBadge(text: string, cls: string): HTMLElement { + const b = document.createElement("span"); + b.className = `badge ${cls}`; + b.textContent = text.toUpperCase(); + return b; } -/** - * Load theme from storage and apply it - */ -async function loadTheme() { - const theme = await ChromeStorage.get("theme"); - const isLightMode = theme === "light"; - applyTheme(isLightMode); +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] ?? ""); } -/** - * Apply theme to the page - */ -function applyTheme(isLightMode: boolean) { - const root = document.documentElement; - if (isLightMode) { - root.classList.add("light-mode"); +function makeIconBtn( + svgContent: string, + tooltip: string, + onClick?: () => void, +): HTMLButtonElement { + const btn = document.createElement("button"); + btn.className = "btn-icon"; + btn.setAttribute("data-tooltip", tooltip); + btn.setAttribute("aria-label", tooltip); + btn.innerHTML = `${svgContent}`; + if (onClick) btn.addEventListener("click", onClick); + return btn; +} + +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)); +} + +function redownload(url: string): void { + chrome.runtime.sendMessage({ + type: MessageType.DOWNLOAD_REQUEST, + payload: { + url, + metadata: { url, format: "unknown" as any, pageUrl: url }, + }, + }); +} + +async function checkManifest( + url: string, + btn: HTMLButtonElement, +): Promise { + btn.dataset.state = "checking"; + btn.querySelector("svg")!.innerHTML = iconSpinner(); + + const result = await chrome.runtime.sendMessage({ + type: MessageType.CHECK_URL, + payload: { url }, + }); + + if (!result) { + btn.dataset.state = "unknown"; + btn.querySelector("svg")!.innerHTML = iconQuestion(); + return; + } + + if (result.ok) { + btn.dataset.state = "ok"; + btn.querySelector("svg")!.innerHTML = iconCheck(); + } else if (result.status === 0) { + btn.dataset.state = "unknown"; + btn.querySelector("svg")!.innerHTML = iconQuestion(); } else { - root.classList.remove("light-mode"); + btn.dataset.state = "dead"; + btn.querySelector("svg")!.innerHTML = iconX(); } - updateThemeIcon(isLightMode); } -/** - * 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 = ` - - `; - } else { - // Sun icon for dark mode (to switch to light) - themeIcon.innerHTML = ` - - - - - - - - - - `; +// ───────────────────────────────────────────── +// Section: Utilities +// ───────────────────────────────────────────── + +function showStatus( + elementId: string, + message: string, + type: "success" | "error" | "info", +): void { + const el = document.getElementById(elementId); + if (!el) return; + el.className = `status-msg ${type}`; + el.textContent = message; + el.style.display = "block"; + setTimeout(() => (el.style.display = "none"), STATUS_MESSAGE_DURATION_MS); +} + +function errorMsg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +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; } } -/** - * Toggle theme between light and dark - */ -async function toggleTheme() { - const root = document.documentElement; - const isLightMode = root.classList.contains("light-mode"); - const newTheme = isLightMode ? "dark" : "light"; +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 < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`; + return `${Math.floor(diff / 86400_000)}d ago`; +} + +// ───────────────────────────────────────────── +// SVG icon snippets (inner SVG path strings) +// ───────────────────────────────────────────── + +function iconDownload(): string { + return ``; +} + +function iconCopy(): string { + return ``; +} + +function iconLink(): string { + return ``; +} + +function iconTrash(): string { + return ``; +} + +function iconCheck(): string { + return ``; +} + +function iconX(): string { + return ``; +} - await ChromeStorage.set("theme", newTheme); - applyTheme(!isLightMode); +function iconQuestion(): string { + return ``; } -// Initialize when DOM is ready +function iconSpinner(): string { + // A simple arc that spins via CSS animation + return ``; +} + +// ───────────────────────────────────────────── +// Bootstrap +// ───────────────────────────────────────────── + if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { diff --git a/src/popup/popup.html b/src/popup/popup.html index 1cb10b3..f1d9455 100644 --- a/src/popup/popup.html +++ b/src/popup/popup.html @@ -930,6 +930,12 @@
Media Bridge
+ - -
- `; - } else if (isFailed) { - actionButtons = ` -
- - -
- `; - } else if (isRecording) { + if (isRecording) { actionButtons = `
From fccbfc53e832f9b9d1299914ab083c796962233b Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Tue, 3 Mar 2026 00:35:44 -0500 Subject: [PATCH 04/21] fix(options): use extension icon in sidebar header, clean up download settings icon Replaces SVG placeholder logo with the actual extension icon (icon-32.png). Swaps the messy gear/path icon on Download Settings for a clean sliders icon. Co-Authored-By: Claude Sonnet 4.6 --- src/options/options.html | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/options/options.html b/src/options/options.html index abb86df..b732b35 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -121,7 +121,6 @@ width: 24px; height: 24px; flex-shrink: 0; - color: var(--accent); } .sidebar-title { @@ -873,18 +872,22 @@
+
+ diff --git a/src/options/options.ts b/src/options/options.ts index 2c305b3..b69ec52 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -639,14 +639,7 @@ function renderHistoryItem(state: DownloadState): HTMLElement { menu.appendChild(makeMenuItem(iconDownload(), "Re-download", () => redownload(state.url))); menu.appendChild(makeMenuItem(iconCopy(), "Copy URL", () => navigator.clipboard.writeText(state.url))); - // Check manifest item (keeps state feedback inline) - const checkItem = makeMenuItem(iconLink(), "Check manifest", () => {}); - checkItem.classList.add("check-btn"); - checkItem.addEventListener("click", (e) => { - e.stopPropagation(); - checkManifest(state.url, checkItem); - }); - menu.appendChild(checkItem); + menu.appendChild(makeMenuItem(iconLink(), "Check manifest", () => checkManifest(state.url))); menu.appendChild(makeMenuItem(iconTrash(), "Delete", async () => { await deleteDownload(state.id); @@ -742,33 +735,43 @@ function redownload(url: string): void { }); } -async function checkManifest( - url: string, - btn: HTMLButtonElement, -): Promise { - btn.dataset.state = "checking"; - btn.querySelector("svg")!.innerHTML = iconSpinner(); +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"), 3000); +} + +async function checkManifest(url: string): Promise { + showToast("Checking…", "warning"); const result = await chrome.runtime.sendMessage({ type: MessageType.CHECK_URL, payload: { url }, }); - if (!result) { - btn.dataset.state = "unknown"; - btn.querySelector("svg")!.innerHTML = iconQuestion(); - return; - } - - if (result.ok) { - btn.dataset.state = "ok"; - btn.querySelector("svg")!.innerHTML = iconCheck(); - } else if (result.status === 0) { - btn.dataset.state = "unknown"; - btn.querySelector("svg")!.innerHTML = iconQuestion(); + if (!result || result.status === 0) { + showToast("Manifest unreachable or CORS blocked", "warning"); + } else if (result.ok) { + showToast("Manifest is live ✓", "success"); } else { - btn.dataset.state = "dead"; - btn.querySelector("svg")!.innerHTML = iconX(); + showToast(`Manifest returned ${result.status}`, "error"); } } From b2d2ede1130ce637512a38e27990877dbd8333f1 Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Tue, 3 Mar 2026 01:13:45 -0500 Subject: [PATCH 07/21] feat(history): show toast when copying URL to clipboard Co-Authored-By: Claude Sonnet 4.6 --- src/options/options.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/options/options.ts b/src/options/options.ts index b69ec52..9ae0c68 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -637,7 +637,10 @@ function renderHistoryItem(state: DownloadState): HTMLElement { } menu.appendChild(makeMenuItem(iconDownload(), "Re-download", () => redownload(state.url))); - menu.appendChild(makeMenuItem(iconCopy(), "Copy URL", () => navigator.clipboard.writeText(state.url))); + 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))); From ca26cdec5a6e3a6608988b242ecd569c07765b71 Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Tue, 3 Mar 2026 01:19:37 -0500 Subject: [PATCH 08/21] feat(history): toast on re-download and refresh history list Shows "Download queued" toast on success or an error toast if the service worker rejects. Refreshes the history list in place so the user stays on the history view without a full page reload. Co-Authored-By: Claude Sonnet 4.6 --- src/options/options.ts | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/options/options.ts b/src/options/options.ts index 9ae0c68..d93d76e 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -5,7 +5,7 @@ import { ChromeStorage } from "../core/storage/chrome-storage"; import { GoogleAuth, GOOGLE_DRIVE_SCOPES } from "../core/cloud/google-auth"; -import { StorageConfig, DownloadState, DownloadStage } from "../core/types"; +import { StorageConfig, DownloadState, DownloadStage, VideoMetadata } from "../core/types"; import { MessageType } from "../shared/messages"; import { getAllDownloads, @@ -636,7 +636,7 @@ function renderHistoryItem(state: DownloadState): HTMLElement { })); } - menu.appendChild(makeMenuItem(iconDownload(), "Re-download", () => redownload(state.url))); + 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"); @@ -728,14 +728,21 @@ function syncBulkBar(): void { !selectAll.checked && visible.some((d) => selectedIds.has(d.id)); } -function redownload(url: string): void { - chrome.runtime.sendMessage({ - type: MessageType.DOWNLOAD_REQUEST, - payload: { - url, - metadata: { url, format: "unknown" as any, pageUrl: url }, - }, - }); +async function redownload(url: string, metadata?: VideoMetadata): Promise { + try { + const response = await chrome.runtime.sendMessage({ + type: MessageType.DOWNLOAD_REQUEST, + payload: { url, metadata: metadata ?? { url, format: "unknown" as any, pageUrl: url } }, + }); + if (response?.error) { + showToast(response.error, "error"); + } else { + showToast("Download queued", "success"); + await loadHistory(); + } + } catch { + showToast("Failed to start download", "error"); + } } let toastTimer: ReturnType | null = null; From 8c1f4598370c5847c95b154c7ecbad761d16de89 Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Tue, 3 Mar 2026 01:58:33 -0500 Subject: [PATCH 09/21] fix(history): use stored metadata for readable filename on re-download Co-Authored-By: Claude Sonnet 4.6 --- src/options/options.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/options/options.ts b/src/options/options.ts index d93d76e..3ff52a6 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -729,10 +729,22 @@ function syncBulkBar(): void { } async function redownload(url: string, metadata?: VideoMetadata): Promise { + let website: string | undefined; + try { + const urlObj = new URL(metadata?.pageUrl ?? url); + website = urlObj.hostname.replace(/^www\./, ""); + } catch {} + const tabTitle = metadata?.title; + try { const response = await chrome.runtime.sendMessage({ type: MessageType.DOWNLOAD_REQUEST, - payload: { url, metadata: metadata ?? { url, format: "unknown" as any, pageUrl: url } }, + payload: { + url, + metadata: metadata ?? { url, format: "unknown" as any, pageUrl: url }, + tabTitle, + website, + }, }); if (response?.error) { showToast(response.error, "error"); From 5ddf646bd3e13ed07fb2a4dd0755d59c09f52a3b Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Tue, 3 Mar 2026 02:20:07 -0500 Subject: [PATCH 10/21] fix(options): restore active section on page refresh switchView() now calls history.replaceState to keep the URL hash in sync with the active section. On refresh, init() reads the hash and opens the correct view instead of always falling back to download-settings. Co-Authored-By: Claude Sonnet 4.6 --- src/options/options.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/options/options.ts b/src/options/options.ts index 3ff52a6..82e9dea 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -68,6 +68,9 @@ function switchView(viewId: string): void { ); 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); From b956ef7991057b43cab6de4e6c41c0001e66c85a Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Tue, 3 Mar 2026 02:35:32 -0500 Subject: [PATCH 11/21] feat(history): infinite scroll with IntersectionObserver Render first 50 items on load; append subsequent batches as the user scrolls near the bottom via a sentinel div watched by IntersectionObserver. Filters and search reset to the first page on every change. Co-Authored-By: Claude Sonnet 4.6 --- src/options/options.ts | 56 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/src/options/options.ts b/src/options/options.ts index 82e9dea..0616e51 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -373,8 +373,13 @@ async function saveS3Settings(): Promise { // 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; async function loadHistory(): Promise { const config = await ChromeStorage.get(STORAGE_CONFIG_KEY); @@ -455,8 +460,9 @@ async function fetchAndRenderHistory(): Promise { } function rerenderHistory(): void { - const filtered = applyFilters(allHistory); - renderHistoryList(filtered); + currentFiltered = applyFilters(allHistory); + currentPage = 0; + renderHistoryList(); } function applyFilters(all: DownloadState[]): DownloadState[] { @@ -493,26 +499,60 @@ function dateInRange(ts: number, range: string): boolean { return true; } -function renderHistoryList(items: DownloadState[]): void { +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 (items.length === 0) { + if (currentFiltered.length === 0) { emptyEl.classList.add("visible"); return; } emptyEl.classList.remove("visible"); - items.forEach((state) => { - list.appendChild(renderHistoryItem(state)); - }); + 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")!; @@ -753,7 +793,7 @@ async function redownload(url: string, metadata?: VideoMetadata): Promise showToast(response.error, "error"); } else { showToast("Download queued", "success"); - await loadHistory(); + await fetchAndRenderHistory(); } } catch { showToast("Failed to start download", "error"); From afd1707ff7ed793a0ad2c9f6a837267693f439b6 Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Tue, 3 Mar 2026 03:36:54 -0500 Subject: [PATCH 12/21] =?UTF-8?q?refactor(options):=20rename=20terminal=20?= =?UTF-8?q?=E2=86=92=20finished=20stages=20and=20add=20live=20history=20up?= =?UTF-8?q?dates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename TERMINAL_STAGES → FINISHED_STAGES and handleTerminalDownload → handleFinishedDownload to align with DownloadStage terminology used elsewhere in the codebase - Update service-worker.ts JSDoc comment to match ("terminal" → "finished") - Add real-time history updates via DOWNLOAD_PROGRESS message listener — newly finished downloads appear/update without requiring a page reload - Flash animation (history-item--new) highlights items as they arrive Co-Authored-By: Claude Sonnet 4.6 --- src/options/options.html | 8 +++++ src/options/options.ts | 70 ++++++++++++++++++++++++++++++++++++++-- src/service-worker.ts | 2 +- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/options/options.html b/src/options/options.html index 7800122..15a49ba 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -649,6 +649,14 @@ background: var(--accent-subtle); } + @keyframes history-item-flash { + 0% { background-color: var(--accent-subtle); border-color: var(--accent); } + 100% { background-color: var(--surface-1); border-color: var(--border); } + } + .history-item--new { + animation: history-item-flash 1.4s ease-out; + } + .history-item-checkbox { accent-color: var(--accent); cursor: pointer; diff --git a/src/options/options.ts b/src/options/options.ts index 0616e51..a35bdd2 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -9,6 +9,7 @@ import { StorageConfig, DownloadState, DownloadStage, VideoMetadata } from "../c import { MessageType } from "../shared/messages"; import { getAllDownloads, + getDownload, deleteDownload, bulkDeleteDownloads, clearAllDownloads, @@ -25,7 +26,7 @@ import { } from "../shared/constants"; const STATUS_MESSAGE_DURATION_MS = 4000; -const TERMINAL_STAGES = new Set([ +const FINISHED_STAGES = new Set([ DownloadStage.COMPLETED, DownloadStage.FAILED, DownloadStage.CANCELLED, @@ -380,6 +381,68 @@ let currentFiltered: DownloadState[] = []; let currentPage = 0; let historyObserver: IntersectionObserver | null = null; let historySentinel: HTMLElement | null = null; +let historyMessageListener: ((msg: unknown) => void) | null = null; + +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" + ); +} + +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); +} + +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 { + 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; + } + + currentFiltered = applyFilters(allHistory); + currentPage = 0; + renderHistoryList(); + flashItem(list.querySelector(`[data-id="${id}"]`)); +} + +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 ChromeStorage.get(STORAGE_CONFIG_KEY); @@ -433,6 +496,7 @@ async function loadHistory(): Promise { return; } + registerHistoryListener(); await fetchAndRenderHistory(); } @@ -444,18 +508,20 @@ async function onHistoryEnabledChange(): Promise { await ChromeStorage.set(STORAGE_CONFIG_KEY, config); if (!enabled) { + unregisterHistoryListener(); await clearAllDownloads(); allHistory = []; selectedIds.clear(); showHistoryDisabled(); } else { + registerHistoryListener(); await fetchAndRenderHistory(); } } async function fetchAndRenderHistory(): Promise { const all = await getAllDownloads(); - allHistory = all.filter((d) => TERMINAL_STAGES.has(d.progress.stage)); + allHistory = all.filter((d) => FINISHED_STAGES.has(d.progress.stage)); rerenderHistory(); } diff --git a/src/service-worker.ts b/src/service-worker.ts index 43ac82a..628d394 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -82,7 +82,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 { From a86c61fea0029c7b1a4e07aeb7d644585c3f1fdf Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Tue, 3 Mar 2026 03:51:10 -0500 Subject: [PATCH 13/21] fix(options): remove redundant check mark from manifest live toast Co-Authored-By: Claude Sonnet 4.6 --- src/options/options.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/options/options.ts b/src/options/options.ts index a35bdd2..84b4160 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -900,7 +900,7 @@ async function checkManifest(url: string): Promise { if (!result || result.status === 0) { showToast("Manifest unreachable or CORS blocked", "warning"); } else if (result.ok) { - showToast("Manifest is live ✓", "success"); + showToast("Manifest is live", "success"); } else { showToast(`Manifest returned ${result.status}`, "error"); } From c9eb8de1303742bd1a88600e254d55d1ccf24219 Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Wed, 4 Mar 2026 02:03:26 -0500 Subject: [PATCH 14/21] feat(options): add Recording, Notifications, and Advanced settings pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add three new options sections: Recording (poll intervals), Notifications (OS notify + auto-open), and Advanced (retries, backoff, caches, IDB sync) - Wire all new settings through DownloadManager → BasePlaylistHandler → fetch-utils so retryDelayMs and retryBackoffFactor now take effect at runtime - Read detectionCacheSize/masterPlaylistCacheSize from chrome.storage in content script init so detection cache limits are configurable - Add cross-field validation: min poll interval must be < max poll interval - Add notifications permission to manifest for OS completion alerts - Add post-download actions handler (notify on completion, reveal in Finder) - Fix re-recording: clean up finished IDB entry before starting fresh - Fix CHECK_URL: use GET for HLS/DASH manifests to avoid CDN 403s on HEAD - Fix history re-download: send START_RECORDING for live stream entries - Add live badge to history items for recordings from live streams - Add history-item flash animation on completion while history tab is open Co-Authored-By: Claude Sonnet 4.6 --- manifest.json | 1 + src/content.ts | 10 +- .../detection/dash/dash-detection-handler.ts | 9 +- src/core/detection/detection-manager.ts | 7 + .../detection/hls/hls-detection-handler.ts | 16 +- src/core/downloader/base-playlist-handler.ts | 49 +- src/core/downloader/crypto-utils.ts | 4 +- .../downloader/dash/dash-recording-handler.ts | 4 +- src/core/downloader/download-manager.ts | 60 ++- .../downloader/hls/hls-recording-handler.ts | 35 +- src/core/types/index.ts | 18 + src/core/utils/fetch-utils.ts | 20 +- src/options/options.html | 242 ++++++++++ src/options/options.ts | 222 ++++++++- src/service-worker.ts | 441 +++++++++++------- src/shared/constants.ts | 30 ++ 16 files changed, 922 insertions(+), 246 deletions(-) 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/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/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/types/index.ts b/src/core/types/index.ts index 88b8bc2..ac23797 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -98,6 +98,24 @@ export interface StorageConfig { 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/options.html b/src/options/options.html index 15a49ba..84599ad 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -763,6 +763,11 @@ color: var(--warning); } + .badge-live { + background: rgba(248, 113, 113, 0.15); + color: #f87171; + } + .history-actions { display: flex; align-items: center; @@ -1018,6 +1023,30 @@ History + + + + + +
+ +
+
+

Recording

+

Configure live stream recording poll intervals and behaviour.

+
+ +
+

HLS Poll Interval

+
+ + +
Floor for computed poll cadence (500–5 000 ms).
+
+
+ + +
Ceiling for computed poll cadence (2 000–30 000 ms).
+
+
+ + +
Fraction of #EXT-X-TARGETDURATION used to compute poll cadence (0.25–1.0).
+
+
+ +
+ +
+
+
+ + +
+
+

Notifications

+

Control OS notifications and post-download actions.

+
+ +
+

Post-Download

+
+ +
Show an OS notification when a download finishes.
+
+
+ +
Reveal the saved file in Finder / Explorer after download completes.
+
+
+ +
+ +
+
+
+ + +
+
+

Advanced

+

Fine-tune retry behaviour, detection caches, and internal write frequency.

+
+ +
+

Retry & Reliability

+
+ + +
Number of times to retry a failed segment or manifest fetch (1–10).
+
+
+ + +
First backoff wait after a fetch failure (50–1 000 ms).
+
+
+ + +
Exponential growth factor applied after each retry (1.0–3.0).
+
+
+ + +
Download is aborted when more than this percentage of segments fail (5–50%).
+
+
+ +
+

Detection Caches

+
+ + +
Max distinct URL path keys tracked per page by HLS and DASH handlers (100–2 000).
+
+
+ + +
Max HLS master playlists held in memory per page (10–200).
+
+
+ +
+

Performance

+
+ + +
How often download state is written to IndexedDB during segment downloads. Lower values increase write frequency.
+
+
+ +
+ + +
+
+
+ diff --git a/src/options/options.ts b/src/options/options.ts index 84b4160..0131eaf 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -23,6 +23,16 @@ import { 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, } from "../shared/constants"; const STATUS_MESSAGE_DURATION_MS = 4000; @@ -43,7 +53,8 @@ function init(): void { // Check URL hash to navigate directly to a view (e.g. opened via history button) const hash = location.hash.slice(1); - switchView(hash === "history" || hash === "cloud-providers" ? hash : "download-settings"); + const validViews = new Set(["history", "cloud-providers", "recording", "notifications", "advanced"]); + switchView(validViews.has(hash) ? hash : "download-settings"); } function setupNavigation(): void { @@ -83,6 +94,9 @@ function switchView(viewId: string): void { loadS3Settings(); setupCloudProviderTabs(); } + if (viewId === "recording") loadRecordingSettings(); + if (viewId === "notifications") loadNotificationSettings(); + if (viewId === "advanced") loadAdvancedSettings(); } // ───────────────────────────────────────────── @@ -668,6 +682,7 @@ function renderHistoryItem(state: DownloadState): HTMLElement { 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"), @@ -838,29 +853,22 @@ function syncBulkBar(): void { } 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 { - const urlObj = new URL(metadata?.pageUrl ?? url); - website = urlObj.hostname.replace(/^www\./, ""); + website = new URL(resolvedMetadata.pageUrl ?? url).hostname.replace(/^www\./, ""); } catch {} - const tabTitle = metadata?.title; try { const response = await chrome.runtime.sendMessage({ - type: MessageType.DOWNLOAD_REQUEST, - payload: { - url, - metadata: metadata ?? { url, format: "unknown" as any, pageUrl: url }, - tabTitle, - website, - }, + type: isLive ? MessageType.START_RECORDING : MessageType.DOWNLOAD_REQUEST, + payload: { url, metadata: resolvedMetadata, tabTitle: metadata?.title, website }, }); - if (response?.error) { - showToast(response.error, "error"); - } else { - showToast("Download queued", "success"); - await fetchAndRenderHistory(); - } + if (response?.error) return showToast(response.error, "error"); + showToast(isLive ? "Recording started" : "Download queued", "success"); + await fetchAndRenderHistory(); } catch { showToast("Failed to start download", "error"); } @@ -986,6 +994,186 @@ function iconSpinner(): string { return ``; } +// ───────────────────────────────────────────── +// Section: Recording View +// ───────────────────────────────────────────── + +async function loadRecordingSettings(): Promise { + const config = await ChromeStorage.get(STORAGE_CONFIG_KEY); + const rec = config?.recording; + + const get = (id: string) => document.getElementById(id) as HTMLInputElement; + + get("poll-min").value = (rec?.minPollIntervalMs ?? DEFAULT_MIN_POLL_MS).toString(); + get("poll-max").value = (rec?.maxPollIntervalMs ?? DEFAULT_MAX_POLL_MS).toString(); + get("poll-fraction").value = (rec?.pollFraction ?? DEFAULT_POLL_FRACTION).toString(); + + document + .getElementById("save-recording-settings") + ?.addEventListener("click", saveRecordingSettings); +} + +async function saveRecordingSettings(): Promise { + const btn = document.getElementById("save-recording-settings") as HTMLButtonElement; + btn.disabled = true; + btn.textContent = "Saving…"; + + try { + const get = (id: string) => document.getElementById(id) as HTMLInputElement; + + const minPollIntervalMs = Math.max(500, Math.min(5000, parseInt(get("poll-min").value) || DEFAULT_MIN_POLL_MS)); + const maxPollIntervalMs = Math.max(2000, Math.min(30000, parseInt(get("poll-max").value) || DEFAULT_MAX_POLL_MS)); + const pollFraction = Math.max(0.25, Math.min(1.0, parseFloat(get("poll-fraction").value) || DEFAULT_POLL_FRACTION)); + + if (minPollIntervalMs >= maxPollIntervalMs) { + showStatus("recording-status", "Minimum poll interval must be less than maximum.", "error"); + return; + } + + const config = (await ChromeStorage.get(STORAGE_CONFIG_KEY)) ?? {}; + config.recording = { minPollIntervalMs, maxPollIntervalMs, pollFraction }; + await ChromeStorage.set(STORAGE_CONFIG_KEY, config); + showStatus("recording-status", "Settings saved.", "success"); + } catch (err) { + showStatus("recording-status", `Save failed: ${errorMsg(err)}`, "error"); + } finally { + btn.disabled = false; + btn.textContent = "Save Settings"; + } +} + +// ───────────────────────────────────────────── +// Section: Notifications View +// ───────────────────────────────────────────── + +async function loadNotificationSettings(): Promise { + const config = await ChromeStorage.get(STORAGE_CONFIG_KEY); + const notif = config?.notifications; + + const notifyCb = document.getElementById("notify-on-completion") as HTMLInputElement; + const autoOpenCb = document.getElementById("auto-open-file") as HTMLInputElement; + + notifyCb.checked = notif?.notifyOnCompletion ?? false; + autoOpenCb.checked = notif?.autoOpenFile ?? false; + + document + .getElementById("save-notification-settings") + ?.addEventListener("click", saveNotificationSettings); +} + +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("notification-status", "Settings saved.", "success"); + } catch (err) { + showStatus("notification-status", `Save failed: ${errorMsg(err)}`, "error"); + } finally { + btn.disabled = false; + btn.textContent = "Save Settings"; + } +} + +// ───────────────────────────────────────────── +// Section: Advanced View +// ───────────────────────────────────────────── + +async function loadAdvancedSettings(): Promise { + const config = await ChromeStorage.get(STORAGE_CONFIG_KEY); + const adv = config?.advanced; + + const get = (id: string) => document.getElementById(id) as HTMLInputElement; + + get("max-retries").value = (adv?.maxRetries ?? DEFAULT_MAX_RETRIES).toString(); + get("retry-delay").value = (adv?.retryDelayMs ?? INITIAL_RETRY_DELAY_MS).toString(); + get("retry-backoff").value = (adv?.retryBackoffFactor ?? RETRY_BACKOFF_FACTOR).toString(); + get("failure-rate").value = Math.round((adv?.fragmentFailureRate ?? MAX_FRAGMENT_FAILURE_RATE) * 100).toString(); + get("detection-cache-size").value = (adv?.detectionCacheSize ?? DEFAULT_DETECTION_CACHE_SIZE).toString(); + get("master-playlist-cache-size").value = (adv?.masterPlaylistCacheSize ?? DEFAULT_MASTER_PLAYLIST_CACHE_SIZE).toString(); + get("db-sync-interval").value = (adv?.dbSyncIntervalMs ?? DEFAULT_DB_SYNC_INTERVAL_MS).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; + btn.disabled = true; + btn.textContent = "Saving…"; + + try { + const get = (id: string) => document.getElementById(id) as HTMLInputElement; + + const maxRetries = Math.max(1, Math.min(10, parseInt(get("max-retries").value) || DEFAULT_MAX_RETRIES)); + const retryDelayMs = Math.max(50, Math.min(1000, parseInt(get("retry-delay").value) || INITIAL_RETRY_DELAY_MS)); + const retryBackoffFactor = Math.max(1.0, Math.min(3.0, parseFloat(get("retry-backoff").value) || RETRY_BACKOFF_FACTOR)); + const fragmentFailureRate = Math.max(0.05, Math.min(0.5, (parseInt(get("failure-rate").value) || Math.round(MAX_FRAGMENT_FAILURE_RATE * 100)) / 100)); + const detectionCacheSize = Math.max(100, Math.min(2000, parseInt(get("detection-cache-size").value) || DEFAULT_DETECTION_CACHE_SIZE)); + const masterPlaylistCacheSize = Math.max(10, Math.min(200, parseInt(get("master-playlist-cache-size").value) || DEFAULT_MASTER_PLAYLIST_CACHE_SIZE)); + const dbSyncIntervalMs = Math.max(100, Math.min(2000, parseInt(get("db-sync-interval").value) || DEFAULT_DB_SYNC_INTERVAL_MS)); + + const config = (await ChromeStorage.get(STORAGE_CONFIG_KEY)) ?? {}; + config.advanced = { + maxRetries, + retryDelayMs, + retryBackoffFactor, + fragmentFailureRate, + detectionCacheSize, + masterPlaylistCacheSize, + dbSyncIntervalMs, + }; + await ChromeStorage.set(STORAGE_CONFIG_KEY, config); + showStatus("advanced-status", "Settings saved.", "success"); + } catch (err) { + showStatus("advanced-status", `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.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.toString(); + + showStatus("advanced-status", "Reset to defaults.", "success"); + } catch (err) { + showStatus("advanced-status", `Reset failed: ${errorMsg(err)}`, "error"); + } finally { + btn.disabled = false; + } +} + // ───────────────────────────────────────────── // Bootstrap // ───────────────────────────────────────────── diff --git a/src/service-worker.ts b/src/service-worker.ts index 628d394..d6b89c7 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -34,8 +34,15 @@ 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 { + deleteChunks, + getChunkCount, + getAllChunkDownloadIds, +} from "./core/database/chunks"; +import { + createOffscreenDocument, + closeOffscreenDocument, +} from "./core/ffmpeg/offscreen-manager"; import { DEFAULT_MAX_CONCURRENT, DEFAULT_FFMPEG_TIMEOUT_MS, @@ -54,19 +61,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 @@ -138,16 +151,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) }; } } @@ -394,8 +402,11 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 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: "HEAD", + method: isManifest ? "GET" : "HEAD", signal: AbortSignal.timeout(5000), }); return { ok: res.ok, status: res.status }; @@ -522,13 +533,22 @@ async function handleDownloadRequest(payload: { const config = await ChromeStorage.get(STORAGE_CONFIG_KEY); const maxConcurrent = - (await ChromeStorage.get(MAX_CONCURRENT_KEY)) || DEFAULT_MAX_CONCURRENT; + (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 downloadManager = new DownloadManager({ maxConcurrent, 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 @@ -537,7 +557,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 @@ -602,9 +624,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)); } } @@ -640,7 +668,7 @@ async function handleDownloadRequest(payload: { // 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); @@ -671,6 +699,37 @@ 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 config = await ChromeStorage.get(STORAGE_CONFIG_KEY); + const notif = config?.notifications; + if (!notif?.notifyOnCompletion && !notif?.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 (notif.notifyOnCompletion) { + chrome.notifications.create(`download-complete-${downloadId}`, { + type: "basic", + iconUrl: "icons/icon-48.png", + title: "Download complete", + message: `${title}\n${filename}`, + }); + } + + if (notif.autoOpenFile && state.chromeDownloadId != null) { + chrome.downloads.show(state.chromeDownloadId); + } + } catch (err) { + logger.warn("handlePostDownloadActions failed:", err); + } } /** @@ -744,6 +803,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 @@ -764,27 +842,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, @@ -824,7 +882,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(); @@ -915,129 +976,180 @@ 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 ChromeStorage.get(STORAGE_CONFIG_KEY); + const maxConcurrent = + (await ChromeStorage.get(MAX_CONCURRENT_KEY)) || + DEFAULT_MAX_CONCURRENT; + const ffmpegTimeout = config?.ffmpegTimeout || DEFAULT_FFMPEG_TIMEOUT_MS; + const recordingHandlerOptions = { + 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); - const cfg = await ChromeStorage.get(STORAGE_CONFIG_KEY); - if (cfg?.historyEnabled === false) 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 ChromeStorage.get(STORAGE_CONFIG_KEY); - if (cfg?.historyEnabled === false) await deleteDownload(stateId); - }) - .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, + maxConcurrent, + ffmpegTimeout, + selectedBandwidth: payload.selectedBandwidth, + ...recordingHandlerOptions, + }) + : new HlsRecordingHandler({ onProgress, maxConcurrent, ffmpegTimeout, ...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 ChromeStorage.get(STORAGE_CONFIG_KEY); + if (cfg?.historyEnabled === false) 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 ChromeStorage.get(STORAGE_CONFIG_KEY); + if (cfg?.historyEnabled === false) 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 }; } /** @@ -1050,7 +1162,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..44e7cc8 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -46,6 +46,36 @@ 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"; From 548fff1f638d3bf843ba9ed6e42e73aa3a35c37b Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Wed, 4 Mar 2026 03:10:54 -0500 Subject: [PATCH 15/21] refactor(config): introduce loadSettings() and consolidate maxConcurrent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add src/core/storage/settings.ts with AppSettings interface and loadSettings() — single function that reads StorageConfig and applies all defaults, eliminating scattered ?? DEFAULT_X fallbacks - Migrate all read-only config calls in service-worker.ts and options.ts load functions to use loadSettings(); save functions keep raw ChromeStorage reads to preserve merge-and-write-back pattern - Consolidate maxConcurrent into StorageConfig (was stored under a separate "max_concurrent" key); remove MAX_CONCURRENT_KEY constant - loadSettings() now does a single storage read instead of Promise.all with two keys Co-Authored-By: Claude Sonnet 4.6 --- src/core/storage/settings.ts | 117 +++++++++++++++++++++++++++++++++++ src/core/types/index.ts | 1 + src/options/options.ts | 85 +++++++++++-------------- src/service-worker.ts | 84 +++++++++++-------------- src/shared/constants.ts | 1 - 5 files changed, 189 insertions(+), 99 deletions(-) create mode 100644 src/core/storage/settings.ts diff --git a/src/core/storage/settings.ts b/src/core/storage/settings.ts new file mode 100644 index 0000000..a52c65f --- /dev/null +++ b/src/core/storage/settings.ts @@ -0,0 +1,117 @@ +/** + * 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, +} 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 ?? "MediaBridge Uploads", + }, + + 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 ac23797..60e3645 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -88,6 +88,7 @@ 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; diff --git a/src/options/options.ts b/src/options/options.ts index 0131eaf..fb85585 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -4,6 +4,7 @@ */ import { ChromeStorage } from "../core/storage/chrome-storage"; +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"; @@ -22,7 +23,6 @@ import { MS_PER_MINUTE, DEFAULT_MAX_CONCURRENT, STORAGE_CONFIG_KEY, - MAX_CONCURRENT_KEY, DEFAULT_MAX_RETRIES, DEFAULT_MIN_POLL_MS, DEFAULT_MAX_POLL_MS, @@ -148,17 +148,13 @@ async function toggleTheme(): Promise { // ───────────────────────────────────────────── async function loadDownloadSettings(): Promise { - const config = await ChromeStorage.get(STORAGE_CONFIG_KEY); - const maxConc = await ChromeStorage.get(MAX_CONCURRENT_KEY); + const config = await loadSettings(); const maxInput = document.getElementById("max-concurrent") as HTMLInputElement; const timeoutInput = document.getElementById("ffmpeg-timeout") as HTMLInputElement; - if (maxInput && maxConc) maxInput.value = maxConc.toString(); - if (timeoutInput) { - const ms = config?.ffmpegTimeout ?? DEFAULT_FFMPEG_TIMEOUT_MS; - timeoutInput.value = Math.round(ms / MS_PER_MINUTE).toString(); - } + maxInput.value = config.maxConcurrent.toString(); + timeoutInput.value = Math.round(config.ffmpegTimeout / MS_PER_MINUTE).toString(); document .getElementById("save-download-settings") @@ -184,11 +180,8 @@ async function saveDownloadSettings(): Promise { ) * MS_PER_MINUTE; config.ffmpegTimeout = clampedMs; + config.maxConcurrent = parseInt(maxInput.value) || DEFAULT_MAX_CONCURRENT; await ChromeStorage.set(STORAGE_CONFIG_KEY, config); - await ChromeStorage.set( - MAX_CONCURRENT_KEY, - parseInt(maxInput.value) || DEFAULT_MAX_CONCURRENT, - ); showStatus("settings-status", "Settings saved.", "success"); } catch (err) { @@ -230,17 +223,15 @@ function setupCloudProviderTabs(): void { // -- Google Drive -- async function loadDriveSettings(): Promise { - const config = await ChromeStorage.get(STORAGE_CONFIG_KEY); + 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; - if (config?.googleDrive) { - enabledCb.checked = config.googleDrive.enabled ?? false; - folderNameIn.value = config.googleDrive.folderName ?? "MediaBridge Uploads"; - folderIdIn.value = config.googleDrive.targetFolderId ?? ""; - } + enabledCb.checked = config.googleDrive.enabled; + folderNameIn.value = config.googleDrive.folderName; + folderIdIn.value = config.googleDrive.targetFolderId ?? ""; const driveSettingsEl = document.getElementById("drive-settings"); if (driveSettingsEl) @@ -335,20 +326,17 @@ async function saveDriveSettings(): Promise { // -- S3 -- async function loadS3Settings(): Promise { - const config = await ChromeStorage.get(STORAGE_CONFIG_KEY); - const s3 = config?.s3; + const { s3 } = await loadSettings(); const get = (id: string) => document.getElementById(id) as HTMLInputElement; - if (s3) { - get("s3-enabled").checked = s3.enabled ?? false; - 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 ?? ""; - } + 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); } @@ -459,12 +447,12 @@ function flashItem(el: HTMLElement | null): void { } async function loadHistory(): Promise { - const config = await ChromeStorage.get(STORAGE_CONFIG_KEY); + const config = await loadSettings(); const historyEnabledCb = document.getElementById( "history-enabled", ) as HTMLInputElement; - historyEnabledCb.checked = config?.historyEnabled !== false; + historyEnabledCb.checked = config.historyEnabled; historyEnabledCb.addEventListener("change", onHistoryEnabledChange); @@ -505,7 +493,7 @@ async function loadHistory(): Promise { rerenderHistory(); }); - if (config?.historyEnabled === false) { + if (!config.historyEnabled) { showHistoryDisabled(); return; } @@ -999,14 +987,13 @@ function iconSpinner(): string { // ───────────────────────────────────────────── async function loadRecordingSettings(): Promise { - const config = await ChromeStorage.get(STORAGE_CONFIG_KEY); - const rec = config?.recording; + const { recording } = await loadSettings(); const get = (id: string) => document.getElementById(id) as HTMLInputElement; - get("poll-min").value = (rec?.minPollIntervalMs ?? DEFAULT_MIN_POLL_MS).toString(); - get("poll-max").value = (rec?.maxPollIntervalMs ?? DEFAULT_MAX_POLL_MS).toString(); - get("poll-fraction").value = (rec?.pollFraction ?? DEFAULT_POLL_FRACTION).toString(); + get("poll-min").value = recording.minPollIntervalMs.toString(); + get("poll-max").value = recording.maxPollIntervalMs.toString(); + get("poll-fraction").value = recording.pollFraction.toString(); document .getElementById("save-recording-settings") @@ -1047,14 +1034,13 @@ async function saveRecordingSettings(): Promise { // ───────────────────────────────────────────── async function loadNotificationSettings(): Promise { - const config = await ChromeStorage.get(STORAGE_CONFIG_KEY); - const notif = config?.notifications; + const { notifications } = await loadSettings(); const notifyCb = document.getElementById("notify-on-completion") as HTMLInputElement; const autoOpenCb = document.getElementById("auto-open-file") as HTMLInputElement; - notifyCb.checked = notif?.notifyOnCompletion ?? false; - autoOpenCb.checked = notif?.autoOpenFile ?? false; + notifyCb.checked = notifications.notifyOnCompletion; + autoOpenCb.checked = notifications.autoOpenFile; document .getElementById("save-notification-settings") @@ -1090,18 +1076,17 @@ async function saveNotificationSettings(): Promise { // ───────────────────────────────────────────── async function loadAdvancedSettings(): Promise { - const config = await ChromeStorage.get(STORAGE_CONFIG_KEY); - const adv = config?.advanced; + const { advanced } = await loadSettings(); const get = (id: string) => document.getElementById(id) as HTMLInputElement; - get("max-retries").value = (adv?.maxRetries ?? DEFAULT_MAX_RETRIES).toString(); - get("retry-delay").value = (adv?.retryDelayMs ?? INITIAL_RETRY_DELAY_MS).toString(); - get("retry-backoff").value = (adv?.retryBackoffFactor ?? RETRY_BACKOFF_FACTOR).toString(); - get("failure-rate").value = Math.round((adv?.fragmentFailureRate ?? MAX_FRAGMENT_FAILURE_RATE) * 100).toString(); - get("detection-cache-size").value = (adv?.detectionCacheSize ?? DEFAULT_DETECTION_CACHE_SIZE).toString(); - get("master-playlist-cache-size").value = (adv?.masterPlaylistCacheSize ?? DEFAULT_MASTER_PLAYLIST_CACHE_SIZE).toString(); - get("db-sync-interval").value = (adv?.dbSyncIntervalMs ?? DEFAULT_DB_SYNC_INTERVAL_MS).toString(); + get("max-retries").value = advanced.maxRetries.toString(); + get("retry-delay").value = advanced.retryDelayMs.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.toString(); document .getElementById("save-advanced-settings") diff --git a/src/service-worker.ts b/src/service-worker.ts index d6b89c7..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, @@ -44,11 +45,8 @@ import { closeOffscreenDocument, } from "./core/ffmpeg/offscreen-manager"; import { - DEFAULT_MAX_CONCURRENT, - DEFAULT_FFMPEG_TIMEOUT_MS, KEEPALIVE_INTERVAL_MS, STORAGE_CONFIG_KEY, - MAX_CONCURRENT_KEY, } from "./shared/constants"; const activeDownloads = new Map>(); @@ -531,24 +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, - 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, + 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 @@ -640,8 +633,8 @@ async function handleDownloadRequest(payload: { downloadPromise .then(async () => { await cleanupDownloadResources(normalizedUrl); - const cfg = await ChromeStorage.get(STORAGE_CONFIG_KEY); - if (cfg?.historyEnabled === false) { + const cfg = await loadSettings(); + if (!cfg.historyEnabled) { const completed = await getDownloadByUrl(normalizedUrl); if (completed) await deleteDownload(completed.id); } @@ -658,8 +651,8 @@ async function handleDownloadRequest(payload: { logger.error(`Download failed for ${url}:`, String(error)); } await cleanupDownloadResources(normalizedUrl); - const cfg = await ChromeStorage.get(STORAGE_CONFIG_KEY); - if (cfg?.historyEnabled === false) { + const cfg = await loadSettings(); + if (!cfg.historyEnabled) { const failed = await getDownloadByUrl(normalizedUrl); if (failed) await deleteDownload(failed.id); } @@ -705,9 +698,8 @@ function sendDownloadComplete(downloadId: string): void { async function handlePostDownloadActions(downloadId: string): Promise { try { - const config = await ChromeStorage.get(STORAGE_CONFIG_KEY); - const notif = config?.notifications; - if (!notif?.notifyOnCompletion && !notif?.autoOpenFile) return; + const { notifications } = await loadSettings(); + if (!notifications.notifyOnCompletion && !notifications.autoOpenFile) return; const state = await getDownload(downloadId); if (!state) return; @@ -715,7 +707,7 @@ async function handlePostDownloadActions(downloadId: string): Promise { const title = state.metadata.title || "Media Bridge"; const filename = state.localPath?.split(/[/\\]/).pop() ?? "Download"; - if (notif.notifyOnCompletion) { + if (notifications.notifyOnCompletion) { chrome.notifications.create(`download-complete-${downloadId}`, { type: "basic", iconUrl: "icons/icon-48.png", @@ -724,7 +716,7 @@ async function handlePostDownloadActions(downloadId: string): Promise { }); } - if (notif.autoOpenFile && state.chromeDownloadId != null) { + if (notifications.autoOpenFile && state.chromeDownloadId != null) { chrome.downloads.show(state.chromeDownloadId); } } catch (err) { @@ -1015,20 +1007,18 @@ async function handleStartRecording(payload: { }; } - 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; + const config = await loadSettings(); const recordingHandlerOptions = { - 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, + 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, }; // Build initial download state @@ -1079,12 +1069,10 @@ async function handleStartRecording(payload: { metadata.format === VideoFormat.DASH ? new DashRecordingHandler({ onProgress, - maxConcurrent, - ffmpegTimeout, selectedBandwidth: payload.selectedBandwidth, ...recordingHandlerOptions, }) - : new HlsRecordingHandler({ onProgress, maxConcurrent, ffmpegTimeout, ...recordingHandlerOptions }); + : new HlsRecordingHandler({ onProgress, ...recordingHandlerOptions }); const finalFilename = resolveFilename(url, metadata, filename, tabTitle, website); @@ -1099,8 +1087,8 @@ async function handleStartRecording(payload: { .then(async () => { await cleanupDownloadResources(normalizedUrl); sendDownloadComplete(stateId); - const cfg = await ChromeStorage.get(STORAGE_CONFIG_KEY); - if (cfg?.historyEnabled === false) await deleteDownload(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 @@ -1123,8 +1111,8 @@ async function handleStartRecording(payload: { ); } await cleanupDownloadResources(normalizedUrl); - const cfg = await ChromeStorage.get(STORAGE_CONFIG_KEY); - if (cfg?.historyEnabled === false) await deleteDownload(stateId); + const cfg = await loadSettings(); + if (!cfg.historyEnabled) await deleteDownload(stateId); }) .finally(() => { activeDownloads.delete(normalizedUrl); diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 44e7cc8..69a2e98 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -79,4 +79,3 @@ 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"; From 48e632f4ba1d8f7e87f8629c57ec7013addd8621 Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Wed, 4 Mar 2026 03:54:47 -0500 Subject: [PATCH 16/21] refactor(options): consolidate constants and unify notifications via toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add src/options/constants.ts for options-page-only validation bounds and UI timing constants (TOAST_DURATION_MS, MS_PER_DAY, MIN/MAX clamps) - Add DEFAULT_GOOGLE_DRIVE_FOLDER_NAME to shared/constants (used by both options.ts and settings.ts) - Replace showStatus(elementId, msg, type) with showStatus(msg, type) that delegates to showToast — all save confirmations now use the same bottom toast as history actions - Remove all status-msg div elements and their CSS from options.html - Replace every hardcoded clamp literal in saveRecordingSettings and saveAdvancedSettings with named constants Co-Authored-By: Claude Sonnet 4.6 --- src/core/storage/settings.ts | 3 +- src/options/constants.ts | 33 ++++++++++ src/options/options.html | 33 ---------- src/options/options.ts | 113 ++++++++++++++++++++--------------- src/shared/constants.ts | 3 + 5 files changed, 102 insertions(+), 83 deletions(-) create mode 100644 src/options/constants.ts diff --git a/src/core/storage/settings.ts b/src/core/storage/settings.ts index a52c65f..0d25cc3 100644 --- a/src/core/storage/settings.ts +++ b/src/core/storage/settings.ts @@ -22,6 +22,7 @@ import { DEFAULT_MASTER_PLAYLIST_CACHE_SIZE, DEFAULT_DB_SYNC_INTERVAL_MS, STORAGE_CONFIG_KEY, + DEFAULT_GOOGLE_DRIVE_FOLDER_NAME, } from "../../shared/constants"; export interface AppSettings { @@ -80,7 +81,7 @@ export async function loadSettings(): Promise { enabled: raw?.googleDrive?.enabled ?? false, targetFolderId: raw?.googleDrive?.targetFolderId, createFolderIfNotExists: raw?.googleDrive?.createFolderIfNotExists ?? false, - folderName: raw?.googleDrive?.folderName ?? "MediaBridge Uploads", + folderName: raw?.googleDrive?.folderName ?? DEFAULT_GOOGLE_DRIVE_FOLDER_NAME, }, s3: { diff --git a/src/options/constants.ts b/src/options/constants.ts new file mode 100644 index 0000000..451eb38 --- /dev/null +++ b/src/options/constants.ts @@ -0,0 +1,33 @@ +/** + * Options-page-only constants. + * + * Values here are used exclusively within the options UI. + * Cross-module constants live in src/shared/constants.ts. + */ + +export const TOAST_DURATION_MS = 3_000; +export const MS_PER_DAY = 86_400_000; + +// ---- Recording settings validation bounds ---- +export const MIN_POLL_MIN_MS = 500; +export const MAX_POLL_MIN_MS = 5_000; +export const MIN_POLL_MAX_MS = 2_000; +export const MAX_POLL_MAX_MS = 30_000; +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_MS = 50; +export const MAX_RETRY_DELAY_MS = 1_000; +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_INTERVAL_MS = 100; +export const MAX_DB_SYNC_INTERVAL_MS = 2_000; diff --git a/src/options/options.html b/src/options/options.html index 84599ad..0e63ca0 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -460,33 +460,6 @@ color: #dc2626; } - /* ---- Status Messages ---- */ - .status-msg { - padding: 11px 14px; - border-radius: var(--radius-sm); - margin-top: 12px; - font-size: 13px; - border-left: 3px solid transparent; - display: none; - } - - .status-msg.success { - background: var(--surface-1); - border-left-color: var(--success); - color: var(--success); - } - - .status-msg.error { - background: var(--surface-1); - border-left-color: var(--error); - color: var(--error); - } - - .status-msg.info { - background: var(--surface-1); - border-left-color: var(--info); - color: var(--info); - } /* ---- Provider Tabs ---- */ .provider-tabs { @@ -1113,7 +1086,6 @@

FFmpeg

-
@@ -1168,7 +1140,6 @@

Cloud Providers

-
@@ -1226,7 +1197,6 @@

Cloud Providers

-
@@ -1347,7 +1317,6 @@

HLS Poll Interval

-
@@ -1378,7 +1347,6 @@

Post-Download

-
@@ -1501,7 +1469,6 @@

Performance

-
diff --git a/src/options/options.ts b/src/options/options.ts index fb85585..ccca99c 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -33,9 +33,34 @@ import { INITIAL_RETRY_DELAY_MS, RETRY_BACKOFF_FACTOR, MAX_FRAGMENT_FAILURE_RATE, + DEFAULT_GOOGLE_DRIVE_FOLDER_NAME, } from "../shared/constants"; -const STATUS_MESSAGE_DURATION_MS = 4000; +import { + TOAST_DURATION_MS, + MS_PER_DAY, + MIN_POLL_MIN_MS, + MAX_POLL_MIN_MS, + MIN_POLL_MAX_MS, + MAX_POLL_MAX_MS, + MIN_POLL_FRACTION, + MAX_POLL_FRACTION, + MIN_MAX_RETRIES, + MAX_MAX_RETRIES, + MIN_RETRY_DELAY_MS, + MAX_RETRY_DELAY_MS, + 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_INTERVAL_MS, + MAX_DB_SYNC_INTERVAL_MS, +} from "./constants"; + const FINISHED_STAGES = new Set([ DownloadStage.COMPLETED, DownloadStage.FAILED, @@ -183,9 +208,9 @@ async function saveDownloadSettings(): Promise { config.maxConcurrent = parseInt(maxInput.value) || DEFAULT_MAX_CONCURRENT; await ChromeStorage.set(STORAGE_CONFIG_KEY, config); - showStatus("settings-status", "Settings saved.", "success"); + showStatus("Settings saved.", "success"); } catch (err) { - showStatus("settings-status", `Save failed: ${errorMsg(err)}`, "error"); + showStatus(`Save failed: ${errorMsg(err)}`, "error"); } finally { btn.disabled = false; btn.textContent = "Save Settings"; @@ -276,10 +301,10 @@ async function handleAuth(): Promise { btn.textContent = "Authenticating…"; try { await GoogleAuth.authenticate(GOOGLE_DRIVE_SCOPES); - showStatus("drive-status", "Authenticated with Google.", "success"); + showStatus("Authenticated with Google.", "success"); await checkAuthStatus(); } catch (err) { - showStatus("drive-status", `Authentication failed: ${errorMsg(err)}`, "error"); + showStatus(`Authentication failed: ${errorMsg(err)}`, "error"); } finally { btn.disabled = false; btn.textContent = "Sign in with Google"; @@ -289,10 +314,10 @@ async function handleAuth(): Promise { async function handleSignOut(): Promise { try { await GoogleAuth.signOut(); - showStatus("drive-status", "Signed out.", "success"); + showStatus("Signed out.", "success"); await checkAuthStatus(); } catch (err) { - showStatus("drive-status", `Sign out failed: ${errorMsg(err)}`, "error"); + showStatus(`Sign out failed: ${errorMsg(err)}`, "error"); } } @@ -309,14 +334,14 @@ async function saveDriveSettings(): Promise { const config = (await ChromeStorage.get(STORAGE_CONFIG_KEY)) ?? {}; config.googleDrive = { enabled: enabledCb.checked, - folderName: folderNameIn.value || "MediaBridge Uploads", + folderName: folderNameIn.value || DEFAULT_GOOGLE_DRIVE_FOLDER_NAME, targetFolderId: folderIdIn.value || undefined, createFolderIfNotExists: true, }; await ChromeStorage.set(STORAGE_CONFIG_KEY, config); - showStatus("drive-status", "Settings saved.", "success"); + showStatus("Settings saved.", "success"); } catch (err) { - showStatus("drive-status", `Save failed: ${errorMsg(err)}`, "error"); + showStatus(`Save failed: ${errorMsg(err)}`, "error"); } finally { btn.disabled = false; btn.textContent = "Save Settings"; @@ -363,9 +388,9 @@ async function saveS3Settings(): Promise { prefix: get("s3-prefix") || undefined, }; await ChromeStorage.set(STORAGE_CONFIG_KEY, config); - showStatus("s3-status", "Settings saved.", "success"); + showStatus("Settings saved.", "success"); } catch (err) { - showStatus("s3-status", `Save failed: ${errorMsg(err)}`, "error"); + showStatus(`Save failed: ${errorMsg(err)}`, "error"); } finally { btn.disabled = false; btn.textContent = "Save Settings"; @@ -560,10 +585,9 @@ function applyFilters(all: DownloadState[]): DownloadState[] { function dateInRange(ts: number, range: string): boolean { if (range === "all") return true; const now = Date.now(); - const day = 86400_000; - if (range === "today") return ts >= now - day; - if (range === "week") return ts >= now - 7 * day; - if (range === "month") return ts >= now - 30 * day; + 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; } @@ -882,7 +906,7 @@ function showToast(message: string, type: "success" | "error" | "warning"): void toast.classList.remove("show"); void toast.offsetWidth; toast.classList.add("show"); - toastTimer = setTimeout(() => toast.classList.remove("show"), 3000); + toastTimer = setTimeout(() => toast.classList.remove("show"), TOAST_DURATION_MS); } async function checkManifest(url: string): Promise { @@ -906,17 +930,8 @@ async function checkManifest(url: string): Promise { // Section: Utilities // ───────────────────────────────────────────── -function showStatus( - elementId: string, - message: string, - type: "success" | "error" | "info", -): void { - const el = document.getElementById(elementId); - if (!el) return; - el.className = `status-msg ${type}`; - el.textContent = message; - el.style.display = "block"; - setTimeout(() => (el.style.display = "none"), STATUS_MESSAGE_DURATION_MS); +function showStatus(message: string, type: "success" | "error" | "warning" | "info"): void { + showToast(message, type === "info" ? "warning" : type); } function errorMsg(err: unknown): string { @@ -937,8 +952,8 @@ 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 < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`; - return `${Math.floor(diff / 86400_000)}d ago`; + if (diff < MS_PER_DAY) return `${Math.floor(diff / 3_600_000)}h ago`; + return `${Math.floor(diff / MS_PER_DAY)}d ago`; } // ───────────────────────────────────────────── @@ -1008,21 +1023,21 @@ async function saveRecordingSettings(): Promise { try { const get = (id: string) => document.getElementById(id) as HTMLInputElement; - const minPollIntervalMs = Math.max(500, Math.min(5000, parseInt(get("poll-min").value) || DEFAULT_MIN_POLL_MS)); - const maxPollIntervalMs = Math.max(2000, Math.min(30000, parseInt(get("poll-max").value) || DEFAULT_MAX_POLL_MS)); - const pollFraction = Math.max(0.25, Math.min(1.0, parseFloat(get("poll-fraction").value) || DEFAULT_POLL_FRACTION)); + const minPollIntervalMs = Math.max(MIN_POLL_MIN_MS, Math.min(MAX_POLL_MIN_MS, parseInt(get("poll-min").value) || DEFAULT_MIN_POLL_MS)); + const maxPollIntervalMs = Math.max(MIN_POLL_MAX_MS, Math.min(MAX_POLL_MAX_MS, parseInt(get("poll-max").value) || DEFAULT_MAX_POLL_MS)); + const pollFraction = Math.max(MIN_POLL_FRACTION, Math.min(MAX_POLL_FRACTION, parseFloat(get("poll-fraction").value) || DEFAULT_POLL_FRACTION)); if (minPollIntervalMs >= maxPollIntervalMs) { - showStatus("recording-status", "Minimum poll interval must be less than maximum.", "error"); + showStatus("Minimum poll interval must be less than maximum.", "error"); return; } const config = (await ChromeStorage.get(STORAGE_CONFIG_KEY)) ?? {}; config.recording = { minPollIntervalMs, maxPollIntervalMs, pollFraction }; await ChromeStorage.set(STORAGE_CONFIG_KEY, config); - showStatus("recording-status", "Settings saved.", "success"); + showStatus("Settings saved.", "success"); } catch (err) { - showStatus("recording-status", `Save failed: ${errorMsg(err)}`, "error"); + showStatus(`Save failed: ${errorMsg(err)}`, "error"); } finally { btn.disabled = false; btn.textContent = "Save Settings"; @@ -1062,9 +1077,9 @@ async function saveNotificationSettings(): Promise { autoOpenFile: autoOpenCb.checked, }; await ChromeStorage.set(STORAGE_CONFIG_KEY, config); - showStatus("notification-status", "Settings saved.", "success"); + showStatus("Settings saved.", "success"); } catch (err) { - showStatus("notification-status", `Save failed: ${errorMsg(err)}`, "error"); + showStatus(`Save failed: ${errorMsg(err)}`, "error"); } finally { btn.disabled = false; btn.textContent = "Save Settings"; @@ -1104,13 +1119,13 @@ async function saveAdvancedSettings(): Promise { try { const get = (id: string) => document.getElementById(id) as HTMLInputElement; - const maxRetries = Math.max(1, Math.min(10, parseInt(get("max-retries").value) || DEFAULT_MAX_RETRIES)); - const retryDelayMs = Math.max(50, Math.min(1000, parseInt(get("retry-delay").value) || INITIAL_RETRY_DELAY_MS)); - const retryBackoffFactor = Math.max(1.0, Math.min(3.0, parseFloat(get("retry-backoff").value) || RETRY_BACKOFF_FACTOR)); - const fragmentFailureRate = Math.max(0.05, Math.min(0.5, (parseInt(get("failure-rate").value) || Math.round(MAX_FRAGMENT_FAILURE_RATE * 100)) / 100)); - const detectionCacheSize = Math.max(100, Math.min(2000, parseInt(get("detection-cache-size").value) || DEFAULT_DETECTION_CACHE_SIZE)); - const masterPlaylistCacheSize = Math.max(10, Math.min(200, parseInt(get("master-playlist-cache-size").value) || DEFAULT_MASTER_PLAYLIST_CACHE_SIZE)); - const dbSyncIntervalMs = Math.max(100, Math.min(2000, parseInt(get("db-sync-interval").value) || DEFAULT_DB_SYNC_INTERVAL_MS)); + const maxRetries = Math.max(MIN_MAX_RETRIES, Math.min(MAX_MAX_RETRIES, parseInt(get("max-retries").value) || DEFAULT_MAX_RETRIES)); + const retryDelayMs = Math.max(MIN_RETRY_DELAY_MS, Math.min(MAX_RETRY_DELAY_MS, parseInt(get("retry-delay").value) || INITIAL_RETRY_DELAY_MS)); + const retryBackoffFactor = Math.max(MIN_RETRY_BACKOFF_FACTOR, Math.min(MAX_RETRY_BACKOFF_FACTOR, parseFloat(get("retry-backoff").value) || RETRY_BACKOFF_FACTOR)); + const fragmentFailureRate = Math.max(MIN_FAILURE_RATE, Math.min(MAX_FAILURE_RATE, (parseInt(get("failure-rate").value) || Math.round(MAX_FRAGMENT_FAILURE_RATE * 100)) / 100)); + const detectionCacheSize = Math.max(MIN_DETECTION_CACHE_SIZE, Math.min(MAX_DETECTION_CACHE_SIZE, parseInt(get("detection-cache-size").value) || DEFAULT_DETECTION_CACHE_SIZE)); + const masterPlaylistCacheSize = Math.max(MIN_MASTER_PLAYLIST_CACHE_SIZE, Math.min(MAX_MASTER_PLAYLIST_CACHE_SIZE, parseInt(get("master-playlist-cache-size").value) || DEFAULT_MASTER_PLAYLIST_CACHE_SIZE)); + const dbSyncIntervalMs = Math.max(MIN_DB_SYNC_INTERVAL_MS, Math.min(MAX_DB_SYNC_INTERVAL_MS, parseInt(get("db-sync-interval").value) || DEFAULT_DB_SYNC_INTERVAL_MS)); const config = (await ChromeStorage.get(STORAGE_CONFIG_KEY)) ?? {}; config.advanced = { @@ -1123,9 +1138,9 @@ async function saveAdvancedSettings(): Promise { dbSyncIntervalMs, }; await ChromeStorage.set(STORAGE_CONFIG_KEY, config); - showStatus("advanced-status", "Settings saved.", "success"); + showStatus("Settings saved.", "success"); } catch (err) { - showStatus("advanced-status", `Save failed: ${errorMsg(err)}`, "error"); + showStatus(`Save failed: ${errorMsg(err)}`, "error"); } finally { btn.disabled = false; btn.textContent = "Save Settings"; @@ -1151,9 +1166,9 @@ async function resetAdvancedSettings(): Promise { get("master-playlist-cache-size").value = DEFAULT_MASTER_PLAYLIST_CACHE_SIZE.toString(); get("db-sync-interval").value = DEFAULT_DB_SYNC_INTERVAL_MS.toString(); - showStatus("advanced-status", "Reset to defaults.", "success"); + showStatus("Reset to defaults.", "success"); } catch (err) { - showStatus("advanced-status", `Reset failed: ${errorMsg(err)}`, "error"); + showStatus(`Reset failed: ${errorMsg(err)}`, "error"); } finally { btn.disabled = false; } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 69a2e98..89255a7 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -79,3 +79,6 @@ export const DEFAULT_DB_SYNC_INTERVAL_MS = 500; // ---- Chrome storage keys ---- export const STORAGE_CONFIG_KEY = "storage_config"; + +/** Default Google Drive upload folder name */ +export const DEFAULT_GOOGLE_DRIVE_FOLDER_NAME = "MediaBridge Uploads"; From 6783cff473bcd1a6eaff250a2cab7733435da44e Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Wed, 4 Mar 2026 04:02:28 -0500 Subject: [PATCH 17/21] refactor(options): unify all time fields to seconds in the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All time inputs now display in seconds; internal storage remains ms throughout. Conversion happens only at the options page boundary (divide on load, multiply on save). - ffmpeg-timeout: minutes → seconds (15 min = 900 s, range 300–3 600) - poll-min/max: ms → seconds (1 000 ms = 1 s, 10 000 ms = 10 s) - retry-delay: ms → seconds (100 ms = 0.1 s) - db-sync-interval: ms → seconds (500 ms = 0.5 s) Remove DEFAULT_FFMPEG_TIMEOUT_MINUTES, MIN/MAX_FFMPEG_TIMEOUT_MINUTES, and MS_PER_MINUTE from shared/constants (options-only). Replace with _S equivalents in options/constants.ts alongside updated _S clamp bounds. Co-Authored-By: Claude Sonnet 4.6 --- src/options/constants.ts | 25 ++++++++++------- src/options/options.html | 57 ++++++++++++++++++++------------------- src/options/options.ts | 58 +++++++++++++++++----------------------- src/shared/constants.ts | 12 --------- 4 files changed, 70 insertions(+), 82 deletions(-) diff --git a/src/options/constants.ts b/src/options/constants.ts index 451eb38..9ce4197 100644 --- a/src/options/constants.ts +++ b/src/options/constants.ts @@ -5,22 +5,29 @@ * 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; -// ---- Recording settings validation bounds ---- -export const MIN_POLL_MIN_MS = 500; -export const MAX_POLL_MIN_MS = 5_000; -export const MIN_POLL_MAX_MS = 2_000; -export const MAX_POLL_MAX_MS = 30_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_MS = 50; -export const MAX_RETRY_DELAY_MS = 1_000; +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; @@ -29,5 +36,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_INTERVAL_MS = 100; -export const MAX_DB_SYNC_INTERVAL_MS = 2_000; +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 0e63ca0..64f1d05 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -1069,17 +1069,18 @@

Concurrency

FFmpeg

- + -
Maximum time for FFmpeg to process a video (5–60 min). Increase for very large files.
+
Maximum time for FFmpeg to process a video (300–3 600 s). Increase for very large files.
@@ -1271,32 +1272,32 @@

Recording

HLS Poll Interval

- + -
Floor for computed poll cadence (500–5 000 ms).
+
Floor for computed poll cadence (0.5–5 s).
- + -
Ceiling for computed poll cadence (2 000–30 000 ms).
+
Ceiling for computed poll cadence (2–30 s).
@@ -1372,18 +1373,18 @@

Retry & Reliability

Number of times to retry a failed segment or manifest fetch (1–10).
- + -
First backoff wait after a fetch failure (50–1 000 ms).
+
First backoff wait after a fetch failure (0.05–1 s).
@@ -1450,15 +1451,15 @@

Detection Caches

Performance

- +
How often download state is written to IndexedDB during segment downloads. Lower values increase write frequency.
diff --git a/src/options/options.ts b/src/options/options.ts index ccca99c..3dda37f 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -16,11 +16,6 @@ import { 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, DEFAULT_MAX_RETRIES, @@ -39,16 +34,19 @@ import { import { TOAST_DURATION_MS, MS_PER_DAY, - MIN_POLL_MIN_MS, - MAX_POLL_MIN_MS, - MIN_POLL_MAX_MS, - MAX_POLL_MAX_MS, + 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_MS, - MAX_RETRY_DELAY_MS, + MIN_RETRY_DELAY_S, + MAX_RETRY_DELAY_S, MIN_RETRY_BACKOFF_FACTOR, MAX_RETRY_BACKOFF_FACTOR, MIN_FAILURE_RATE, @@ -57,8 +55,8 @@ import { MAX_DETECTION_CACHE_SIZE, MIN_MASTER_PLAYLIST_CACHE_SIZE, MAX_MASTER_PLAYLIST_CACHE_SIZE, - MIN_DB_SYNC_INTERVAL_MS, - MAX_DB_SYNC_INTERVAL_MS, + MIN_DB_SYNC_S, + MAX_DB_SYNC_S, } from "./constants"; const FINISHED_STAGES = new Set([ @@ -179,7 +177,7 @@ async function loadDownloadSettings(): Promise { const timeoutInput = document.getElementById("ffmpeg-timeout") as HTMLInputElement; maxInput.value = config.maxConcurrent.toString(); - timeoutInput.value = Math.round(config.ffmpegTimeout / MS_PER_MINUTE).toString(); + timeoutInput.value = Math.round(config.ffmpegTimeout / 1000).toString(); document .getElementById("save-download-settings") @@ -196,15 +194,9 @@ async function saveDownloadSettings(): Promise { try { const config = (await ChromeStorage.get(STORAGE_CONFIG_KEY)) ?? {}; - const timeoutMinutes = - parseInt(timeoutInput.value) || DEFAULT_FFMPEG_TIMEOUT_MINUTES; - const clampedMs = - Math.max( - MIN_FFMPEG_TIMEOUT_MINUTES, - Math.min(MAX_FFMPEG_TIMEOUT_MINUTES, timeoutMinutes), - ) * MS_PER_MINUTE; - - config.ffmpegTimeout = clampedMs; + const timeoutSeconds = parseInt(timeoutInput.value) || DEFAULT_FFMPEG_TIMEOUT_S; + config.ffmpegTimeout = + Math.max(MIN_FFMPEG_TIMEOUT_S, Math.min(MAX_FFMPEG_TIMEOUT_S, timeoutSeconds)) * 1000; config.maxConcurrent = parseInt(maxInput.value) || DEFAULT_MAX_CONCURRENT; await ChromeStorage.set(STORAGE_CONFIG_KEY, config); @@ -1006,8 +998,8 @@ async function loadRecordingSettings(): Promise { const get = (id: string) => document.getElementById(id) as HTMLInputElement; - get("poll-min").value = recording.minPollIntervalMs.toString(); - get("poll-max").value = recording.maxPollIntervalMs.toString(); + get("poll-min").value = (recording.minPollIntervalMs / 1000).toString(); + get("poll-max").value = (recording.maxPollIntervalMs / 1000).toString(); get("poll-fraction").value = recording.pollFraction.toString(); document @@ -1023,8 +1015,8 @@ async function saveRecordingSettings(): Promise { try { const get = (id: string) => document.getElementById(id) as HTMLInputElement; - const minPollIntervalMs = Math.max(MIN_POLL_MIN_MS, Math.min(MAX_POLL_MIN_MS, parseInt(get("poll-min").value) || DEFAULT_MIN_POLL_MS)); - const maxPollIntervalMs = Math.max(MIN_POLL_MAX_MS, Math.min(MAX_POLL_MAX_MS, parseInt(get("poll-max").value) || DEFAULT_MAX_POLL_MS)); + const minPollIntervalMs = Math.max(MIN_POLL_MIN_S, Math.min(MAX_POLL_MIN_S, parseFloat(get("poll-min").value) || (DEFAULT_MIN_POLL_MS / 1000))) * 1000; + const maxPollIntervalMs = Math.max(MIN_POLL_MAX_S, Math.min(MAX_POLL_MAX_S, parseFloat(get("poll-max").value) || (DEFAULT_MAX_POLL_MS / 1000))) * 1000; const pollFraction = Math.max(MIN_POLL_FRACTION, Math.min(MAX_POLL_FRACTION, parseFloat(get("poll-fraction").value) || DEFAULT_POLL_FRACTION)); if (minPollIntervalMs >= maxPollIntervalMs) { @@ -1096,12 +1088,12 @@ async function loadAdvancedSettings(): Promise { const get = (id: string) => document.getElementById(id) as HTMLInputElement; get("max-retries").value = advanced.maxRetries.toString(); - get("retry-delay").value = advanced.retryDelayMs.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.toString(); + get("db-sync-interval").value = (advanced.dbSyncIntervalMs / 1000).toString(); document .getElementById("save-advanced-settings") @@ -1120,12 +1112,12 @@ async function saveAdvancedSettings(): Promise { const get = (id: string) => document.getElementById(id) as HTMLInputElement; const maxRetries = Math.max(MIN_MAX_RETRIES, Math.min(MAX_MAX_RETRIES, parseInt(get("max-retries").value) || DEFAULT_MAX_RETRIES)); - const retryDelayMs = Math.max(MIN_RETRY_DELAY_MS, Math.min(MAX_RETRY_DELAY_MS, parseInt(get("retry-delay").value) || INITIAL_RETRY_DELAY_MS)); + const retryDelayMs = Math.max(MIN_RETRY_DELAY_S, Math.min(MAX_RETRY_DELAY_S, parseFloat(get("retry-delay").value) || (INITIAL_RETRY_DELAY_MS / 1000))) * 1000; const retryBackoffFactor = Math.max(MIN_RETRY_BACKOFF_FACTOR, Math.min(MAX_RETRY_BACKOFF_FACTOR, parseFloat(get("retry-backoff").value) || RETRY_BACKOFF_FACTOR)); const fragmentFailureRate = Math.max(MIN_FAILURE_RATE, Math.min(MAX_FAILURE_RATE, (parseInt(get("failure-rate").value) || Math.round(MAX_FRAGMENT_FAILURE_RATE * 100)) / 100)); const detectionCacheSize = Math.max(MIN_DETECTION_CACHE_SIZE, Math.min(MAX_DETECTION_CACHE_SIZE, parseInt(get("detection-cache-size").value) || DEFAULT_DETECTION_CACHE_SIZE)); const masterPlaylistCacheSize = Math.max(MIN_MASTER_PLAYLIST_CACHE_SIZE, Math.min(MAX_MASTER_PLAYLIST_CACHE_SIZE, parseInt(get("master-playlist-cache-size").value) || DEFAULT_MASTER_PLAYLIST_CACHE_SIZE)); - const dbSyncIntervalMs = Math.max(MIN_DB_SYNC_INTERVAL_MS, Math.min(MAX_DB_SYNC_INTERVAL_MS, parseInt(get("db-sync-interval").value) || DEFAULT_DB_SYNC_INTERVAL_MS)); + const dbSyncIntervalMs = Math.max(MIN_DB_SYNC_S, Math.min(MAX_DB_SYNC_S, parseFloat(get("db-sync-interval").value) || (DEFAULT_DB_SYNC_INTERVAL_MS / 1000))) * 1000; const config = (await ChromeStorage.get(STORAGE_CONFIG_KEY)) ?? {}; config.advanced = { @@ -1159,12 +1151,12 @@ async function resetAdvancedSettings(): Promise { // 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.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.toString(); + get("db-sync-interval").value = (DEFAULT_DB_SYNC_INTERVAL_MS / 1000).toString(); showStatus("Reset to defaults.", "success"); } catch (err) { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 89255a7..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 */ From ce1a687ad8dc48b8762cfb7ad93aea0c8ac56af8 Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Wed, 4 Mar 2026 04:04:56 -0500 Subject: [PATCH 18/21] chore(options): remove dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove makeIconBtn and iconSpinner — defined but never called. Co-Authored-By: Claude Sonnet 4.6 --- src/options/options.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/options/options.ts b/src/options/options.ts index 3dda37f..b15fee8 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -828,19 +828,6 @@ function makeStageBadge(stage: DownloadStage): HTMLElement { return makeBadge(stage, map[stage] ?? ""); } -function makeIconBtn( - svgContent: string, - tooltip: string, - onClick?: () => void, -): HTMLButtonElement { - const btn = document.createElement("button"); - btn.className = "btn-icon"; - btn.setAttribute("data-tooltip", tooltip); - btn.setAttribute("aria-label", tooltip); - btn.innerHTML = `${svgContent}`; - if (onClick) btn.addEventListener("click", onClick); - return btn; -} function syncBulkBar(): void { const bar = document.getElementById("bulk-bar")!; @@ -984,10 +971,6 @@ function iconQuestion(): string { return ``; } -function iconSpinner(): string { - // A simple arc that spins via CSS animation - return ``; -} // ───────────────────────────────────────────── // Section: Recording View From d9d0bf1ee312632807cbd82712674e9ec0815bdf Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Wed, 4 Mar 2026 04:10:48 -0500 Subject: [PATCH 19/21] docs: update CLAUDE.md and README to reflect refactor/options-page changes Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 25 +++++++++++++++++++++---- README.md | 18 +++++++++++------- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b6ad194..143ad0f 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,21 @@ 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. + +### 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 +192,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 +223,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 From 41115a8d29d55c9fd6039a5f90cd993ab0ec6a59 Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Wed, 4 Mar 2026 09:08:49 -0500 Subject: [PATCH 20/21] refactor(styles): extract shared design tokens and badge styles into shared.css Move duplicated @font-face declarations, CSS custom properties, reset, scrollbar, and badge styles out of popup.html and options.html into a new public/shared.css. Both pages now reference it via /shared.css. Also unifies token drift between the two files: options gains --recording, --space-*, and --radius-lg; badge colors are harmonised; options gets light-mode badge overrides it was missing. Co-Authored-By: Claude Sonnet 4.6 --- public/shared.css | 189 +++++++++++++++++++++++++++++++++++++++ src/options/options.html | 133 +-------------------------- src/popup/popup.html | 142 +---------------------------- 3 files changed, 194 insertions(+), 270 deletions(-) create mode 100644 public/shared.css 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/options/options.html b/src/options/options.html index 64f1d05..4f06c6a 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -4,92 +4,16 @@ Media Bridge Settings + From f10f50d3c1ccc46834f717eaae99c65a51478543 Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Wed, 4 Mar 2026 09:32:11 -0500 Subject: [PATCH 21/21] feat(options): add inline field validation before saving Replace silent Math.max/Math.min clamping in all save handlers with explicit validation. Invalid fields get a red border and an inline error message; the button stays enabled and no write is attempted until all fields pass. Helpers: validateField(), markInvalid(), clearInvalid() in options.ts. The recording cross-field check (min < max) now marks the specific field invalid instead of falling back to the toast. Toast is reserved for storage/network errors. Documents the pattern in CLAUDE.md. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 10 +++ src/options/options.html | 14 ++++ src/options/options.ts | 137 +++++++++++++++++++++++++++++---------- 3 files changed, 128 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 143ad0f..8ed8245 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,6 +130,16 @@ HLS, M3U8, and DASH handlers support saving partial downloads when cancelled. If **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`. diff --git a/src/options/options.html b/src/options/options.html index 4f06c6a..808d231 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -216,6 +216,20 @@ border-color: var(--accent); } + .form-input.invalid { + border-color: var(--error); + } + + .form-input.invalid:focus { + border-color: var(--error); + } + + .form-error { + font-size: 11px; + color: var(--error); + margin-top: 4px; + } + .form-input::placeholder { color: var(--text-tertiary); } diff --git a/src/options/options.ts b/src/options/options.ts index b15fee8..8751b16 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -189,17 +189,18 @@ async function saveDownloadSettings(): Promise { 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; + btn.disabled = true; btn.textContent = "Saving…"; try { const config = (await ChromeStorage.get(STORAGE_CONFIG_KEY)) ?? {}; - const timeoutSeconds = parseInt(timeoutInput.value) || DEFAULT_FFMPEG_TIMEOUT_S; - config.ffmpegTimeout = - Math.max(MIN_FFMPEG_TIMEOUT_S, Math.min(MAX_FFMPEG_TIMEOUT_S, timeoutSeconds)) * 1000; - config.maxConcurrent = parseInt(maxInput.value) || DEFAULT_MAX_CONCURRENT; + 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"); @@ -913,6 +914,66 @@ function showStatus(message: string, type: "success" | "error" | "warning" | "in showToast(message, type === "info" ? "warning" : type); } +// ───────────────────────────────────────────── +// Section: Field Validation +// ───────────────────────────────────────────── + +/** + * 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. + */ +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); } @@ -992,23 +1053,28 @@ async function loadRecordingSettings(): Promise { async function saveRecordingSettings(): Promise { const btn = document.getElementById("save-recording-settings") as HTMLButtonElement; - btn.disabled = true; - btn.textContent = "Saving…"; + const get = (id: string) => document.getElementById(id) as HTMLInputElement; - try { - 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; - const minPollIntervalMs = Math.max(MIN_POLL_MIN_S, Math.min(MAX_POLL_MIN_S, parseFloat(get("poll-min").value) || (DEFAULT_MIN_POLL_MS / 1000))) * 1000; - const maxPollIntervalMs = Math.max(MIN_POLL_MAX_S, Math.min(MAX_POLL_MAX_S, parseFloat(get("poll-max").value) || (DEFAULT_MAX_POLL_MS / 1000))) * 1000; - const pollFraction = Math.max(MIN_POLL_FRACTION, Math.min(MAX_POLL_FRACTION, parseFloat(get("poll-fraction").value) || DEFAULT_POLL_FRACTION)); + if (pollMinS >= pollMaxS) { + markInvalid(get("poll-min"), "Must be less than the maximum poll interval"); + return; + } - if (minPollIntervalMs >= maxPollIntervalMs) { - showStatus("Minimum poll interval must be less than maximum.", "error"); - return; - } + btn.disabled = true; + btn.textContent = "Saving…"; + try { const config = (await ChromeStorage.get(STORAGE_CONFIG_KEY)) ?? {}; - config.recording = { minPollIntervalMs, maxPollIntervalMs, pollFraction }; + config.recording = { + minPollIntervalMs: pollMinS * 1000, + maxPollIntervalMs: pollMaxS * 1000, + pollFraction, + }; await ChromeStorage.set(STORAGE_CONFIG_KEY, config); showStatus("Settings saved.", "success"); } catch (err) { @@ -1088,29 +1154,34 @@ async function loadAdvancedSettings(): Promise { 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 get = (id: string) => document.getElementById(id) as HTMLInputElement; - - const maxRetries = Math.max(MIN_MAX_RETRIES, Math.min(MAX_MAX_RETRIES, parseInt(get("max-retries").value) || DEFAULT_MAX_RETRIES)); - const retryDelayMs = Math.max(MIN_RETRY_DELAY_S, Math.min(MAX_RETRY_DELAY_S, parseFloat(get("retry-delay").value) || (INITIAL_RETRY_DELAY_MS / 1000))) * 1000; - const retryBackoffFactor = Math.max(MIN_RETRY_BACKOFF_FACTOR, Math.min(MAX_RETRY_BACKOFF_FACTOR, parseFloat(get("retry-backoff").value) || RETRY_BACKOFF_FACTOR)); - const fragmentFailureRate = Math.max(MIN_FAILURE_RATE, Math.min(MAX_FAILURE_RATE, (parseInt(get("failure-rate").value) || Math.round(MAX_FRAGMENT_FAILURE_RATE * 100)) / 100)); - const detectionCacheSize = Math.max(MIN_DETECTION_CACHE_SIZE, Math.min(MAX_DETECTION_CACHE_SIZE, parseInt(get("detection-cache-size").value) || DEFAULT_DETECTION_CACHE_SIZE)); - const masterPlaylistCacheSize = Math.max(MIN_MASTER_PLAYLIST_CACHE_SIZE, Math.min(MAX_MASTER_PLAYLIST_CACHE_SIZE, parseInt(get("master-playlist-cache-size").value) || DEFAULT_MASTER_PLAYLIST_CACHE_SIZE)); - const dbSyncIntervalMs = Math.max(MIN_DB_SYNC_S, Math.min(MAX_DB_SYNC_S, parseFloat(get("db-sync-interval").value) || (DEFAULT_DB_SYNC_INTERVAL_MS / 1000))) * 1000; - const config = (await ChromeStorage.get(STORAGE_CONFIG_KEY)) ?? {}; config.advanced = { maxRetries, - retryDelayMs, - retryBackoffFactor, - fragmentFailureRate, - detectionCacheSize, - masterPlaylistCacheSize, - dbSyncIntervalMs, + 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");