200 lines
4.8 KiB
JavaScript
200 lines
4.8 KiB
JavaScript
|
|
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}`);
|
||
|
|
});
|