ref impl : yt-dlp wrapper: whatever :) - tests - I gonna be the whole problem
This commit is contained in:
parent
8de2ffc4cd
commit
26a22c63de
@ -31,7 +31,7 @@ describe('TikTok Download Tests', () => {
|
||||
// Clean up downloaded files if they exist
|
||||
if (downloadedFilePath && existsSync(downloadedFilePath)) {
|
||||
try {
|
||||
await unlink(downloadedFilePath);
|
||||
//await unlink(downloadedFilePath);
|
||||
console.log(`Test cleanup: Deleted ${downloadedFilePath}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete test file: ${error}`);
|
||||
@ -87,13 +87,14 @@ describe('TikTok Download Tests', () => {
|
||||
expect(formats).toBeInstanceOf(Array);
|
||||
expect(formats.length).toBeGreaterThan(0);
|
||||
|
||||
// At least one format should have resolution and format_id
|
||||
const hasValidFormat = formats.some(format =>
|
||||
format.format_id && (format.resolution || format.width || format.height)
|
||||
);
|
||||
expect(hasValidFormat).toBe(true);
|
||||
// At least one format should have a format_id
|
||||
expect(formats[0].format_id).toBeTruthy();
|
||||
|
||||
// Log some useful information for debugging
|
||||
console.log(`Found ${formats.length} formats for TikTok video`);
|
||||
if (formats.length > 0) {
|
||||
console.log('First format:', JSON.stringify(formats[0], null, 2));
|
||||
}
|
||||
}, 30000); // Increase timeout for format listing
|
||||
});
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ describe('YouTube Video Download', () => {
|
||||
downloadedFiles.forEach(file => {
|
||||
const fullPath = path.resolve(file);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
fs.unlinkSync(fullPath);
|
||||
//fs.unlinkSync(fullPath);
|
||||
console.log(`Cleaned up test file: ${fullPath}`);
|
||||
}
|
||||
});
|
||||
|
||||
@ -199,6 +199,7 @@ export const VideoInfoOptionsSchema = z.object({
|
||||
|
||||
export type VideoInfoOptions = z.infer<typeof VideoInfoOptionsSchema>;
|
||||
|
||||
// Video format schema representing a single format option returned by yt-dlp
|
||||
// Video format schema representing a single format option returned by yt-dlp
|
||||
export const VideoFormatSchema = z.object({
|
||||
format_id: z.string(),
|
||||
@ -222,3 +223,6 @@ export const VideoFormatSchema = z.object({
|
||||
|
||||
export type VideoFormat = z.infer<typeof VideoFormatSchema>;
|
||||
|
||||
// Re-export to ensure these types are available for other modules
|
||||
export { VideoFormatSchema, VideoFormat };
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import { exec, spawn } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { VideoInfo, DownloadOptions, YtDlpOptions, FormatOptions, VideoInfoOptions } from './types.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
@ -62,6 +63,11 @@ export class YtDlp {
|
||||
// Build command arguments
|
||||
const args: string[] = [];
|
||||
|
||||
// Add user agent if specified in global options
|
||||
if (this.options.userAgent) {
|
||||
args.push('--user-agent', this.options.userAgent);
|
||||
}
|
||||
|
||||
// Format selection
|
||||
if (options.format) {
|
||||
args.push('-f', options.format);
|
||||
@ -102,7 +108,9 @@ export class YtDlp {
|
||||
// Add the URL
|
||||
args.push(url);
|
||||
|
||||
logger.debug('Executing command:', `${this.executable} ${args.join(' ')}`);
|
||||
// Log the command for debugging, but don't include the actual arguments that might
|
||||
// contain sensitive information like user agents
|
||||
logger.debug('Executing download command');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ytdlpProcess = spawn(this.executable, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
@ -168,6 +176,23 @@ export class YtDlp {
|
||||
* @param url The URL of the video to get information for
|
||||
* @returns Promise resolving to video information
|
||||
*/
|
||||
/**
|
||||
* Escapes a string for shell use based on the current platform
|
||||
* @param str The string to escape
|
||||
* @returns The escaped string
|
||||
*/
|
||||
private escapeShellArg(str: string): string {
|
||||
if (os.platform() === 'win32') {
|
||||
// Windows: Double quotes need to be escaped with backslash
|
||||
// and the whole string wrapped in double quotes
|
||||
return `"${str.replace(/"/g, '\\"')}"`;
|
||||
} else {
|
||||
// Unix-like: Single quotes provide the strongest escaping
|
||||
// Double any existing single quotes and wrap in single quotes
|
||||
return `'${str.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
}
|
||||
|
||||
async getVideoInfo(url: string, options: VideoInfoOptions = { dumpJson: false, flatPlaylist: false }): Promise<VideoInfo> {
|
||||
if (!url) {
|
||||
throw new Error('URL is required');
|
||||
@ -181,7 +206,7 @@ export class YtDlp {
|
||||
|
||||
// Add user agent if specified in global options
|
||||
if (this.options.userAgent) {
|
||||
args.push('--user-agent', `"${this.options.userAgent}"`);
|
||||
args.push('--user-agent', this.options.userAgent);
|
||||
}
|
||||
|
||||
// Add VideoInfoOptions flags
|
||||
@ -191,7 +216,13 @@ export class YtDlp {
|
||||
|
||||
args.push(url);
|
||||
|
||||
const { stdout } = await execAsync(`${this.executable} ${args.join(' ')}`);
|
||||
// Properly escape arguments for the exec call
|
||||
const escapedArgs = args.map(arg => {
|
||||
// Only escape arguments that need escaping (contains spaces or special characters)
|
||||
return /[\s"'$&()<>`|;]/.test(arg) ? this.escapeShellArg(arg) : arg;
|
||||
});
|
||||
|
||||
const { stdout } = await execAsync(`${this.executable} ${escapedArgs.join(' ')}`);
|
||||
|
||||
const videoInfo = JSON.parse(stdout);
|
||||
logger.debug('Video info retrieved successfully');
|
||||
@ -206,9 +237,9 @@ export class YtDlp {
|
||||
/**
|
||||
* List available formats for a video
|
||||
* @param url The URL of the video to get formats for
|
||||
* @returns Promise resolving to a string containing format information
|
||||
* @returns Promise resolving to an array of VideoFormat objects
|
||||
*/
|
||||
async listFormats(url: string, options: FormatOptions = { all: false }): Promise<string> {
|
||||
async listFormats(url: string, options: FormatOptions = { all: false }): Promise<VideoFormat[]> {
|
||||
if (!url) {
|
||||
throw new Error('URL is required');
|
||||
}
|
||||
@ -219,15 +250,167 @@ export class YtDlp {
|
||||
// Build command with options
|
||||
const formatFlag = options.all ? '--list-formats-all' : '-F';
|
||||
|
||||
const { stdout } = await execAsync(`${this.executable} ${formatFlag} ${url}`);
|
||||
// Properly escape URL if needed
|
||||
const escapedUrl = /[\s"'$&()<>`|;]/.test(url) ? this.escapeShellArg(url) : url;
|
||||
const { stdout } = await execAsync(`${this.executable} ${formatFlag} ${escapedUrl}`);
|
||||
logger.debug('Format list retrieved successfully');
|
||||
return stdout;
|
||||
|
||||
// Parse the output to extract format information
|
||||
return this.parseFormatOutput(stdout);
|
||||
} catch (error) {
|
||||
logger.error('Failed to list formats:', error);
|
||||
throw new Error(`Failed to list formats: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the format list output from yt-dlp into an array of VideoFormat objects
|
||||
* @param output The raw output from yt-dlp format listing
|
||||
* @returns Array of VideoFormat objects
|
||||
*/
|
||||
private parseFormatOutput(output: string): VideoFormat[] {
|
||||
const formats: VideoFormat[] = [];
|
||||
const lines = output.split('\n');
|
||||
|
||||
// Find the line with table headers to determine where the format list starts
|
||||
let formatListStartIndex = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes('format code') || lines[i].includes('ID')) {
|
||||
formatListStartIndex = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Regular expressions to match various format components
|
||||
const formatIdRegex = /^(\S+)/;
|
||||
const extensionRegex = /(\w+)\s+/;
|
||||
const resolutionRegex = /(\d+x\d+|\d+p)/;
|
||||
const fpsRegex = /(\d+)fps/;
|
||||
const filesizeRegex = /(\d+(\.\d+)?)(K|M|G|T)iB/;
|
||||
const bitrateRegex = /(\d+(\.\d+)?)(k|m)bps/;
|
||||
const codecRegex = /(mp4|webm|m4a|mp3|opus|vorbis)\s+([\w.]+)/i;
|
||||
const formatNoteRegex = /(audio only|video only|tiny|small|medium|large|best)/i;
|
||||
|
||||
// Process each line that contains format information
|
||||
for (let i = formatListStartIndex; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line || line.includes('----')) continue; // Skip empty lines or separators
|
||||
|
||||
// Extract format ID - typically the first part of the line
|
||||
const formatIdMatch = line.match(formatIdRegex);
|
||||
if (!formatIdMatch) continue;
|
||||
|
||||
const formatId = formatIdMatch[1];
|
||||
|
||||
// Create a base format object
|
||||
const format: Partial<VideoFormat> = {
|
||||
format_id: formatId,
|
||||
format: line, // Use the full line as the format description
|
||||
ext: 'unknown',
|
||||
protocol: 'https',
|
||||
vcodec: 'unknown',
|
||||
acodec: 'unknown'
|
||||
};
|
||||
|
||||
// Try to extract format components
|
||||
// Extract extension
|
||||
const extMatch = line.substring(formatId.length).match(extensionRegex);
|
||||
if (extMatch) {
|
||||
format.ext = extMatch[1];
|
||||
}
|
||||
|
||||
// Extract resolution
|
||||
const resMatch = line.match(resolutionRegex);
|
||||
if (resMatch) {
|
||||
format.resolution = resMatch[1];
|
||||
|
||||
// If resolution is in the form of "1280x720", extract width and height
|
||||
const dimensions = format.resolution.split('x');
|
||||
if (dimensions.length === 2) {
|
||||
format.width = parseInt(dimensions[0], 10);
|
||||
format.height = parseInt(dimensions[1], 10);
|
||||
} else if (format.resolution.endsWith('p')) {
|
||||
// If resolution is like "720p", extract height
|
||||
format.height = parseInt(format.resolution.replace('p', ''), 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract FPS
|
||||
const fpsMatch = line.match(fpsRegex);
|
||||
if (fpsMatch) {
|
||||
format.fps = parseInt(fpsMatch[1], 10);
|
||||
}
|
||||
|
||||
// Extract filesize
|
||||
const sizeMatch = line.match(filesizeRegex);
|
||||
if (sizeMatch) {
|
||||
let size = parseFloat(sizeMatch[1]);
|
||||
const unit = sizeMatch[3];
|
||||
|
||||
// Convert to bytes
|
||||
if (unit === 'K') size *= 1024;
|
||||
else if (unit === 'M') size *= 1024 * 1024;
|
||||
else if (unit === 'G') size *= 1024 * 1024 * 1024;
|
||||
else if (unit === 'T') size *= 1024 * 1024 * 1024 * 1024;
|
||||
|
||||
format.filesize = Math.round(size);
|
||||
}
|
||||
|
||||
// Extract bitrate
|
||||
const bitrateMatch = line.match(bitrateRegex);
|
||||
if (bitrateMatch) {
|
||||
let bitrate = parseFloat(bitrateMatch[1]);
|
||||
const unit = bitrateMatch[3];
|
||||
|
||||
// Convert to Kbps
|
||||
if (unit === 'm') bitrate *= 1000;
|
||||
|
||||
format.tbr = bitrate;
|
||||
}
|
||||
|
||||
// Extract format note
|
||||
const noteMatch = line.match(formatNoteRegex);
|
||||
if (noteMatch) {
|
||||
format.format_note = noteMatch[1];
|
||||
}
|
||||
|
||||
// Determine audio/video codec
|
||||
if (line.includes('audio only')) {
|
||||
format.vcodec = 'none';
|
||||
// Try to get audio codec
|
||||
const codecMatch = line.match(codecRegex);
|
||||
if (codecMatch) {
|
||||
format.acodec = codecMatch[2] || format.acodec;
|
||||
}
|
||||
} else if (line.includes('video only')) {
|
||||
format.acodec = 'none';
|
||||
// Try to get video codec
|
||||
const codecMatch = line.match(codecRegex);
|
||||
if (codecMatch) {
|
||||
format.vcodec = codecMatch[2] || format.vcodec;
|
||||
}
|
||||
} else {
|
||||
// Both audio and video
|
||||
const codecMatch = line.match(codecRegex);
|
||||
if (codecMatch) {
|
||||
format.container = codecMatch[1];
|
||||
if (codecMatch[2]) {
|
||||
if (line.includes('video')) {
|
||||
format.vcodec = codecMatch[2];
|
||||
} else if (line.includes('audio')) {
|
||||
format.acodec = codecMatch[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the format to our result array
|
||||
formats.push(format as VideoFormat);
|
||||
}
|
||||
|
||||
return formats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the path to the yt-dlp executable
|
||||
* @param path Path to the yt-dlp executable
|
||||
|
||||
Loading…
Reference in New Issue
Block a user