Files
downlink/lib/youtube.ts
2025-12-21 11:43:22 -06:00

166 lines
5.4 KiB
TypeScript

import { exec } from 'child_process';
import { promisify } from 'util';
import { writeFile, readFile, 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';
}
const DOWNLOADS_DIR = path.join(process.cwd(), 'downloads');
const PUBLIC_DOWNLOADS_DIR = path.join(process.cwd(), 'public', 'downloads');
// Sanitize filename for safe storage
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 at runtime
const ytDlpPath = getYtDlpPath();
console.log('[downloadVideo] Using yt-dlp path:', ytDlpPath);
// Download with yt-dlp
let command = `"${ytDlpPath}" -f "${ytDlpFormat}" -o "${outputPath}" "${url}"`;
if (formatType === 'audio' && format === 'mp3') {
// For MP3, we need to extract audio and convert
command = `"${ytDlpPath}" -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}" -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',
};
}
}