feat: backend now uses ffmpeg as local package on server to convert video to audio, added package to dockerfile, naming conventions

This commit is contained in:
theoleuthardt 2025-02-22 15:25:08 +01:00
parent 95c8ba211c
commit f16661d1fd
7 changed files with 143 additions and 84 deletions

View file

@ -2,7 +2,7 @@
FROM node:18-alpine AS base FROM node:18-alpine AS base
# Install system dependencies # 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 WORKDIR /app

View file

@ -17,8 +17,7 @@
"fastify-multipart": "^5.3.1", "fastify-multipart": "^5.3.1",
"libreoffice-convert": "^1.6.0", "libreoffice-convert": "^1.6.0",
"luxon": "^3.5.0", "luxon": "^3.5.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4"
"video-to-audio": "^1.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
@ -1318,12 +1317,6 @@
"node": ">= 0.8" "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": { "node_modules/which-module": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",

View file

@ -20,8 +20,7 @@
"fastify-multipart": "^5.3.1", "fastify-multipart": "^5.3.1",
"libreoffice-convert": "^1.6.0", "libreoffice-convert": "^1.6.0",
"luxon": "^3.5.0", "luxon": "^3.5.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4"
"video-to-audio": "^1.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",

View file

@ -8,7 +8,7 @@ import { regexTest } from "./src/routes/regextest.route";
import { tmzConvert } from "./src/routes/tmzconvert.route"; import { tmzConvert } from "./src/routes/tmzconvert.route";
import { generateQRCode } from "./src/routes/generateqrcode.route"; import { generateQRCode } from "./src/routes/generateqrcode.route";
import { wordCounter } from "./src/routes/wordcounter.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 }); const app = Fastify({ logger: true });
@ -26,7 +26,7 @@ app.register(regexTest);
app.register(tmzConvert); app.register(tmzConvert);
app.register(generateQRCode); app.register(generateQRCode);
app.register(wordCounter); app.register(wordCounter);
app.register(videoConvert); app.register(videoToAudio);
const PORT = process.env.PORT || 4000; const PORT = process.env.PORT || 4000;
app.listen({ port: Number(PORT), host: "0.0.0.0" }, () => { app.listen({ port: Number(PORT), host: "0.0.0.0" }, () => {

View file

@ -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<string>).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!" });
}
},
);
}

View file

@ -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<string>;
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<void>((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",
});
}
},
);
}

View file

@ -6,6 +6,6 @@
"rootDir": ".", "rootDir": ".",
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true,
} }
} }