173 lines
5.8 KiB
TypeScript
173 lines
5.8 KiB
TypeScript
import { exec } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import { unlink, mkdir, copyFile } from 'fs/promises';
|
|
import { existsSync } from 'fs';
|
|
import path from 'path';
|
|
import type { VideoInfo, FormatOption, DownloadRequest, DownloadResponse } from './types';
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
// Get yt-dlp path from environment variable or use default
|
|
// Read at runtime to ensure env vars are loaded
|
|
function getYtDlpPath(): string {
|
|
return process.env.YT_DLP_PATH || 'yt-dlp';
|
|
}
|
|
|
|
// Get ffmpeg location flag for yt-dlp (optional)
|
|
function getFfmpegLocationFlag(): string {
|
|
const ffmpegPath = process.env.FFMPEG_PATH;
|
|
return ffmpegPath ? `--ffmpeg-location "${ffmpegPath}"` : '';
|
|
}
|
|
|
|
const DOWNLOADS_DIR = path.join(process.cwd(), 'downloads');
|
|
const PUBLIC_DOWNLOADS_DIR = path.join(process.cwd(), 'public', 'downloads');
|
|
|
|
export function sanitizeFilename(filename: string): string {
|
|
return filename
|
|
.replace(/[^a-z0-9]/gi, '_')
|
|
.replace(/_+/g, '_')
|
|
.substring(0, 200);
|
|
}
|
|
|
|
// Ensure directories exist
|
|
async function ensureDirectories() {
|
|
if (!existsSync(DOWNLOADS_DIR)) {
|
|
await mkdir(DOWNLOADS_DIR, { recursive: true });
|
|
}
|
|
if (!existsSync(PUBLIC_DOWNLOADS_DIR)) {
|
|
await mkdir(PUBLIC_DOWNLOADS_DIR, { recursive: true });
|
|
}
|
|
}
|
|
|
|
export async function getVideoInfo(url: string): Promise<VideoInfo | null> {
|
|
try {
|
|
await ensureDirectories();
|
|
|
|
// Get video info as JSON
|
|
const ytDlpPath = getYtDlpPath();
|
|
console.log('[getVideoInfo] Using yt-dlp path:', ytDlpPath);
|
|
|
|
// Increase maxBuffer to handle large JSON responses from long videos
|
|
// Default is 1MB, increase to 10MB to handle videos with many formats
|
|
const { stdout } = await execAsync(
|
|
`"${ytDlpPath}" --dump-json --no-download "${url}"`,
|
|
{ maxBuffer: 10 * 1024 * 1024 } // 10MB buffer
|
|
);
|
|
|
|
const info = JSON.parse(stdout);
|
|
|
|
const formats: FormatOption[] = (info.formats || []).map((f: any) => ({
|
|
formatId: f.format_id,
|
|
ext: f.ext,
|
|
resolution: f.resolution || `${f.width}x${f.height}`,
|
|
filesize: f.filesize,
|
|
formatNote: f.format_note,
|
|
}));
|
|
|
|
return {
|
|
title: info.title || 'Unknown',
|
|
duration: info.duration || 0,
|
|
thumbnail: info.thumbnail,
|
|
formats,
|
|
};
|
|
} catch (error) {
|
|
console.error('Error getting video info:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function downloadVideo(
|
|
request: DownloadRequest
|
|
): Promise<DownloadResponse> {
|
|
try {
|
|
await ensureDirectories();
|
|
|
|
const { url, format, formatType } = request;
|
|
const fileId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
|
|
|
let ytDlpFormat = '';
|
|
let outputExt = format;
|
|
|
|
if (formatType === 'audio') {
|
|
// Audio formats
|
|
if (format === 'mp3') {
|
|
ytDlpFormat = 'bestaudio[ext=m4a]/bestaudio';
|
|
outputExt = 'mp3';
|
|
} else if (format === 'wav') {
|
|
ytDlpFormat = 'bestaudio[ext=m4a]/bestaudio';
|
|
outputExt = 'wav';
|
|
} else {
|
|
ytDlpFormat = `bestaudio[ext=${format}]/bestaudio`;
|
|
}
|
|
} else {
|
|
// Video formats
|
|
ytDlpFormat = `bestvideo[ext=${format}]+bestaudio[ext=m4a]/best[ext=${format}]/best`;
|
|
}
|
|
|
|
const outputPath = path.join(DOWNLOADS_DIR, `${fileId}.%(ext)s`);
|
|
const finalPath = path.join(PUBLIC_DOWNLOADS_DIR, `${fileId}.${outputExt}`);
|
|
|
|
// Get yt-dlp path and ffmpeg location at runtime
|
|
const ytDlpPath = getYtDlpPath();
|
|
const ffmpegFlag = getFfmpegLocationFlag();
|
|
console.log('[downloadVideo] Using yt-dlp path:', ytDlpPath);
|
|
if (ffmpegFlag) console.log('[downloadVideo] Using ffmpeg location flag:', ffmpegFlag);
|
|
|
|
// Download with yt-dlp
|
|
let command = `"${ytDlpPath}" ${ffmpegFlag} -f "${ytDlpFormat}" -o "${outputPath}" "${url}"`;
|
|
|
|
if (formatType === 'audio' && format === 'mp3') {
|
|
// For MP3, we need to extract audio and convert
|
|
command = `"${ytDlpPath}" ${ffmpegFlag} -f "bestaudio[ext=m4a]/bestaudio" -x --audio-format mp3 --audio-quality 0 -o "${outputPath}" "${url}"`;
|
|
} else if (formatType === 'audio' && format === 'wav') {
|
|
// For WAV, extract and convert
|
|
command = `"${ytDlpPath}" ${ffmpegFlag} -f "bestaudio[ext=m4a]/bestaudio" -x --audio-format wav -o "${outputPath}" "${url}"`;
|
|
}
|
|
|
|
// Increase maxBuffer for downloads as well
|
|
await execAsync(command, { maxBuffer: 10 * 1024 * 1024 }); // 10MB buffer
|
|
|
|
// Find the downloaded file
|
|
const files = await import('fs/promises').then(fs =>
|
|
fs.readdir(DOWNLOADS_DIR)
|
|
);
|
|
const downloadedFile = files.find(f => f.startsWith(fileId));
|
|
|
|
if (!downloadedFile) {
|
|
throw new Error('Downloaded file not found');
|
|
}
|
|
|
|
const downloadedPath = path.join(DOWNLOADS_DIR, downloadedFile);
|
|
const downloadedExt = path.extname(downloadedFile).slice(1);
|
|
|
|
// Get video info for response (before moving file)
|
|
const videoInfo = await getVideoInfo(url);
|
|
const safeFilename = sanitizeFilename(videoInfo?.title || 'video');
|
|
|
|
// If extension matches, just copy. Otherwise, rename during copy
|
|
if (downloadedExt === outputExt) {
|
|
// Copy file to public directory
|
|
await copyFile(downloadedPath, finalPath);
|
|
await unlink(downloadedPath);
|
|
} else {
|
|
// For format conversions, yt-dlp should have already converted
|
|
// But if extension doesn't match, copy with new extension
|
|
await copyFile(downloadedPath, finalPath);
|
|
await unlink(downloadedPath);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
downloadUrl: `/downloads/${fileId}.${outputExt}`,
|
|
filename: `${safeFilename}.${outputExt}`,
|
|
videoInfo: videoInfo || undefined,
|
|
};
|
|
} catch (error: any) {
|
|
console.error('Error downloading video:', error);
|
|
return {
|
|
success: false,
|
|
error: error.message || 'Failed to download video',
|
|
};
|
|
}
|
|
}
|