const express = require("express"); const { spawn } = require("child_process"); const path = require("path"); const { v4: uuidv4 } = require("uuid"); const app = express(); const PORT = 3000; app.use(express.json()); app.use(express.static(path.join(__dirname, "../frontend/dist"))); // 다운로드 요청 임시 저장 const downloadRequests = new Map(); // HH:MM:SS 형식을 초 단위로 변환하는 헬퍼 함수 const timeToSeconds = (timeStr) => { if (!timeStr) return 0; const parts = timeStr.split(":").map(Number); if (parts.length === 3) { return parts[0] * 3600 + parts[1] * 60 + parts[2]; } return 0; }; // ffprobe를 사용하여 URL 유효성을 검사하고 영상 길이를 가져오는 헬퍼 함수 const validateUrl = (url, options) => { return new Promise((resolve, reject) => { const args = []; if (options?.userAgent) { args.push("-headers", `user-agent: ${options.userAgent}`); } if (options?.referer) { args.push("-headers", `referer: ${options.referer}`); } args.push( "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", url ); const ffprobe = spawn("ffprobe", args); let output = ""; let stderr = ""; ffprobe.stdout.on("data", (data) => { output += data.toString(); }); ffprobe.stderr.on("data", (data) => { stderr += data.toString(); }); ffprobe.on("close", (code) => { if (code === 0) { const duration = parseFloat(output); resolve(isNaN(duration) ? 0 : duration); } else { console.error("ffprobe validation failed:", stderr); reject(new Error("유효하지 않은 URL이거나 접근할 수 없습니다.")); } }); }); }; app.post("/api/download-request", async (req, res) => { let { url, options, title } = req.body; // 입력값 앞뒤 공백 제거 if (url) url = url.trim(); if (title) title = title.trim(); if (!url) { return res.status(400).json({ error: "URL을 입력해주세요." }); } try { // URL 유효성 검사 및 영상 길이 확인 const duration = await validateUrl(url, options); // 시작 시간 유효성 검사 if (options?.startTime) { const startTimeSec = timeToSeconds(options.startTime); if (duration > 0 && startTimeSec >= duration) { throw new Error( `시작 시간이 영상 길이(${new Date(duration * 1000) .toISOString() .substr(11, 8)})보다 큽니다.` ); } } const id = uuidv4(); downloadRequests.set(id, { url, options, title }); // 5분 후 요청 만료 setTimeout(() => { downloadRequests.delete(id); }, 5 * 60 * 1000); res.json({ id }); } catch (error) { console.error("Download request error:", error); res .status(400) .json({ error: error.message || "알 수 없는 오류가 발생했습니다." }); } }); app.get("/api/download/:id", (req, res) => { const { id } = req.params; const request = downloadRequests.get(id); if (!request) { return res.status(404).send("Download request not found or expired"); } const { url, options, title } = request; const timestamp = Date.now(); let filename; if (title) { // 파일명 정제: 안전하지 않은 문자 제거 const safeTitle = title.replace(/[^a-z0-9가-힣_\-\. ]/gi, "_"); filename = `${safeTitle}.mp4`; } else { filename = `video_${timestamp}.mp4`; } res.header( "Content-Disposition", `attachment; filename="${encodeURIComponent(filename)}"` ); res.header("Content-Type", "video/mp4"); let args = []; // 403 Forbidden 에러 방지 옵션 if (options?.userAgent) { args.push("-headers", `user-agent: ${options.userAgent}`); } if (options?.referer) { args.push("-headers", `referer: ${options.referer}`); } // 입력 URL args.push("-i", url); // 시간 범위 옵션 if (options?.startTime) { args.push("-ss", options.startTime); } if (options?.endTime) { args.push("-to", options.endTime); } // 스트리밍을 위한 출력 옵션 args.push( "-c", "copy", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov", "pipe:1" ); console.log("Executing ffmpeg with args:", args); const ffmpeg = spawn("ffmpeg", args); ffmpeg.stdout.pipe(res); ffmpeg.stderr.on("data", (data) => { console.error(`stderr: ${data}`); }); ffmpeg.on("close", (code) => { console.log(`child process exited with code ${code}`); downloadRequests.delete(id); }); req.on("close", () => { ffmpeg.kill(); }); }); app.get("*", (req, res) => { res.sendFile(path.join(__dirname, "../frontend/dist/index.html")); }); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); });