Merge pull request #342 from Netzz0/FEAT/better-handling-of-multiples-files

Better handling of multiples files (Added Archive downloads and env var to set maximum concurrent processes)
This commit is contained in:
Emrik Östling 2025-07-22 18:15:01 +02:00 committed by GitHub
commit a9bc9d7e8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 96 additions and 62 deletions

View file

@ -10,6 +10,7 @@
"@kitajs/html": "^4.2.9", "@kitajs/html": "^4.2.9",
"elysia": "^1.3.4", "elysia": "^1.3.4",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"tar": "^7.4.3",
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.28.0", "@eslint/js": "^9.28.0",

View file

@ -20,7 +20,8 @@
"@elysiajs/static": "^1.3.0", "@elysiajs/static": "^1.3.0",
"@kitajs/html": "^4.2.9", "@kitajs/html": "^4.2.9",
"elysia": "^1.3.4", "elysia": "^1.3.4",
"sanitize-filename": "^1.6.3" "sanitize-filename": "^1.6.3",
"tar": "^7.4.3"
}, },
"module": "src/index.tsx", "module": "src/index.tsx",
"type": "module", "type": "module",

View file

@ -1,18 +1,4 @@
const webroot = document.querySelector("meta[name='webroot']").content; const webroot = document.querySelector("meta[name='webroot']").content;
window.downloadAll = function () {
// Get all download links
const downloadLinks = document.querySelectorAll("a[download]");
// Trigger download for each link
downloadLinks.forEach((link, index) => {
// We add a delay for each download to prevent them from starting at the same time
setTimeout(() => {
const event = new MouseEvent("click");
link.dispatchEvent(event);
}, index * 100);
});
};
const jobId = window.location.pathname.split("/").pop(); const jobId = window.location.pathname.split("/").pop();
const main = document.querySelector("main"); const main = document.querySelector("main");
let progressElem = document.querySelector("progress"); let progressElem = document.querySelector("progress");

View file

@ -1,4 +1,6 @@
import { normalizeFiletype } from "../helpers/normalizeFiletype"; import db from "../db/db";
import { MAX_CONVERT_PROCESS } from "../helpers/env";
import { normalizeFiletype, normalizeOutputFiletype } from "../helpers/normalizeFiletype";
import { convert as convertassimp, properties as propertiesassimp } from "./assimp"; import { convert as convertassimp, properties as propertiesassimp } from "./assimp";
import { convert as convertCalibre, properties as propertiesCalibre } from "./calibre"; import { convert as convertCalibre, properties as propertiesCalibre } from "./calibre";
import { convert as convertDvisvgm, properties as propertiesDvisvgm } from "./dvisvgm"; import { convert as convertDvisvgm, properties as propertiesDvisvgm } from "./dvisvgm";
@ -111,6 +113,63 @@ const properties: Record<
}, },
}; };
function chunks<T>(arr: T[], size: number): T[][] {
if(size <= 0){
return [arr]
}
return Array.from({ length: Math.ceil(arr.length / size) }, (_: T, i: number) =>
arr.slice(i * size, i * size + size)
);
}
export async function handleConvert(
fileNames: string[],
userUploadsDir: string,
userOutputDir: string,
convertTo: string,
converterName: string,
jobId: any
) {
const query = db.query(
"INSERT INTO file_names (job_id, file_name, output_file_name, status) VALUES (?1, ?2, ?3, ?4)",
);
for (const chunk of chunks(fileNames, MAX_CONVERT_PROCESS)) {
const toProcess: Promise<string>[] = [];
for(const fileName of chunk) {
const filePath = `${userUploadsDir}${fileName}`;
const fileTypeOrig = fileName.split(".").pop() ?? "";
const fileType = normalizeFiletype(fileTypeOrig);
const newFileExt = normalizeOutputFiletype(convertTo);
const newFileName = fileName.replace(
new RegExp(`${fileTypeOrig}(?!.*${fileTypeOrig})`),
newFileExt,
);
const targetPath = `${userOutputDir}${newFileName}`;
toProcess.push(
new Promise((resolve, reject) => {
mainConverter(
filePath,
fileType,
convertTo,
targetPath,
{},
converterName,
).then(r => {
if (jobId.value) {
query.run(jobId.value, fileName, newFileName, r);
}
resolve(r);
}).catch(c => reject(c));
})
);
}
await Promise.all(toProcess);
}
}
export async function mainConverter( export async function mainConverter(
inputFilePath: string, inputFilePath: string,
fileTypeOriginal: string, fileTypeOriginal: string,

View file

@ -15,3 +15,5 @@ export const HIDE_HISTORY = process.env.HIDE_HISTORY?.toLowerCase() === "true" |
export const WEBROOT = process.env.WEBROOT ?? ""; export const WEBROOT = process.env.WEBROOT ?? "";
export const LANGUAGE = process.env.LANGUAGE?.toLowerCase() || "en"; export const LANGUAGE = process.env.LANGUAGE?.toLowerCase() || "en";
export const MAX_CONVERT_PROCESS = process.env.MAX_CONVERT_PROCESS && Number(process.env.MAX_CONVERT_PROCESS) > 0 ? Number(process.env.MAX_CONVERT_PROCESS) : 0

View file

@ -2,11 +2,11 @@ import { mkdir } from "node:fs/promises";
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import sanitize from "sanitize-filename"; import sanitize from "sanitize-filename";
import { outputDir, uploadsDir } from ".."; import { outputDir, uploadsDir } from "..";
import { mainConverter } from "../converters/main"; import { handleConvert } from "../converters/main";
import db from "../db/db"; import db from "../db/db";
import { Jobs } from "../db/types"; import { Jobs } from "../db/types";
import { WEBROOT } from "../helpers/env"; import { WEBROOT } from "../helpers/env";
import { normalizeFiletype, normalizeOutputFiletype } from "../helpers/normalizeFiletype"; import { normalizeFiletype } from "../helpers/normalizeFiletype";
import { userService } from "./user"; import { userService } from "./user";
export const convert = new Elysia().use(userService).post( export const convert = new Elysia().use(userService).post(
@ -61,36 +61,8 @@ export const convert = new Elysia().use(userService).post(
jobId.value, jobId.value,
); );
const query = db.query(
"INSERT INTO file_names (job_id, file_name, output_file_name, status) VALUES (?1, ?2, ?3, ?4)",
);
// Start the conversion process in the background // Start the conversion process in the background
Promise.all( handleConvert(fileNames, userUploadsDir, userOutputDir, convertTo, converterName, jobId)
fileNames.map(async (fileName) => {
const filePath = `${userUploadsDir}${fileName}`;
const fileTypeOrig = fileName.split(".").pop() ?? "";
const fileType = normalizeFiletype(fileTypeOrig);
const newFileExt = normalizeOutputFiletype(convertTo);
const newFileName = fileName.replace(
new RegExp(`${fileTypeOrig}(?!.*${fileTypeOrig})`),
newFileExt,
);
const targetPath = `${userOutputDir}${newFileName}`;
const result = await mainConverter(
filePath,
fileType,
convertTo,
targetPath,
{},
converterName,
);
if (jobId.value) {
query.run(jobId.value, fileName, newFileName, result);
}
}),
)
.then(() => { .then(() => {
// All conversions are done, update the job status to 'completed' // All conversions are done, update the job status to 'completed'
if (jobId.value) { if (jobId.value) {

View file

@ -4,6 +4,8 @@ import { outputDir } from "..";
import db from "../db/db"; import db from "../db/db";
import { WEBROOT } from "../helpers/env"; import { WEBROOT } from "../helpers/env";
import { userService } from "./user"; import { userService } from "./user";
import path from "node:path";
import * as tar from "tar";
export const download = new Elysia() export const download = new Elysia()
.use(userService) .use(userService)
@ -35,8 +37,7 @@ export const download = new Elysia()
return Bun.file(filePath); return Bun.file(filePath);
}, },
) )
.get("/zip/:userId/:jobId", async ({ params, jwt, redirect, cookie: { auth } }) => { .get("/archive/:userId/:jobId", async ({ params, jwt, redirect, cookie: { auth } }) => {
// TODO: Implement zip download
if (!auth?.value) { if (!auth?.value) {
return redirect(`${WEBROOT}/login`, 302); return redirect(`${WEBROOT}/login`, 302);
} }
@ -54,9 +55,11 @@ export const download = new Elysia()
return redirect(`${WEBROOT}/results`, 302); return redirect(`${WEBROOT}/results`, 302);
} }
// const userId = decodeURIComponent(params.userId); const userId = decodeURIComponent(params.userId);
// const jobId = decodeURIComponent(params.jobId); const jobId = decodeURIComponent(params.jobId);
// const outputPath = `${outputDir}${userId}/`{jobId}/); const outputPath = `${outputDir}${userId}/${jobId}`;
const outputTar = path.join(outputPath, `converted_files_${jobId}.tar`)
// return Bun.zip(outputPath); await tar.create({file: outputTar, cwd: outputPath, filter: (path) => { return !path.match(".*\\.tar"); }}, ["."]);
return Bun.file(outputTar);
}); });

View file

@ -6,12 +6,17 @@ import db from "../db/db";
import { Filename, Jobs } from "../db/types"; import { Filename, Jobs } from "../db/types";
import { ALLOW_UNAUTHENTICATED, WEBROOT } from "../helpers/env"; import { ALLOW_UNAUTHENTICATED, WEBROOT } from "../helpers/env";
import { userService } from "./user"; import { userService } from "./user";
import { JWTPayloadSpec } from "@elysiajs/jwt";
function ResultsArticle({ function ResultsArticle({
user,
job, job,
files, files,
outputPath, outputPath,
}: { }: {
user: {
id: string;
} & JWTPayloadSpec;
job: Jobs; job: Jobs;
files: Filename[]; files: Filename[];
outputPath: string; outputPath: string;
@ -21,14 +26,19 @@ function ResultsArticle({
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<h1 class="text-xl">Results</h1> <h1 class="text-xl">Results</h1>
<div> <div>
<a
style={files.length !== job.num_files ? "pointer-events: none;" : ""}
href={`${WEBROOT}/archive/${user.id}/${job.id}`}
download={`converted_files_${job.id}.tar`}
>
<button <button
type="button" type="button"
class="float-right w-40 btn-primary" class="float-right w-40 btn-primary"
onclick="downloadAll()"
{...(files.length !== job.num_files ? { disabled: true, "aria-busy": "true" } : "")} {...(files.length !== job.num_files ? { disabled: true, "aria-busy": "true" } : "")}
> >
{files.length === job.num_files ? "Download All" : "Converting..."} {files.length === job.num_files ? "Download All" : "Converting..."}
</button> </button>
</a>
</div> </div>
</div> </div>
<progress <progress
@ -170,7 +180,7 @@ export const results = new Elysia()
sm:px-4 sm:px-4
`} `}
> >
<ResultsArticle job={job} files={files} outputPath={outputPath} /> <ResultsArticle user={user} job={job} files={files} outputPath={outputPath} />
</main> </main>
<script src={`${WEBROOT}/results.js`} defer /> <script src={`${WEBROOT}/results.js`} defer />
</> </>
@ -211,5 +221,5 @@ export const results = new Elysia()
.as(Filename) .as(Filename)
.all(params.jobId); .all(params.jobId);
return <ResultsArticle job={job} files={files} outputPath={outputPath} />; return <ResultsArticle user={user} job={job} files={files} outputPath={outputPath} />;
}); });