diff --git a/src/content.ts b/src/content.ts index 7a7cdf6..1d3dd4d 100644 --- a/src/content.ts +++ b/src/content.ts @@ -4,7 +4,7 @@ */ import { MessageType } from "./shared/messages"; -import { VideoMetadata } from "./core/types"; +import { VideoMetadata, VideoFormat } from "./core/types"; import { DetectionManager } from "./core/detection/detection-manager"; import { normalizeUrl } from "./core/utils/url-utils"; import { logger } from "./core/utils/logger"; @@ -68,7 +68,7 @@ function removeDetectedVideo(url: string): void { */ function addDetectedVideo(video: VideoMetadata) { // Reject unknown formats - don't show them in UI - if (video.format === "unknown") { + if (video.format === VideoFormat.UNKNOWN) { return; } diff --git a/src/core/detection/detection-manager.ts b/src/core/detection/detection-manager.ts index fb3cba5..ab79f68 100644 --- a/src/core/detection/detection-manager.ts +++ b/src/core/detection/detection-manager.ts @@ -20,7 +20,7 @@ * @module DetectionManager */ -import { VideoMetadata } from "../types"; +import { VideoMetadata, VideoFormat } from "../types"; import { logger } from "../utils/logger"; import { detectFormatFromUrl } from "../utils/url-utils"; import { DirectDetectionHandler } from "./direct/direct-detection-handler"; @@ -68,12 +68,12 @@ export class DetectionManager { const format = detectFormatFromUrl(url); switch (format) { - case "direct": + case VideoFormat.DIRECT: logger.debug("[Media Bridge] Direct video detected", { url }); this.directHandler.handleNetworkRequest(url); break; - case "hls": + case VideoFormat.HLS: logger.debug("[Media Bridge] HLS video detected", { url }); this.hlsHandler.handleNetworkRequest(url); break; diff --git a/src/core/detection/direct/direct-detection-handler.ts b/src/core/detection/direct/direct-detection-handler.ts index 48fd302..90bad85 100644 --- a/src/core/detection/direct/direct-detection-handler.ts +++ b/src/core/detection/direct/direct-detection-handler.ts @@ -23,7 +23,7 @@ * @module DirectDetectionHandler */ -import { VideoMetadata } from "../../types"; +import { VideoMetadata, VideoFormat } from "../../types"; import { detectFormatFromUrl } from "../../utils/url-utils"; import { extractThumbnail } from "../../utils/thumbnail-utils"; @@ -339,7 +339,7 @@ export class DirectDetectionHandler { const format = detectFormatFromUrl(url); // Reject unknown formats - if (format === "unknown") { + if (format === VideoFormat.UNKNOWN) { return null; } diff --git a/src/core/detection/hls/hls-detection-handler.ts b/src/core/detection/hls/hls-detection-handler.ts index 3917ca8..661997c 100644 --- a/src/core/detection/hls/hls-detection-handler.ts +++ b/src/core/detection/hls/hls-detection-handler.ts @@ -25,7 +25,7 @@ * @module HlsDetectionHandler */ -import { VideoMetadata } from "../../types"; +import { VideoMetadata, VideoFormat } from "../../types"; import { isMasterPlaylist, isMediaPlaylist, @@ -168,7 +168,7 @@ export class HlsDetectionHandler { // A media playlist without #EXT-X-ENDLIST is a live stream const isLive = !playlistText.includes("#EXT-X-ENDLIST"); logger.info("[Media Bridge] Detected standalone M3U8 media playlist", url); - return await this.addDetectedVideo(url, "m3u8", playlistText, isLive); + return await this.addDetectedVideo(url, VideoFormat.M3U8, playlistText, isLive); } /** @@ -197,7 +197,7 @@ export class HlsDetectionHandler { // Determine liveness by fetching the first variant playlist and checking for #EXT-X-ENDLIST const isLive = await this.checkMasterIsLive(levels); - return await this.addDetectedVideo(url, "hls", playlistText, isLive); + return await this.addDetectedVideo(url, VideoFormat.HLS, playlistText, isLive); } /** @@ -268,7 +268,7 @@ export class HlsDetectionHandler { */ private async addDetectedVideo( url: string, - format: "hls" | "m3u8", + format: VideoFormat.HLS | VideoFormat.M3U8, playlistText: string, isLive: boolean = false, ): Promise { @@ -372,7 +372,7 @@ export class HlsDetectionHandler { */ private async extractMetadata( url: string, - format: "hls" | "m3u8" = "hls", + format: VideoFormat.HLS | VideoFormat.M3U8 = VideoFormat.HLS, playlistText: string, isLive: boolean = false, ): Promise { diff --git a/src/core/downloader/download-manager.ts b/src/core/downloader/download-manager.ts index 7b5396e..0be4fd3 100644 --- a/src/core/downloader/download-manager.ts +++ b/src/core/downloader/download-manager.ts @@ -115,7 +115,7 @@ export class DownloadManager { ); // Validate format from metadata (should already be set by detection) - if (metadata.format === "unknown") { + if (metadata.format === VideoFormat.UNKNOWN) { const error = new Error(`Video format is unknown for URL: ${url}`); return await this.createFailedState( state.id, @@ -136,7 +136,7 @@ export class DownloadManager { const actualVideoUrl = metadata.url; // Route to appropriate download handler based on format - if (format === "direct") { + if (format === VideoFormat.DIRECT) { // Use direct download handler with Chrome downloads API // AbortSignal is used to cancel the HEAD request for extension detection // The actual Chrome download is cancelled via chrome.downloads.cancel @@ -146,7 +146,7 @@ export class DownloadManager { state.id, abortSignal, ); - } else if (format === "hls") { + } else if (format === VideoFormat.HLS) { // Use HLS download handler await this.hlsDownloadHandler.download( actualVideoUrl, @@ -156,7 +156,7 @@ export class DownloadManager { abortSignal, metadata.pageUrl, ); - } else if (format === "m3u8") { + } else if (format === VideoFormat.M3U8) { // Use M3U8 download handler await this.m3u8DownloadHandler.download( actualVideoUrl, diff --git a/src/core/types.ts b/src/core/types.ts index b0d2fff..6ec995f 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -2,7 +2,12 @@ * Type definitions for Media Bridge Extension */ -export type VideoFormat = "direct" | "hls" | "m3u8" | "unknown"; +export enum VideoFormat { + DIRECT = "direct", + HLS = "hls", + M3U8 = "m3u8", + UNKNOWN = "unknown", +} export interface VideoMetadata { url: string; diff --git a/src/core/utils/url-utils.ts b/src/core/utils/url-utils.ts index 9fa066c..71d494a 100644 --- a/src/core/utils/url-utils.ts +++ b/src/core/utils/url-utils.ts @@ -27,26 +27,26 @@ export function normalizeUrl(url: string): string { export function detectFormatFromUrl(url: string): VideoFormat { // Handle blob URLs - these are already video blobs, treat as direct if (url.startsWith("blob:")) { - return "direct"; + return VideoFormat.DIRECT; } // Handle data URLs - treat as direct if (url.startsWith("data:")) { - return "direct"; + return VideoFormat.DIRECT; } let urlObj: URL; try { urlObj = new URL(url); } catch (error) { - return "unknown"; + return VideoFormat.UNKNOWN; } const pathnameLower = urlObj.pathname.toLowerCase(); // Check for HLS playlist files (.m3u8) on pathname only if (pathnameLower.endsWith(".m3u8")) { - return "hls"; + return VideoFormat.HLS; } // Check for common video extensions on pathname only @@ -61,14 +61,14 @@ export function detectFormatFromUrl(url: string): VideoFormat { ".wmv", ]; if (videoExtensions.some((ext) => pathnameLower.endsWith(ext))) { - return "direct"; + return VideoFormat.DIRECT; } // If no clear format detected but it looks like a video URL, assume direct // Many video CDNs don't include file extensions if (urlObj.pathname.match(/\/(video|stream|media|v|embed)\//i)) { - return "direct"; + return VideoFormat.DIRECT; } - return "unknown"; + return VideoFormat.UNKNOWN; } diff --git a/src/offscreen/offscreen.ts b/src/offscreen/offscreen.ts index 695df34..db214af 100644 --- a/src/offscreen/offscreen.ts +++ b/src/offscreen/offscreen.ts @@ -60,12 +60,12 @@ let processingQueue: Promise = Promise.resolve(); function enqueue(job: () => Promise): Promise { return new Promise((resolve, reject) => { processingQueue = processingQueue.then(async () => { - try { - resolve(await job()); - } catch (error) { - reject(error); - } - }); + try { + resolve(await job()); + } catch (error) { + reject(error); + } + }); }); } @@ -108,7 +108,9 @@ async function concatenateChunks( } } - logger.info(`Concatenated ${chunks.length}/${length} chunks (${totalBytes} bytes, ${missingCount} missing) for ${downloadId}`); + logger.info( + `Concatenated ${chunks.length}/${length} chunks (${totalBytes} bytes, ${missingCount} missing) for ${downloadId}`, + ); return { blob: new Blob(chunks, { type: "video/mp2t" }), @@ -123,12 +125,17 @@ async function concatenateChunks( /** * Safely clean up intermediate files from FFmpeg's virtual filesystem */ -async function cleanupFiles(ffmpeg: FFmpeg, filenames: string[]): Promise { +async function cleanupFiles( + ffmpeg: FFmpeg, + filenames: string[], +): Promise { for (const name of filenames) { try { await ffmpeg.deleteFile(name); } catch { - logger.debug(`Could not delete intermediate file ${name} (may not exist)`); + logger.debug( + `Could not delete intermediate file ${name} (may not exist)`, + ); } } } @@ -137,7 +144,10 @@ async function cleanupFiles(ffmpeg: FFmpeg, filenames: string[]): Promise * Build a user-facing warning string from missing chunk counts. * Returns undefined if no chunks are missing. */ -function buildMissingChunksWarning(missingCount: number, totalCount: number): string | undefined { +function buildMissingChunksWarning( + missingCount: number, + totalCount: number, +): string | undefined { if (missingCount === 0 || totalCount === 0) return undefined; const pct = ((missingCount / totalCount) * 100).toFixed(1); return `${missingCount} of ${totalCount} chunks were missing (${pct}%) — video may have gaps`; @@ -242,7 +252,12 @@ async function processAudioOnly( onProgress?: (progress: number, message: string) => void, ): Promise { return processSingleStream( - ffmpeg, downloadId, audioLength, 0, "audio", outputFileName, + ffmpeg, + downloadId, + audioLength, + 0, + "audio", + outputFileName, ["-c:a", "copy", "-movflags", "+faststart"], onProgress, ); @@ -282,7 +297,12 @@ async function processHLSChunks( ); } else if (videoLength > 0) { warning = await processSingleStream( - ffmpeg, downloadId, videoLength, 0, "video", outputFileName, + ffmpeg, + downloadId, + videoLength, + 0, + "video", + outputFileName, ["-c", "copy", "-bsf:a", "aac_adtstoasc", "-movflags", "+faststart"], onProgress, ); @@ -341,7 +361,12 @@ async function processM3u8Chunks( try { // Process M3U8 media playlist const warning = await processSingleStream( - ffmpeg, downloadId, fragmentCount, 0, "media", outputFileName, + ffmpeg, + downloadId, + fragmentCount, + 0, + "media", + outputFileName, ["-c", "copy", "-bsf:a", "aac_adtstoasc", "-movflags", "+faststart"], onProgress, ); @@ -375,7 +400,9 @@ async function processM3u8Chunks( */ function sendToServiceWorker(msg: object): void { chrome.runtime.sendMessage(msg, () => { - if (chrome.runtime.lastError) { /* intentionally swallowed */ } + if (chrome.runtime.lastError) { + /* intentionally swallowed */ + } }); } @@ -398,15 +425,14 @@ function handleProcessingMessage( const { downloadId } = message.payload; sendResponse({ acknowledged: true }); - enqueue(() => processFn( - message.payload, - (progress, msg) => { + enqueue(() => + processFn(message.payload, (progress, msg) => { sendToServiceWorker({ type: responseType, payload: { downloadId, type: "progress", progress, message: msg }, }); - }, - )) + }), + ) .then(({ blobUrl, warning }) => { sendToServiceWorker({ type: responseType, @@ -431,28 +457,45 @@ function handleProcessingMessage( * Handle messages from service worker */ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { - if (handleProcessingMessage( - message, sendResponse, - MessageType.OFFSCREEN_PROCESS_HLS, - MessageType.OFFSCREEN_PROCESS_HLS_RESPONSE, - (payload, onProgress) => processHLSChunks( - payload.downloadId as string, - payload.videoLength as number, - payload.audioLength as number, - onProgress, - ), - )) return true; - - if (handleProcessingMessage( - message, sendResponse, - MessageType.OFFSCREEN_PROCESS_M3U8, - MessageType.OFFSCREEN_PROCESS_M3U8_RESPONSE, - (payload, onProgress) => processM3u8Chunks( - payload.downloadId as string, - payload.fragmentCount as number, - onProgress, - ), - )) return true; + if ( + handleProcessingMessage( + message, + sendResponse, + MessageType.OFFSCREEN_PROCESS_HLS, + MessageType.OFFSCREEN_PROCESS_HLS_RESPONSE, + (payload, onProgress) => + processHLSChunks( + payload.downloadId as string, + payload.videoLength as number, + payload.audioLength as number, + onProgress, + ), + ) + ) + return true; + + if ( + handleProcessingMessage( + message, + sendResponse, + MessageType.OFFSCREEN_PROCESS_M3U8, + MessageType.OFFSCREEN_PROCESS_M3U8_RESPONSE, + (payload, onProgress) => + processM3u8Chunks( + payload.downloadId as string, + payload.fragmentCount as number, + onProgress, + ), + ) + ) + return true; + + // Pre-warm FFmpeg while segments are downloading + if (message.type === MessageType.WARMUP_FFMPEG) { + sendResponse({ acknowledged: true }); + getFFmpeg().catch((err) => logger.error("FFmpeg warmup failed:", err)); + return false; + } // Revoke a blob URL that was created in this offscreen document context if (message.type === MessageType.REVOKE_BLOB_URL) { diff --git a/src/popup/popup.ts b/src/popup/popup.ts index 203294d..489b35d 100644 --- a/src/popup/popup.ts +++ b/src/popup/popup.ts @@ -2,7 +2,7 @@ * Popup entry point: initialization, tab switching, theme, and message routing. */ -import { VideoMetadata, DownloadStage } from "../core/types"; +import { VideoMetadata, DownloadStage, VideoFormat } from "../core/types"; import { getAllDownloads, deleteDownload } from "../core/database/downloads"; import { MessageType } from "../shared/messages"; import { normalizeUrl } from "../core/utils/url-utils"; @@ -232,7 +232,7 @@ function removeDetectedVideo(url: string | undefined): void { } function addDetectedVideo(video: VideoMetadata): void { - if (video.format === "unknown") { + if (video.format === VideoFormat.UNKNOWN) { const normalizedUrl = normalizeUrl(video.url); if (detectedVideos[normalizedUrl]) { delete detectedVideos[normalizedUrl]; diff --git a/src/popup/render-downloads.ts b/src/popup/render-downloads.ts index 3e438b9..b2b8e46 100644 --- a/src/popup/render-downloads.ts +++ b/src/popup/render-downloads.ts @@ -2,7 +2,7 @@ * Downloads tab rendering with incremental DOM updates. */ -import { DownloadState, DownloadStage } from "../core/types"; +import { DownloadState, DownloadStage, VideoFormat } from "../core/types"; import { storeDownload } from "../core/database/downloads"; import { dom, downloadStates } from "./state"; import { @@ -47,7 +47,7 @@ function updateDownloadCardProgress(card: HTMLElement, download: DownloadState): const isRecording = stage === DownloadStage.RECORDING; const isManifestDownload = - (download.metadata.format === "hls" || download.metadata.format === "m3u8") && + (download.metadata.format === VideoFormat.HLS || download.metadata.format === VideoFormat.M3U8) && (stage === DownloadStage.DOWNLOADING || stage === DownloadStage.MERGING); if (isRecording) { @@ -173,8 +173,8 @@ function renderDownloadItem(download: DownloadState): string { let progressBar = ""; const isRecording = stage === DownloadStage.RECORDING; const isManifestDownload = - (download.metadata.format === "hls" || - download.metadata.format === "m3u8") && + (download.metadata.format === VideoFormat.HLS || + download.metadata.format === VideoFormat.M3U8) && (stage === DownloadStage.DOWNLOADING || stage === DownloadStage.MERGING); if (isRecording) { @@ -287,7 +287,7 @@ function renderDownloadItem(download: DownloadState): string { `; } else { const isDownloading = download.progress.stage === DownloadStage.DOWNLOADING; - const isManifestType = download.metadata.format === "hls" || download.metadata.format === "m3u8"; + const isManifestType = download.metadata.format === VideoFormat.HLS || download.metadata.format === VideoFormat.M3U8; actionButtons = `
${isDownloading && isManifestType ? `` : ``} diff --git a/src/popup/render-manifest.ts b/src/popup/render-manifest.ts index 9b2923f..a86ac34 100644 --- a/src/popup/render-manifest.ts +++ b/src/popup/render-manifest.ts @@ -2,7 +2,7 @@ * Manual manifest tab: load playlist, quality selection, start download. */ -import { VideoMetadata, DownloadStage } from "../core/types"; +import { VideoMetadata, DownloadStage, VideoFormat } from "../core/types"; import { normalizeUrl, detectFormatFromUrl } from "../core/utils/url-utils"; import { parseMasterPlaylist, isMasterPlaylist, isMediaPlaylist } from "../core/utils/m3u8-parser"; import { hasDrm, canDecrypt } from "../core/utils/drm-utils"; @@ -130,7 +130,7 @@ export async function handleLoadManifestPlaylist(): Promise { const normalizedUrl = normalizeUrl(rawUrl); const format = detectFormatFromUrl(normalizedUrl); - if (format !== "hls") { + if (format !== VideoFormat.HLS) { alert("Please enter a valid manifest URL (.m3u8 or .mpd)"); return; } @@ -339,7 +339,7 @@ export async function handleStartManifestDownload(): Promise { const metadata: VideoMetadata = { url: playlistUrl, - format: isMediaPlaylistMode ? "m3u8" : "hls", + format: isMediaPlaylistMode ? VideoFormat.M3U8 : VideoFormat.HLS, title: tabTitle || "Manifest Video", pageUrl: pageUrl || window.location.href, isLive: isLiveManifest, diff --git a/src/popup/render-videos.ts b/src/popup/render-videos.ts index 3f0ec7b..315fd46 100644 --- a/src/popup/render-videos.ts +++ b/src/popup/render-videos.ts @@ -2,7 +2,7 @@ * Detected videos tab rendering. */ -import { DownloadState, DownloadStage, VideoMetadata } from "../core/types"; +import { DownloadState, DownloadStage, VideoMetadata, VideoFormat } from "../core/types"; import { normalizeUrl } from "../core/utils/url-utils"; import { MessageType } from "../shared/messages"; import { dom, detectedVideos, downloadStates } from "./state"; @@ -63,7 +63,7 @@ function updateVideoCardProgress(card: HTMLElement, video: VideoMetadata): boole } const isManifestDownload = - (video.format === "hls" || video.format === "m3u8") && + (video.format === VideoFormat.HLS || video.format === VideoFormat.M3U8) && (stage === DownloadStage.DOWNLOADING || stage === DownloadStage.MERGING); if (isManifestDownload && stage === DownloadStage.DOWNLOADING) { @@ -357,7 +357,7 @@ function renderVideoItem(video: VideoMetadata): string { } const isManifestDownload = - (video.format === "hls" || video.format === "m3u8") && + (video.format === VideoFormat.HLS || video.format === VideoFormat.M3U8) && (stage === DownloadStage.DOWNLOADING || stage === DownloadStage.MERGING); if (stage === DownloadStage.RECORDING) { @@ -460,7 +460,7 @@ function renderVideoItem(video: VideoMetadata): string { ${buttonText} ` : ""} - ${(video.format === "hls" || video.format === "m3u8") && !hasDrm && !unsupported ? ` + ${(video.format === VideoFormat.HLS || video.format === VideoFormat.M3U8) && !hasDrm && !unsupported ? `