ffmpeg-gui/backend/server.js

200 lines
4.8 KiB
JavaScript
Raw Permalink Normal View History

2025-12-15 23:53:29 +09:00
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}`);
});