Merge branch 'main' into chore/typescript-7

This commit is contained in:
Emrik Östling
2026-09-09 23:44:44 +02:00
committed by GitHub
11 changed files with 131 additions and 46 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Bun
uses: oven-sh/setup-bun@v2
+6 -6
View File
@@ -49,7 +49,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: downcase REPO
run: |
@@ -57,12 +57,12 @@ jobs:
- name: Docker meta default
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ env.REPO }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
with:
platforms: ${{ matrix.platform }}
@@ -76,7 +76,7 @@ jobs:
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
env:
DOCKER_BUILDKIT: 1
with:
@@ -130,14 +130,14 @@ jobs:
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
ghcr.io/${{ env.REPO }}
${{ env.IMAGE_NAME }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
dockerHubDescription:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Docker Hub Description
uses: peter-evans/dockerhub-description@v5
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Bun
uses: oven-sh/setup-bun@v2
+26 -3
View File
@@ -1,5 +1,22 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import {
execFile as execFileOriginal,
type ChildProcess,
type ExecFileOptions,
} from "node:child_process";
// ffmpeg streams continuous progress to stderr, so a long conversion overflows
// execFile's 1 MB default maxBuffer and fails with "stderr maxBuffer length
// exceeded" (issue #565). Raise it well above that. The options object must be
// passed before the callback: execFile ignores an options argument placed after
// the callback, which is why the shared ExecFileFn type cannot carry it.
const FFMPEG_MAX_BUFFER = 1024 * 1024 * 64; // 64 MB
type FfmpegExecFile = (
cmd: string,
args: string[],
options: ExecFileOptions,
callback: (err: Error | null, stdout: string, stderr: string) => void,
) => ChildProcess | void;
// This could be done dynamically by running `ffmpeg -formats` and parsing the output
export const properties = {
@@ -224,6 +241,7 @@ export const properties = {
"lxf",
"m15",
"m2a",
"m2ts",
"m4a",
"m4b",
"m4v",
@@ -406,6 +424,7 @@ export const properties = {
"tiff",
"tmv",
"truehd",
"ts",
"tta",
"tty",
"txd",
@@ -439,6 +458,7 @@ export const properties = {
"vivo",
"vmd",
"vobsub",
"vob",
"voc",
"vpk",
"vplayer",
@@ -457,6 +477,7 @@ export const properties = {
"webp",
"webvtt",
"wma",
"wmv",
"wow",
"wsaud",
"wsd",
@@ -589,6 +610,7 @@ export const properties = {
"m4v",
"mjpeg",
"mjpg",
"mka",
"mkv",
"mlp",
"mmf",
@@ -694,7 +716,7 @@ export async function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: FfmpegExecFile = execFileOriginal as FfmpegExecFile, // to make it mockable
): Promise<string> {
let extraArgs: string[] = [];
let message = "Done";
@@ -739,6 +761,7 @@ export async function convert(
execFile(
"ffmpeg",
[...ffmpegArgs, "-i", filePath, ...ffmpegOutputArgs, ...extraArgs, targetPath],
{ maxBuffer: FFMPEG_MAX_BUFFER },
(error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
+3 -1
View File
@@ -317,8 +317,10 @@ export function convert(
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
): Promise<string> {
// Apply EXIF orientation so photos (e.g. from phones) don't end up sideways
// when converted to formats where the orientation tag is lost or ignored
return new Promise((resolve, reject) => {
execFile("gm", ["convert", filePath, targetPath], (error, stdout, stderr) => {
execFile("gm", ["convert", filePath, "-auto-orient", targetPath], (error, stdout, stderr) => {
if (error) {
reject(`error: ${error}`);
}
+8 -2
View File
@@ -11,7 +11,7 @@ export const download = new Elysia()
.use(userService)
.get(
"/download/:userId/:jobId/:fileName",
async ({ params, redirect, user }) => {
async ({ params, redirect, set, user }) => {
const userId = user.id;
const job = await db
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
@@ -25,7 +25,13 @@ export const download = new Elysia()
const fileName = sanitize(decodeURIComponent(params.fileName));
const filePath = `${outputDir}${userId}/${jobId}/${fileName}`;
return Bun.file(filePath);
const file = Bun.file(filePath);
if (!(await file.exists())) {
set.status = 404;
return { message: "Converted file not found." };
}
return file;
},
{
auth: true,
+41 -29
View File
@@ -96,35 +96,47 @@ function ResultsArticle({
</tr>
</thead>
<tbody>
{files.map((file) => (
<tr>
<td safe class="max-w-[20vw] truncate">
{file.output_file_name}
</td>
<td safe>{file.status}</td>
<td class="flex flex-row gap-4">
<a
class={`
text-accent-500 underline
hover:text-accent-400
`}
href={buildDownloadUrl(WEBROOT, outputPath, file.output_file_name)}
>
<EyeIcon />
</a>
<a
class={`
text-accent-500 underline
hover:text-accent-400
`}
href={buildDownloadUrl(WEBROOT, outputPath, file.output_file_name)}
download={file.output_file_name}
>
<DownloadIcon />
</a>
</td>
</tr>
))}
{files.map((file) => {
const conversionFailed = ["Failed, check logs", "File type not supported"].includes(
file.status,
);
return (
<tr>
<td safe class="max-w-[20vw] truncate">
{file.output_file_name}
</td>
<td safe>{file.status}</td>
<td class="flex flex-row gap-4">
{conversionFailed ? (
<span class="text-neutral-500">Unavailable</span>
) : (
<>
<a
class={`
text-accent-500 underline
hover:text-accent-400
`}
href={buildDownloadUrl(WEBROOT, outputPath, file.output_file_name)}
>
<EyeIcon />
</a>
<a
class={`
text-accent-500 underline
hover:text-accent-400
`}
href={buildDownloadUrl(WEBROOT, outputPath, file.output_file_name)}
download={file.output_file_name}
>
<DownloadIcon />
</a>
</>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</article>
+1
View File
@@ -279,6 +279,7 @@ export const user = new Elysia()
class="rounded-sm bg-neutral-800 p-3"
placeholder="Email"
autocomplete="email"
autofocus
required
/>
</label>
+16
View File
@@ -1,14 +1,18 @@
import { beforeEach, expect, test } from "bun:test";
import { convert } from "../../src/converters/ffmpeg";
import type { ExecFileOptions } from "node:child_process";
let calls: string[][] = [];
let lastOptions: ExecFileOptions | undefined;
function mockExecFile(
_cmd: string,
args: string[],
options: ExecFileOptions,
callback: (err: Error | null, stdout: string, stderr: string) => void,
) {
calls.push(args);
lastOptions = options;
if (args.includes("fail.mov")) {
callback(new Error("mock failure"), "", "Fake stderr: fail");
} else {
@@ -18,6 +22,7 @@ function mockExecFile(
beforeEach(() => {
calls = [];
lastOptions = undefined;
delete process.env.FFMPEG_ARGS;
});
@@ -168,6 +173,7 @@ test("logs stderr when execFile returns only stderr and no error", async () => {
const mockExecFileStderrOnly = (
_cmd: string,
_args: string[],
_options: ExecFileOptions,
callback: (err: Error | null, stdout: string, stderr: string) => void,
) => {
callback(null, "", "Only stderr output");
@@ -179,3 +185,13 @@ test("logs stderr when execFile returns only stderr and no error", async () => {
expect(loggedMessage).toBe("stderr: Only stderr output");
});
test("passes a maxBuffer above the 1 MB default so long conversions don't overflow stderr (#565)", async () => {
await convert("in.mkv", "mkv", "h264.mp4", "out.mp4", undefined, mockExecFile);
// execFile's default maxBuffer is 1 MB; ffmpeg's progress output on a long
// encode exceeds it and the conversion fails with "stderr maxBuffer length
// exceeded". Lock in the raised buffer (must match FFMPEG_MAX_BUFFER in
// ffmpeg.ts) so a regression to a smaller-but-still-over-1-MB value is caught.
expect(lastOptions?.maxBuffer).toBe(1024 * 1024 * 64);
});
+27 -2
View File
@@ -1,7 +1,32 @@
import { test } from "bun:test";
import { beforeEach, expect, test } from "bun:test";
import type { ExecFileException } from "node:child_process";
import { convert } from "../../src/converters/graphicsmagick";
import { ExecFileFn } from "../../src/converters/types";
import { runCommonTests } from "./helpers/commonTests";
let calls: string[][] = [];
beforeEach(() => {
calls = [];
});
runCommonTests(convert);
test.skip("dummy - required to trigger test detection", () => {});
test("convert applies EXIF auto-orient", async () => {
let command = "";
const mockExecFile: ExecFileFn = (
cmd: string,
args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
command = cmd;
calls.push(args);
callback(null, "", "");
};
const result = await convert("input.jpg", "jpg", "pdf", "output.pdf", undefined, mockExecFile);
expect(result).toBe("Done");
expect(command).toBe("gm");
expect(calls[0]).toEqual(["convert", "input.jpg", "-auto-orient", "output.pdf"]);
});