diff --git a/.env.example b/.env.example index a0358d4..03e7cee 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,4 @@ SERVER_ADDR=127.0.0.1 TRACE_MEDIA_SALT=TRACE_MEDIA_SALT TRACE_API_SECRET=TRACE_API_SECRET IP_WHITELIST=192.168.1.100 +MAX_QUEUE=8 \ No newline at end of file diff --git a/server.js b/server.js index f21f40c..1a20eb4 100644 --- a/server.js +++ b/server.js @@ -61,10 +61,11 @@ app.use((req, res, next) => { app.use( rateLimit({ - max: 30, // 30 requests per IP address (per node.js process) - windowMs: 60 * 1000, // per 1 minute + max: 60, // limit each IP to 60 requests per 60 seconds + delayMs: 0, // disable delaying - full speed until the max limit is reached }), ); +app.locals.queue = 0; app.get("/", (req, res) => res.send("ok")); @@ -74,7 +75,7 @@ app.get("/image/:anilistID/:filename", image); app.use("/file/:anilistID/:filename", checkSecret, file); -app.use("/list", checkSecret, list); +app.use("/list", list); app.use("/admin", checkIP, admin); diff --git a/src/image.js b/src/image.js index 72dc488..33cee07 100644 --- a/src/image.js +++ b/src/image.js @@ -3,36 +3,43 @@ import fs from "fs-extra"; import crypto from "crypto"; import child_process from "child_process"; -const { VIDEO_PATH = "/mnt/", TRACE_MEDIA_SALT } = process.env; +const { VIDEO_PATH = "/mnt/", TRACE_MEDIA_SALT, MAX_QUEUE } = process.env; -const generateImagePreview = (filePath, t, size = "m") => { - const ffmpeg = child_process.spawnSync("ffmpeg", [ - "-hide_banner", - "-loglevel", - "error", - "-nostats", - "-y", - "-ss", - t - 10, - "-i", - filePath, - "-ss", - "10", - "-vf", - `scale=${{ l: 640, m: 320, s: 160 }[size]}:-2`, - "-c:v", - "mjpeg", - "-vframes", - "1", - "-f", - "image2pipe", - "pipe:1", - ]); - if (ffmpeg.stderr.length) { - console.log(ffmpeg.stderr.toString()); - } - return ffmpeg.stdout; -}; +const generateImagePreview = async (filePath, t, size = "m") => + new Promise((resolve) => { + const ffmpeg = child_process.spawn("ffmpeg", [ + "-hide_banner", + "-loglevel", + "error", + "-nostats", + "-y", + "-ss", + t - 10, + "-i", + filePath, + "-ss", + "10", + "-vf", + `scale=${{ l: 640, m: 320, s: 160 }[size]}:-2`, + "-c:v", + "mjpeg", + "-vframes", + "1", + "-f", + "image2pipe", + "pipe:1", + ]); + ffmpeg.stderr.on("data", (data) => { + console.log(data.toString()); + }); + let chunks = Buffer.alloc(0); + ffmpeg.stdout.on("data", (data) => { + chunks = Buffer.concat([chunks, data]); + }); + ffmpeg.on("close", () => { + resolve(chunks); + }); + }); export default async (req, res) => { if ( @@ -67,19 +74,22 @@ export default async (req, res) => { if (!videoFilePath.startsWith(VIDEO_PATH)) { return res.status(403).send("Forbidden"); } - if (!fs.existsSync(videoFilePath)) { + if (!(await fs.exists(videoFilePath))) { return res.status(404).send("Not found"); } const size = req.query.size || "m"; if (!["l", "m", "s"].includes(size)) { return res.status(400).send("Bad Request. Invalid param: size"); } + if (req.app.locals.queue > MAX_QUEUE) return res.status(503).send("Service Unavailable"); + req.app.locals.queue++; try { - const image = generateImagePreview(videoFilePath, t, size); + const image = await generateImagePreview(videoFilePath, t, size); res.set("Content-Type", "image/jpg"); res.send(image); } catch (e) { console.log(e); res.status(500).send("Internal Server Error"); } + req.app.locals.queue--; }; diff --git a/src/lib/detect-scene.js b/src/lib/detect-scene.js index f99a4b4..82de361 100644 --- a/src/lib/detect-scene.js +++ b/src/lib/detect-scene.js @@ -10,7 +10,7 @@ export default async (filePath, t, minDuration) => { return null; } - const videoDuration = getVideoDuration(filePath); + const videoDuration = await getVideoDuration(filePath); if (videoDuration === null || t > videoDuration) { return null; } @@ -32,31 +32,33 @@ export default async (filePath, t, minDuration) => { const height = 18; const tempPath = path.join(os.tmpdir(), `videoPreview${process.hrtime().join("")}`); - fs.removeSync(tempPath); - fs.ensureDirSync(tempPath); - const ffmpeg = child_process.spawnSync( - "ffmpeg", - [ - "-y", - "-ss", - trimStart - 10, - "-i", - filePath, - "-ss", - "10", - "-t", - trimEnd - trimStart, - "-an", - "-vf", - `fps=${fps},scale=${width}:${height}`, - `${tempPath}/%04d.jpg`, - ], - { encoding: "utf-8" }, - ); - // console.log(ffmpeg.stderr); + await fs.remove(tempPath); + await fs.ensureDir(tempPath); + await new Promise((resolve) => { + const ffmpeg = child_process.spawn( + "ffmpeg", + [ + "-y", + "-ss", + trimStart - 10, + "-i", + filePath, + "-ss", + "10", + "-t", + trimEnd - trimStart, + "-an", + "-vf", + `fps=${fps},scale=${width}:${height}`, + `${tempPath}/%04d.jpg`, + ], + { encoding: "utf-8" }, + ); + ffmpeg.on("close", resolve); + }); const imageDataList = await Promise.all( - fs.readdirSync(tempPath).map( + (await fs.readdir(tempPath)).map( (file) => new Promise(async (resolve) => { const canvas = Canvas.createCanvas(width, height); @@ -67,7 +69,7 @@ export default async (filePath, t, minDuration) => { }), ), ); - fs.removeSync(tempPath); + await fs.remove(tempPath); const getImageDiff = (a, b) => { let diff = 0; diff --git a/src/lib/get-video-duration.js b/src/lib/get-video-duration.js index e4ba2ed..af91fe1 100644 --- a/src/lib/get-video-duration.js +++ b/src/lib/get-video-duration.js @@ -1,14 +1,21 @@ import child_process from "child_process"; -export default (filePath) => { - const stdLog = child_process.spawnSync( - "ffprobe", - ["-i", filePath, "-show_entries", "format=duration", "-v", "quiet"], - { encoding: "utf-8" }, - ).stdout; - const result = /duration=((\d|\.)+)/.exec(stdLog); - if (result === null) { - return null; - } - return parseFloat(result[1]); -}; +export default async (filePath) => + new Promise((resolve) => { + const ffprobe = child_process.spawn( + "ffprobe", + ["-i", filePath, "-show_entries", "format=duration", "-v", "quiet"], + { encoding: "utf-8" }, + ); + let stdLog = ""; + ffprobe.stdout.on("data", (data) => { + stdLog += data.toString(); + }); + ffprobe.on("close", () => { + const result = /duration=((\d|\.)+)/.exec(stdLog); + if (result === null) { + resolve(null); + } + resolve(parseFloat(result[1])); + }); + }); diff --git a/src/video.js b/src/video.js index f6c52c2..36858aa 100644 --- a/src/video.js +++ b/src/video.js @@ -2,70 +2,78 @@ import path from "path"; import fs from "fs-extra"; import crypto from "crypto"; import child_process from "child_process"; +import { Buffer } from "node:buffer"; import detectScene from "./lib/detect-scene.js"; -const { VIDEO_PATH = "/mnt/", TRACE_MEDIA_SALT } = process.env; +const { VIDEO_PATH = "/mnt/", TRACE_MEDIA_SALT, MAX_QUEUE } = process.env; -const generateVideoPreview = (filePath, start, end, size = "m", mute = false) => { - const ffmpeg = child_process.spawnSync( - "ffmpeg", - [ - "-hide_banner", - "-loglevel", - "error", - "-nostats", - "-y", - "-ss", - start - 10, - "-i", - filePath, - "-ss", - "10", - "-t", - end - start, - mute ? "-an" : "-y", - "-map", - "0:v:0", - "-map", - "0:a:0?", - "-vf", - `scale=${{ l: 640, m: 320, s: 160 }[size]}:-2`, - "-c:v", - "libx264", - "-crf", - "23", - "-profile:v", - "high", - "-preset", - "faster", - "-r", - "24000/1001", - "-pix_fmt", - "yuv420p", - "-c:a", - "aac", - "-b:a", - "128k", - "-max_muxing_queue_size", - "1024", - "-movflags", - "empty_moov", - "-map_metadata", - "-1", - "-map_chapters", - "-1", - "-f", - "mp4", - "-", - ], - { maxBuffer: 1024 * 1024 * 100 }, - ); - if (ffmpeg.stderr.length) { - console.log(ffmpeg.stderr.toString()); - } - return ffmpeg.stdout; -}; +const generateVideoPreview = async (filePath, start, end, size = "m", mute = false) => + new Promise((resolve) => { + const ffmpeg = child_process.spawn( + "ffmpeg", + [ + "-hide_banner", + "-loglevel", + "error", + "-nostats", + "-y", + "-ss", + start - 10, + "-i", + filePath, + "-ss", + "10", + "-t", + end - start, + mute ? "-an" : "-y", + "-map", + "0:v:0", + "-map", + "0:a:0?", + "-vf", + `scale=${{ l: 640, m: 320, s: 160 }[size]}:-2`, + "-c:v", + "libx264", + "-crf", + "23", + "-profile:v", + "high", + "-preset", + "faster", + "-r", + "24000/1001", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + "-b:a", + "128k", + "-max_muxing_queue_size", + "1024", + "-movflags", + "empty_moov", + "-map_metadata", + "-1", + "-map_chapters", + "-1", + "-f", + "mp4", + "-", + ], + { maxBuffer: 1024 * 1024 * 100 }, + ); + ffmpeg.stderr.on("data", (data) => { + console.log(data.toString()); + }); + let chunks = Buffer.alloc(0); + ffmpeg.stdout.on("data", (data) => { + chunks = Buffer.concat([chunks, data]); + }); + ffmpeg.on("close", () => { + resolve(chunks); + }); + }); export default async (req, res) => { if ( @@ -96,7 +104,7 @@ export default async (req, res) => { if (!videoFilePath.startsWith(VIDEO_PATH)) { return res.status(403).send("Forbidden"); } - if (!fs.existsSync(videoFilePath)) { + if (!(await fs.exists(videoFilePath))) { return res.status(404).send("Not found"); } const size = req.query.size || "m"; @@ -104,12 +112,14 @@ export default async (req, res) => { return res.status(400).send("Bad Request. Invalid param: size"); } const minDuration = Number(req.query.minDuration) || 0.25; + if (req.app.locals.queue > MAX_QUEUE) return res.status(503).send("Service Unavailable"); + req.app.locals.queue++; try { const scene = await detectScene(videoFilePath, t, minDuration > 2 ? 2 : minDuration); if (scene === null) { return res.status(500).send("Internal Server Error"); } - const video = generateVideoPreview( + const video = await generateVideoPreview( videoFilePath, scene.start, scene.end, @@ -126,4 +136,5 @@ export default async (req, res) => { console.log(e); res.status(500).send("Internal Server Error"); } + req.app.locals.queue--; };