initial commit
This commit is contained in:
39
lib/types.ts
Normal file
39
lib/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export type VideoFormat = 'mp4' | 'webm' | 'mkv' | 'avi';
|
||||
export type AudioFormat = 'mp3' | 'wav' | 'm4a' | 'opus';
|
||||
|
||||
export type FormatType = 'video' | 'audio';
|
||||
|
||||
export interface DownloadRequest {
|
||||
url: string;
|
||||
format: VideoFormat | AudioFormat;
|
||||
formatType: FormatType;
|
||||
}
|
||||
|
||||
export interface DownloadResponse {
|
||||
success: boolean;
|
||||
downloadUrl?: string;
|
||||
filename?: string;
|
||||
error?: string;
|
||||
videoInfo?: VideoInfo;
|
||||
}
|
||||
|
||||
export interface VideoInfo {
|
||||
title: string;
|
||||
duration: number;
|
||||
thumbnail?: string;
|
||||
formats?: FormatOption[];
|
||||
}
|
||||
|
||||
export interface FormatOption {
|
||||
formatId: string;
|
||||
ext: string;
|
||||
resolution?: string;
|
||||
filesize?: number;
|
||||
formatNote?: string;
|
||||
}
|
||||
|
||||
export interface TranscodeRequest {
|
||||
fileId: string;
|
||||
format: VideoFormat | AudioFormat;
|
||||
formatType: FormatType;
|
||||
}
|
||||
30
lib/utils.ts
Normal file
30
lib/utils.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
// Validate YouTube URL
|
||||
export function isValidYouTubeUrl(url: string): boolean {
|
||||
const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/.+/;
|
||||
return youtubeRegex.test(url);
|
||||
}
|
||||
|
||||
// Extract YouTube video ID
|
||||
export function extractYouTubeId(url: string): string | null {
|
||||
const patterns = [
|
||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&\n?#]+)/,
|
||||
/youtube\.com\/embed\/([^&\n?#]+)/,
|
||||
/youtube\.com\/v\/([^&\n?#]+)/,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern);
|
||||
if (match && match[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
165
lib/youtube.ts
Normal file
165
lib/youtube.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user