From f16661d1fd9a54fd8d275cbdf19b150838335e59 Mon Sep 17 00:00:00 2001 From: theoleuthardt Date: Sat, 22 Feb 2025 15:25:08 +0100 Subject: [PATCH] feat: backend now uses ffmpeg as local package on server to convert video to audio, added package to dockerfile, naming conventions --- backend/Dockerfile | 2 +- backend/package-lock.json | 9 +- backend/package.json | 3 +- backend/server.ts | 4 +- backend/src/routes/videoconvert.route.ts | 70 ------------ backend/src/routes/videotoaudio.route.ts | 137 +++++++++++++++++++++++ backend/tsconfig.json | 2 +- 7 files changed, 143 insertions(+), 84 deletions(-) delete mode 100644 backend/src/routes/videoconvert.route.ts create mode 100644 backend/src/routes/videotoaudio.route.ts diff --git a/backend/Dockerfile b/backend/Dockerfile index 6583c94..49c0c65 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,7 +2,7 @@ FROM node:18-alpine AS base # Install system dependencies -RUN apk add --no-cache libc6-compat libreoffice ttf-liberation +RUN apk add --no-cache libc6-compat libreoffice ttf-liberation ffmpeg WORKDIR /app diff --git a/backend/package-lock.json b/backend/package-lock.json index 7b41736..b6705e9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -17,8 +17,7 @@ "fastify-multipart": "^5.3.1", "libreoffice-convert": "^1.6.0", "luxon": "^3.5.0", - "qrcode": "^1.5.4", - "video-to-audio": "^1.0.1" + "qrcode": "^1.5.4" }, "devDependencies": { "@types/luxon": "^3.4.2", @@ -1318,12 +1317,6 @@ "node": ">= 0.8" } }, - "node_modules/video-to-audio": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/video-to-audio/-/video-to-audio-1.0.1.tgz", - "integrity": "sha512-6+h79x2kSO60PYTT2cXcgXIsqw/3X8a5qshsZanxH0cxbGUVxVllwQdNOWr/8ywDNcgGgecSmk8Q6TJ79gtFjQ==", - "license": "MIT" - }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index fb60513..ab5c3e8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,8 +20,7 @@ "fastify-multipart": "^5.3.1", "libreoffice-convert": "^1.6.0", "luxon": "^3.5.0", - "qrcode": "^1.5.4", - "video-to-audio": "^1.0.1" + "qrcode": "^1.5.4" }, "devDependencies": { "@types/luxon": "^3.4.2", diff --git a/backend/server.ts b/backend/server.ts index bffc6aa..6051ea6 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -8,7 +8,7 @@ import { regexTest } from "./src/routes/regextest.route"; import { tmzConvert } from "./src/routes/tmzconvert.route"; import { generateQRCode } from "./src/routes/generateqrcode.route"; import { wordCounter } from "./src/routes/wordcounter.route"; -import { videoConvert } from "./src/routes/videoconvert.route"; +import { videoToAudio } from "./src/routes/videotoaudio.route"; const app = Fastify({ logger: true }); @@ -26,7 +26,7 @@ app.register(regexTest); app.register(tmzConvert); app.register(generateQRCode); app.register(wordCounter); -app.register(videoConvert); +app.register(videoToAudio); const PORT = process.env.PORT || 4000; app.listen({ port: Number(PORT), host: "0.0.0.0" }, () => { diff --git a/backend/src/routes/videoconvert.route.ts b/backend/src/routes/videoconvert.route.ts deleted file mode 100644 index 5daca9b..0000000 --- a/backend/src/routes/videoconvert.route.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; -import * as libre from "libreoffice-convert"; -import { promisify } from "util"; -import { MultipartValue } from "@fastify/multipart"; - -const libreConvertAsync = promisify(libre.convert); - -const mimeTypes: { [key: string]: string } = { - mp4: "video/mp4", - avi: "video/x-msvideo", - mkv: "video/x-matroska", -}; - -export async function videoConvert(app: FastifyInstance) { - app.post( - "/api/video-convert", - async (request: FastifyRequest, reply: FastifyReply) => { - try { - const parts = request.parts(); - - let fileBuffer: Buffer | null = null; - let outputFileExt = ""; - - for await (const part of parts) { - if (part.type === "file") { - fileBuffer = await part.toBuffer(); - } else if ( - part.fieldname === "outputFormat" && - part.type === "field" - ) { - outputFileExt = (part as MultipartValue).value; - } - } - - if (!fileBuffer) { - return reply.status(400).send({ error: "No file uploaded!" }); - } - if (!outputFileExt) { - return reply - .status(400) - .send({ error: "No output format provided!" }); - } - if (!outputFileExt.startsWith(".")) { - outputFileExt = "." + outputFileExt; - } - - const format = outputFileExt.substring(1); - const mimeType = mimeTypes[format] || "application/octet-stream"; - - const convertedBuffer = await libreConvertAsync( - fileBuffer, - outputFileExt, - undefined, - ); - - reply - .header("Content-Type", mimeType) - .header( - "Content-Disposition", - `attachment; filename="converted${outputFileExt}"`, - ) - .status(200) - .send(convertedBuffer); - } catch (error) { - console.error("Convert error:", error); - reply.status(500).send({ error: "Error while converting!" }); - } - }, - ); -} diff --git a/backend/src/routes/videotoaudio.route.ts b/backend/src/routes/videotoaudio.route.ts new file mode 100644 index 0000000..31ffe0a --- /dev/null +++ b/backend/src/routes/videotoaudio.route.ts @@ -0,0 +1,137 @@ +import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { MultipartValue } from "@fastify/multipart"; +import { spawn } from "child_process"; +import { promises as fs } from "fs"; +import * as path from "path"; +import { randomUUID } from "crypto"; + +interface ConversionOptions { + format: string; + bitrate?: string; + channels?: number; + sampleRate?: number; +} + +let options: ConversionOptions; + +export async function videoToAudio(app: FastifyInstance) { + app.post( + "/api/video-to-audio", + async (request: FastifyRequest, reply: FastifyReply) => { + const tmpDir = path.join(process.cwd(), "tmp"); + const sessionId = randomUUID(); + const inputPath = path.join(tmpDir, `input-${sessionId}`); + const outputPath = path.join(tmpDir, `output-${sessionId}`); + + try { + await fs.mkdir(tmpDir, { recursive: true }); + + const parts = request.parts(); + + let fileBuffer: Buffer | null = null; + options = { + format: "mp3", + bitrate: "192k", + channels: 2, + sampleRate: 44100, + }; + + for await (const part of parts) { + if (part.type === "file") { + fileBuffer = await part.toBuffer(); + } else if (part.type === "field") { + const field = part as MultipartValue; + switch (field.fieldname) { + case "format": + const format = field.value.toLowerCase(); + options.format = format; + break; + case "bitrate": + options.bitrate = field.value; + break; + case "channels": + options.channels = parseInt(field.value, 10); + break; + case "sampleRate": + options.sampleRate = parseInt(field.value, 10); + break; + } + } + } + + if (!fileBuffer) { + return reply.status(400).send({ error: "No file uploaded!" }); + } + + await fs.writeFile(inputPath, fileBuffer); + + await new Promise((resolve, reject) => { + const args = [ + "-i", + inputPath, + "-vn", + "-acodec", + options.format === "mp3" ? "libmp3lame" : options.format, + "-ab", + options.bitrate || "192k", + "-ac", + String(options.channels || 2), + "-ar", + String(options.sampleRate || 44100), + outputPath + "." + options.format, + ]; + + const ffmpeg = spawn("ffmpeg", args); + + ffmpeg.stderr.on("data", (data) => { + console.log(`ffmpeg stderr: ${data}`); + }); + + ffmpeg.on("close", (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`ffmpeg process exited with code ${code}`)); + } + }); + + ffmpeg.on("error", (err) => { + reject(err); + }); + }); + + const outputFile = await fs.readFile(outputPath + "." + options.format); + + await Promise.all([ + fs.unlink(inputPath), + fs.unlink(outputPath + "." + options.format), + ]); + + reply + .header("Content-Type", `audio/${options.format}`) + .header( + "Content-Disposition", + `attachment; filename="converted.${options.format}"`, + ) + .send(outputFile); + } catch (error) { + try { + await Promise.all([ + fs.unlink(inputPath).catch(() => {}), + fs + .unlink(outputPath + "." + (options?.format || "mp3")) + .catch(() => {}), + ]); + } catch (cleanupError) { + console.error("Cleanup error:", cleanupError); + } + + console.error("Conversion error:", error); + reply.status(500).send({ + error: "Error during conversion process", + details: error instanceof Error ? error.message : "Unknown error", + }); + } + }, + ); +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json index b0de5ac..c6f6778 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -6,6 +6,6 @@ "rootDir": ".", "strict": true, "esModuleInterop": true, - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, } }