Skip to content
14 changes: 10 additions & 4 deletions src/core/detection/hls/hls-detection-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { fetchText } from "../../utils/fetch-utils";
import { normalizeUrl } from "../../utils/url-utils";
import { logger } from "../../utils/logger";
import { extractThumbnail } from "../../utils/thumbnail-utils";
import { hasDrm, canDecrypt } from "../../utils/drm-utils";

/** Configuration options for HlsDetectionHandler */
export interface HlsDetectionHandlerOptions {
Expand Down Expand Up @@ -88,7 +89,7 @@ export class HlsDetectionHandler {
const isMedia = isMediaPlaylist(playlistText);

if (isMedia) {
return await this.handleMediaPlaylist(url, normalizedUrl);
return await this.handleMediaPlaylist(url, normalizedUrl, playlistText);
}

if (isMaster) {
Expand Down Expand Up @@ -125,6 +126,7 @@ export class HlsDetectionHandler {
private async handleMediaPlaylist(
url: string,
normalizedUrl: string,
playlistText: string,
): Promise<VideoMetadata | null> {
const belongsToMaster = this.checkIfBelongsToMasterPlaylist(normalizedUrl);

Expand All @@ -139,7 +141,7 @@ export class HlsDetectionHandler {

// It's a standalone media playlist, add it as M3U8 format
logger.info("[Media Bridge] Detected standalone M3U8 media playlist", url);
return await this.addDetectedVideo(url, "m3u8");
return await this.addDetectedVideo(url, "m3u8", playlistText);
}

/**
Expand All @@ -162,7 +164,7 @@ export class HlsDetectionHandler {
// Remove any existing detected videos that are variants of this master playlist
this.removeVariantVideos(variantUrls);

return await this.addDetectedVideo(url, "hls");
return await this.addDetectedVideo(url, "hls", playlistText);
}

/**
Expand Down Expand Up @@ -208,8 +210,9 @@ export class HlsDetectionHandler {
private async addDetectedVideo(
url: string,
format: "hls" | "m3u8",
playlistText: string,
): Promise<VideoMetadata | null> {
const metadata = await this.extractMetadata(url, format);
const metadata = await this.extractMetadata(url, format, playlistText);

if (metadata && this.onVideoDetected) {
this.onVideoDetected(metadata);
Expand Down Expand Up @@ -307,13 +310,16 @@ export class HlsDetectionHandler {
private async extractMetadata(
url: string,
format: "hls" | "m3u8" = "hls",
playlistText: string,
): Promise<VideoMetadata | null> {
const metadata: VideoMetadata = {
url,
format,
pageUrl: window.location.href,
title: document.title,
fileExtension: "m3u8",
hasDrm: hasDrm(playlistText),
unsupported: !canDecrypt(playlistText),
};

// Extract thumbnail using unified utility (page-based search)
Expand Down
9 changes: 6 additions & 3 deletions src/core/downloader/direct/direct-download-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
DirectDownloadHandlerResult,
DirectDownloadResult,
} from "../types";
import { sanitizeFilename } from "../../utils/file-utils";

/** Internal listener structure for tracking Chrome download progress */
interface DownloadListener {
Expand Down Expand Up @@ -330,16 +331,18 @@ export class DirectDownloadHandler {
abortSignal?: AbortSignal,
): Promise<DirectDownloadHandlerResult> {
try {
logger.info(`Downloading direct video from ${url} to ${filename}`);
// Sanitize filename to remove invalid characters
const sanitizedFilename = sanitizeFilename(filename);
logger.info(`Downloading direct video from ${url} to ${sanitizedFilename}`);

// Check if already aborted before starting
throwIfAborted(abortSignal);

// Extract file extension from URL or headers (cancellable)
const fileExtension = await this.extractFileExtension(url, abortSignal);

// Start Chrome download
const chromeDownloadId = await this.startChromeDownload(url, filename);
// Start Chrome download with sanitized filename
const chromeDownloadId = await this.startChromeDownload(url, sanitizedFilename);

// Store chromeDownloadId in download state for reliable cancellation
const currentState = await getDownload(stateId);
Expand Down
103 changes: 81 additions & 22 deletions src/core/downloader/hls/hls-download-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import {
DownloadProgressCallback,
DownloadProgressCallback as ProgressCallback,
} from "../types";
import { canDownloadHLSManifest } from "../../utils/drm-utils";
import { sanitizeFilename } from "../../utils/file-utils";

/** Configuration options for HLS download handler */
export interface HlsDownloadHandlerOptions {
Expand Down Expand Up @@ -552,6 +554,9 @@ export class HlsDownloadHandler {
},
async (downloadId) => {
if (chrome.runtime.lastError) {
logger.error(
`Chrome downloads API error: ${chrome.runtime.lastError.message}`,
);
reject(new Error(chrome.runtime.lastError.message));
return;
}
Expand Down Expand Up @@ -673,6 +678,19 @@ export class HlsDownloadHandler {

let videoPlaylistUrl: string | null = null;
let audioPlaylistUrl: string | null = null;
let videoPlaylistText: string | null = null;
let audioPlaylistText: string | null = null;

// Fetch and validate master playlist once
const masterPlaylistText = this.abortSignal
? await cancelIfAborted(
fetchText(masterPlaylistUrl, 3, this.abortSignal),
this.abortSignal
)
: await fetchText(masterPlaylistUrl, 3);

// Validate master playlist can be downloaded
canDownloadHLSManifest(masterPlaylistText);

// If quality preferences are provided, use them directly
if (manifestQuality) {
Expand All @@ -683,14 +701,30 @@ export class HlsDownloadHandler {
videoPlaylistUrl || "none"
}, audio: ${audioPlaylistUrl || "none"}`,
);

// Fetch and validate video playlist if provided
if (videoPlaylistUrl) {
videoPlaylistText = this.abortSignal
? await cancelIfAborted(
fetchText(videoPlaylistUrl, 3, this.abortSignal),
this.abortSignal
)
: await fetchText(videoPlaylistUrl, 3);
canDownloadHLSManifest(videoPlaylistText);
}

// Fetch and validate audio playlist if provided
if (audioPlaylistUrl) {
audioPlaylistText = this.abortSignal
? await cancelIfAborted(
fetchText(audioPlaylistUrl, 3, this.abortSignal),
this.abortSignal
)
: await fetchText(audioPlaylistUrl, 3);
canDownloadHLSManifest(audioPlaylistText);
}
} else {
// Otherwise, fetch and parse master playlist to auto-select
const masterPlaylistText = this.abortSignal
? await cancelIfAborted(
fetchText(masterPlaylistUrl, 3, this.abortSignal),
this.abortSignal
)
: await fetchText(masterPlaylistUrl, 3);
// Otherwise, parse master playlist to auto-select
const levels = parseMasterPlaylist(
masterPlaylistText,
masterPlaylistUrl,
Expand All @@ -717,13 +751,20 @@ export class HlsDownloadHandler {

throwIfAborted(this.abortSignal);

// Process video playlist if available
if (videoPlaylistUrl) {
const videoPlaylistText = this.abortSignal
? await cancelIfAborted(
fetchText(videoPlaylistUrl, 3, this.abortSignal),
this.abortSignal
)
: await fetchText(videoPlaylistUrl, 3);
// Fetch video playlist if not already fetched (when auto-selecting)
if (!videoPlaylistText) {
videoPlaylistText = this.abortSignal
? await cancelIfAborted(
fetchText(videoPlaylistUrl, 3, this.abortSignal),
this.abortSignal
)
: await fetchText(videoPlaylistUrl, 3);
// Validate video playlist can be downloaded
canDownloadHLSManifest(videoPlaylistText);
}

const videoFragments = parseLevelsPlaylist(
videoPlaylistText,
videoPlaylistUrl,
Expand Down Expand Up @@ -752,13 +793,20 @@ export class HlsDownloadHandler {

throwIfAborted(this.abortSignal);

// Process audio playlist if available
if (audioPlaylistUrl) {
const audioPlaylistText = this.abortSignal
? await cancelIfAborted(
fetchText(audioPlaylistUrl, 3, this.abortSignal),
this.abortSignal
)
: await fetchText(audioPlaylistUrl, 3);
// Fetch audio playlist if not already fetched (when auto-selecting)
if (!audioPlaylistText) {
audioPlaylistText = this.abortSignal
? await cancelIfAborted(
fetchText(audioPlaylistUrl, 3, this.abortSignal),
this.abortSignal
)
: await fetchText(audioPlaylistUrl, 3);
// Validate audio playlist can be downloaded
canDownloadHLSManifest(audioPlaylistText);
}

const audioFragments = parseLevelsPlaylist(
audioPlaylistText,
audioPlaylistUrl,
Expand Down Expand Up @@ -798,8 +846,18 @@ export class HlsDownloadHandler {
this.notifyProgress(mergingState);
}

// Extract base filename without extension
const baseFileName = filename.replace(/\.[^/.]+$/, "");
// Sanitize and extract base filename without extension
const sanitizedFilename = sanitizeFilename(filename);
logger.info(`Sanitized filename: "${sanitizedFilename}"`);

let baseFileName = sanitizedFilename.replace(/\.[^/.]+$/, "");

// Fallback if base filename is empty or invalid
if (!baseFileName || baseFileName.trim() === "") {
const timestamp = Date.now();
baseFileName = `video_${timestamp}`;
logger.warn(`Filename became empty after sanitization, using fallback: ${baseFileName}`);
}

// Process chunks using offscreen document and FFmpeg
const blobUrl = await this.streamToMp4Blob(
Expand Down Expand Up @@ -831,9 +889,10 @@ export class HlsDownloadHandler {
}

// Save to file using blob URL
const finalFilename = `${baseFileName}.mp4`;
const filePath = await this.saveBlobUrlToFile(
blobUrl,
`${baseFileName}.mp4`,
finalFilename,
stateId,
);

Expand Down
20 changes: 17 additions & 3 deletions src/core/downloader/m3u8/m3u8-download-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
* @module M3u8DownloadHandler
*/

import { CancellationError } from "../../utils/errors";
import { CancellationError, DownloadError } from "../../utils/errors";
import { getDownload, storeDownload } from "../../database/downloads";
import { DownloadState, Fragment, DownloadStage } from "../../types";
import { logger } from "../../utils/logger";
Expand All @@ -40,6 +40,8 @@ import {
DownloadProgressCallback,
DownloadProgressCallback as ProgressCallback,
} from "../types";
import { canDownloadHLSManifest } from "../../utils/drm-utils";
import { sanitizeFilename } from "../../utils/file-utils";

/** Configuration options for M3U8 download handler */
export interface M3u8DownloadHandlerOptions {
Expand Down Expand Up @@ -615,6 +617,10 @@ export class M3u8DownloadHandler {
this.abortSignal
)
: await fetchText(mediaPlaylistUrl, 3);

// Validate media playlist can be downloaded
canDownloadHLSManifest(mediaPlaylistText);

const fragments = parseLevelsPlaylist(
mediaPlaylistText,
mediaPlaylistUrl,
Expand Down Expand Up @@ -645,8 +651,16 @@ export class M3u8DownloadHandler {
this.notifyProgress(mergingState);
}

// Extract base filename without extension
const baseFileName = filename.replace(/\.[^/.]+$/, "");
// Sanitize and extract base filename without extension
const sanitizedFilename = sanitizeFilename(filename);
let baseFileName = sanitizedFilename.replace(/\.[^/.]+$/, "");

// Fallback if base filename is empty or invalid
if (!baseFileName || baseFileName.trim() === "") {
const timestamp = Date.now();
baseFileName = `video_${timestamp}`;
logger.warn(`Filename became empty after sanitization, using fallback: ${baseFileName}`);
}

// Process chunks using offscreen document and FFmpeg
const blobUrl = await this.streamToMp4Blob(
Expand Down
2 changes: 2 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface VideoMetadata {
thumbnail?: string; // Thumbnail/preview image URL
videoId?: string; // Unique identifier for this video instance
fileExtension?: string; // Detected file extension (e.g., "mp4", "webm")
hasDrm?: boolean; // Indicates if the video is DRM-protected
unsupported?: boolean; // Indicates if the manifest uses unsupported encryption methods
}

export interface VideoQuality {
Expand Down
Loading