- Add process 'error' event handler to catch spawn failures (e.g., yt-dlp not installed) - Separate stdout/stderr to prevent yt-dlp warnings from corrupting parsed output - Add try-catch for RegExp construction from YTDLP_SANITIZE_ILLEGAL_CHARS env var - Add NaN validation for YTDLP_MAX_FILENAME_LENGTH env var - Sync VERSION constant with package.json (0.8.4) - Update tests for new output format and null handling - Add version sync guidance to CLAUDE.md
329 lines
10 KiB
TypeScript
329 lines
10 KiB
TypeScript
import * as os from "os";
|
|
import * as path from "path";
|
|
import * as fs from "fs";
|
|
|
|
type DeepPartial<T> = {
|
|
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
|
|
};
|
|
|
|
/**
|
|
* Valid browser names for cookie extraction
|
|
*/
|
|
export const VALID_BROWSERS = [
|
|
'brave', 'chrome', 'chromium', 'edge',
|
|
'firefox', 'opera', 'safari', 'vivaldi', 'whale'
|
|
] as const;
|
|
|
|
export type ValidBrowser = typeof VALID_BROWSERS[number];
|
|
|
|
/**
|
|
* Configuration type definitions
|
|
*/
|
|
export interface Config {
|
|
// File-related configuration
|
|
file: {
|
|
maxFilenameLength: number;
|
|
downloadsDir: string;
|
|
tempDirPrefix: string;
|
|
// Filename processing configuration
|
|
sanitize: {
|
|
// Character to replace illegal characters
|
|
replaceChar: string;
|
|
// Suffix when truncating filenames
|
|
truncateSuffix: string;
|
|
// Regular expression for illegal characters
|
|
illegalChars: RegExp;
|
|
// List of reserved names
|
|
reservedNames: readonly string[];
|
|
};
|
|
};
|
|
// Tool-related configuration
|
|
tools: {
|
|
required: readonly string[];
|
|
};
|
|
// Download-related configuration
|
|
download: {
|
|
defaultResolution: "480p" | "720p" | "1080p" | "best";
|
|
defaultAudioFormat: "m4a" | "mp3";
|
|
defaultSubtitleLanguage: string;
|
|
};
|
|
// Response limits
|
|
limits: {
|
|
characterLimit: number;
|
|
maxTranscriptLength: number;
|
|
};
|
|
// Cookie configuration for authenticated access
|
|
cookies: {
|
|
// Path to Netscape format cookie file
|
|
file?: string;
|
|
// Browser name and settings (format: BROWSER[:PROFILE][::CONTAINER])
|
|
fromBrowser?: string;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Default configuration
|
|
*/
|
|
const defaultConfig: Config = {
|
|
file: {
|
|
maxFilenameLength: 50,
|
|
downloadsDir: path.join(os.homedir(), "Downloads"),
|
|
tempDirPrefix: "ytdlp-",
|
|
sanitize: {
|
|
replaceChar: '_',
|
|
truncateSuffix: '...',
|
|
illegalChars: /[<>:"/\\|?*\x00-\x1F]/g, // Windows illegal characters
|
|
reservedNames: [
|
|
'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4',
|
|
'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2',
|
|
'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
|
|
]
|
|
}
|
|
},
|
|
tools: {
|
|
required: ['yt-dlp']
|
|
},
|
|
download: {
|
|
defaultResolution: "720p",
|
|
defaultAudioFormat: "m4a",
|
|
defaultSubtitleLanguage: "en"
|
|
},
|
|
limits: {
|
|
characterLimit: 25000, // Standard MCP character limit
|
|
maxTranscriptLength: 50000 // Transcripts can be larger
|
|
},
|
|
cookies: {
|
|
file: undefined,
|
|
fromBrowser: undefined
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Load configuration from environment variables
|
|
*/
|
|
function loadEnvConfig(): DeepPartial<Config> {
|
|
const envConfig: DeepPartial<Config> = {};
|
|
|
|
// File configuration
|
|
const fileConfig: DeepPartial<Config['file']> = {
|
|
sanitize: {
|
|
replaceChar: process.env.YTDLP_SANITIZE_REPLACE_CHAR,
|
|
truncateSuffix: process.env.YTDLP_SANITIZE_TRUNCATE_SUFFIX,
|
|
illegalChars: (() => {
|
|
if (!process.env.YTDLP_SANITIZE_ILLEGAL_CHARS) return undefined;
|
|
try {
|
|
return new RegExp(process.env.YTDLP_SANITIZE_ILLEGAL_CHARS);
|
|
} catch {
|
|
console.warn('[yt-dlp-mcp] Invalid regex in YTDLP_SANITIZE_ILLEGAL_CHARS, using default');
|
|
return undefined;
|
|
}
|
|
})(),
|
|
reservedNames: process.env.YTDLP_SANITIZE_RESERVED_NAMES?.split(',')
|
|
}
|
|
};
|
|
|
|
if (process.env.YTDLP_MAX_FILENAME_LENGTH) {
|
|
const parsed = parseInt(process.env.YTDLP_MAX_FILENAME_LENGTH, 10);
|
|
if (!isNaN(parsed) && parsed >= 5) {
|
|
fileConfig.maxFilenameLength = parsed;
|
|
} else {
|
|
console.warn('[yt-dlp-mcp] Invalid YTDLP_MAX_FILENAME_LENGTH, using default');
|
|
}
|
|
}
|
|
if (process.env.YTDLP_DOWNLOADS_DIR) {
|
|
fileConfig.downloadsDir = process.env.YTDLP_DOWNLOADS_DIR;
|
|
}
|
|
if (process.env.YTDLP_TEMP_DIR_PREFIX) {
|
|
fileConfig.tempDirPrefix = process.env.YTDLP_TEMP_DIR_PREFIX;
|
|
}
|
|
|
|
if (Object.keys(fileConfig).length > 0) {
|
|
envConfig.file = fileConfig;
|
|
}
|
|
|
|
// Download configuration
|
|
const downloadConfig: Partial<Config['download']> = {};
|
|
if (process.env.YTDLP_DEFAULT_RESOLUTION &&
|
|
['480p', '720p', '1080p', 'best'].includes(process.env.YTDLP_DEFAULT_RESOLUTION)) {
|
|
downloadConfig.defaultResolution = process.env.YTDLP_DEFAULT_RESOLUTION as Config['download']['defaultResolution'];
|
|
}
|
|
if (process.env.YTDLP_DEFAULT_AUDIO_FORMAT &&
|
|
['m4a', 'mp3'].includes(process.env.YTDLP_DEFAULT_AUDIO_FORMAT)) {
|
|
downloadConfig.defaultAudioFormat = process.env.YTDLP_DEFAULT_AUDIO_FORMAT as Config['download']['defaultAudioFormat'];
|
|
}
|
|
if (process.env.YTDLP_DEFAULT_SUBTITLE_LANG) {
|
|
downloadConfig.defaultSubtitleLanguage = process.env.YTDLP_DEFAULT_SUBTITLE_LANG;
|
|
}
|
|
if (Object.keys(downloadConfig).length > 0) {
|
|
envConfig.download = downloadConfig;
|
|
}
|
|
|
|
// Cookie configuration
|
|
const cookiesConfig: Partial<Config['cookies']> = {};
|
|
if (process.env.YTDLP_COOKIES_FILE) {
|
|
cookiesConfig.file = process.env.YTDLP_COOKIES_FILE;
|
|
}
|
|
if (process.env.YTDLP_COOKIES_FROM_BROWSER) {
|
|
cookiesConfig.fromBrowser = process.env.YTDLP_COOKIES_FROM_BROWSER;
|
|
}
|
|
if (Object.keys(cookiesConfig).length > 0) {
|
|
envConfig.cookies = cookiesConfig;
|
|
}
|
|
|
|
return envConfig;
|
|
}
|
|
|
|
/**
|
|
* Validate configuration
|
|
*/
|
|
function validateConfig(config: Config): void {
|
|
// Validate filename length
|
|
if (config.file.maxFilenameLength < 5) {
|
|
throw new Error('maxFilenameLength must be at least 5');
|
|
}
|
|
|
|
// Validate downloads directory
|
|
if (!config.file.downloadsDir) {
|
|
throw new Error('downloadsDir must be specified');
|
|
}
|
|
|
|
// Validate temporary directory prefix
|
|
if (!config.file.tempDirPrefix) {
|
|
throw new Error('tempDirPrefix must be specified');
|
|
}
|
|
|
|
// Validate default resolution
|
|
if (!['480p', '720p', '1080p', 'best'].includes(config.download.defaultResolution)) {
|
|
throw new Error('Invalid defaultResolution');
|
|
}
|
|
|
|
// Validate default audio format
|
|
if (!['m4a', 'mp3'].includes(config.download.defaultAudioFormat)) {
|
|
throw new Error('Invalid defaultAudioFormat');
|
|
}
|
|
|
|
// Validate default subtitle language
|
|
if (!/^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$/i.test(config.download.defaultSubtitleLanguage)) {
|
|
throw new Error('Invalid defaultSubtitleLanguage');
|
|
}
|
|
|
|
// Validate cookies (lenient - warnings only)
|
|
validateCookiesConfig(config);
|
|
}
|
|
|
|
/**
|
|
* Validate cookie configuration (lenient - logs warnings but doesn't throw)
|
|
*/
|
|
function validateCookiesConfig(config: Config): void {
|
|
// Validate cookie file path
|
|
if (config.cookies.file) {
|
|
if (!fs.existsSync(config.cookies.file)) {
|
|
console.warn(`[yt-dlp-mcp] Cookie file not found: ${config.cookies.file}, continuing without cookies`);
|
|
config.cookies.file = undefined;
|
|
}
|
|
}
|
|
|
|
// Validate browser name only
|
|
// Format: BROWSER[:PROFILE_OR_PATH][::CONTAINER]
|
|
// We only validate browser name; yt-dlp will validate path/container
|
|
if (config.cookies.fromBrowser) {
|
|
const browserName = config.cookies.fromBrowser.split(':')[0].toLowerCase();
|
|
|
|
if (!VALID_BROWSERS.includes(browserName as ValidBrowser)) {
|
|
console.warn(`[yt-dlp-mcp] Invalid browser name: ${browserName}. Valid browsers: ${VALID_BROWSERS.join(', ')}`);
|
|
config.cookies.fromBrowser = undefined;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Merge configuration
|
|
*/
|
|
function mergeConfig(base: Config, override: DeepPartial<Config>): Config {
|
|
return {
|
|
file: {
|
|
maxFilenameLength: override.file?.maxFilenameLength || base.file.maxFilenameLength,
|
|
downloadsDir: override.file?.downloadsDir || base.file.downloadsDir,
|
|
tempDirPrefix: override.file?.tempDirPrefix || base.file.tempDirPrefix,
|
|
sanitize: {
|
|
replaceChar: override.file?.sanitize?.replaceChar || base.file.sanitize.replaceChar,
|
|
truncateSuffix: override.file?.sanitize?.truncateSuffix || base.file.sanitize.truncateSuffix,
|
|
illegalChars: (override.file?.sanitize?.illegalChars || base.file.sanitize.illegalChars) as RegExp,
|
|
reservedNames: (override.file?.sanitize?.reservedNames || base.file.sanitize.reservedNames) as readonly string[]
|
|
}
|
|
},
|
|
tools: {
|
|
required: (override.tools?.required || base.tools.required) as readonly string[]
|
|
},
|
|
download: {
|
|
defaultResolution: override.download?.defaultResolution || base.download.defaultResolution,
|
|
defaultAudioFormat: override.download?.defaultAudioFormat || base.download.defaultAudioFormat,
|
|
defaultSubtitleLanguage: override.download?.defaultSubtitleLanguage || base.download.defaultSubtitleLanguage
|
|
},
|
|
limits: {
|
|
characterLimit: override.limits?.characterLimit || base.limits.characterLimit,
|
|
maxTranscriptLength: override.limits?.maxTranscriptLength || base.limits.maxTranscriptLength
|
|
},
|
|
cookies: {
|
|
file: override.cookies?.file ?? base.cookies.file,
|
|
fromBrowser: override.cookies?.fromBrowser ?? base.cookies.fromBrowser
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Load configuration
|
|
*/
|
|
export function loadConfig(): Config {
|
|
const envConfig = loadEnvConfig();
|
|
const config = mergeConfig(defaultConfig, envConfig);
|
|
validateConfig(config);
|
|
return config;
|
|
}
|
|
|
|
/**
|
|
* Safe filename processing function
|
|
*/
|
|
export function sanitizeFilename(filename: string, config: Config['file']): string {
|
|
// Remove illegal characters
|
|
let safe = filename.replace(config.sanitize.illegalChars, config.sanitize.replaceChar);
|
|
|
|
// Check reserved names
|
|
const basename = path.parse(safe).name.toUpperCase();
|
|
if (config.sanitize.reservedNames.includes(basename)) {
|
|
safe = `_${safe}`;
|
|
}
|
|
|
|
// Handle length limitation
|
|
if (safe.length > config.maxFilenameLength) {
|
|
const ext = path.extname(safe);
|
|
const name = safe.slice(0, config.maxFilenameLength - ext.length - config.sanitize.truncateSuffix.length);
|
|
safe = `${name}${config.sanitize.truncateSuffix}${ext}`;
|
|
}
|
|
|
|
return safe;
|
|
}
|
|
|
|
/**
|
|
* Get cookie-related yt-dlp arguments
|
|
* Priority: file > fromBrowser
|
|
* @param config Configuration object
|
|
* @returns Array of yt-dlp arguments for cookie handling
|
|
*/
|
|
export function getCookieArgs(config: Config): string[] {
|
|
// Guard against missing cookies config
|
|
if (!config.cookies) {
|
|
return [];
|
|
}
|
|
// Cookie file takes precedence over browser extraction
|
|
if (config.cookies.file) {
|
|
return ['--cookies', config.cookies.file];
|
|
}
|
|
if (config.cookies.fromBrowser) {
|
|
return ['--cookies-from-browser', config.cookies.fromBrowser];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
// Export current configuration instance
|
|
export const CONFIG = loadConfig();
|