Files
gerbeur/api/routes/thumbnails.ts
khannurien d62e4a9883
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
Build and deploy docs / build-and-push (push) Successful in 26s
v3: the backend is documented for deno doc, one way in every file, and the docs build and publish themselves — comments were in three shapes: file headers as // lines, as an untagged /** */ (which deno doc drops, only an @module block counts) or missing; 43 exports described by a // deno doc can't see, two JSDoc blocks sitting on the wrong declaration and one duplicated; endpoints commented in 5 of 18 route files, three ways, on router.get() calls no doc tool reads. The rule, applied to every non-test module of api/, migrations included: each opens with a /** … @module */ block; every export has JSDoc, and so does every field of an exported interface; a comment describing a declaration is /** */, // only notes code; functions open with a verb, everything else with a noun phrase; parameters are named in the prose, {@link} only for symbols in scope; whatever throws to its caller says so with @throws (for an APIException, the statuses and when); a router's JSDoc lists its endpoints, method and path, who may call it, what it does. deno doc --lint goes from 528 errors to none in api/ (shared/ keeps its 385; it is next). The lint also wanted explicit types on config.ts's settings and a few return types, JsonLdResult exported, and the routers typed Router. deno task doc builds the HTML reference into docs/ from every module of api/ and shared/ but the tests, deno task doc:lint lints it; the index page is the module doc of a root mod.ts (deno.json's exports), which introduces the backend, shared/ and the frontend to come and links every module, and mod.test.ts fails when one is missing. The docs workflow builds it on every push to main that touches them and publishes it to the gh-pages branch, like publish-assistant's. Two defects found along the way are fixed: a private playlist's cover was served to anyone who had its id or slug, and now needs its owner's media token, as private dump files do (playlistImageUrl adds it for private playlists only, so public cover URLs stay the same for everyone); og.ts and static.ts each filled index.html's placeholders, and now share lib/index-html.ts, so the OG middleware picks up a rebuilt index.html instead of keeping the first it read. The README states the documentation rule. 205 backend and 628 frontend tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:07:40 +00:00

261 lines
7.9 KiB
TypeScript

/**
* The route serving dumps' thumbnails, generated with ffmpeg where needed.
*
* @module
*/
import { Router } from "@oak/oak";
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import {
dumpFileVersion,
getDump,
getDumpThumbnailInfo,
} from "../services/dump-service.ts";
import { serveFile } from "../lib/serve-file.ts";
import { serveUploadedFile } from "../lib/upload.ts";
import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
import { authenticateMedia } from "../lib/auth.ts";
/**
* The route serving dumps' thumbnails:
*
* - `GET /api/thumbnails/:dumpId?token=&v=` — public; a private dump's
* thumbnail needs its poster's media `token`. Serves the dump's custom
* thumbnail, else one made from its upload: a still of a video, or a
* scaled-down copy of an image (the image itself when none can be made); 404
* when there is neither.
*/
const router: Router = new Router({ prefix: "/api/thumbnails" });
/**
* Whether ffmpeg runs, probed once per process: spawning `ffmpeg -version` on
* every uncached thumbnail request cost a process per card.
*/
let ffmpegProbe: Promise<boolean> | undefined;
/** Checks whether ffmpeg is available. */
function ffmpegAvailable(): Promise<boolean> {
ffmpegProbe ??= (async () => {
try {
const { success } = await new Deno.Command("ffmpeg", {
args: ["-version"],
stderr: "null",
stdout: "null",
}).output();
return success;
} catch {
return false;
}
})();
return ffmpegProbe;
}
/**
* The container formats ffmpeg may read a video as. The upload's type is only
* what the uploader claimed, and ffmpeg probes the real format from the bytes —
* a "video" could be an HLS or concat playlist pointing ffmpeg at local files
* or internal URLs. Read nothing but the file itself, and only as the kind of
* container it's meant to be.
*/
const VIDEO_FORMATS =
"mov,mp4,m4a,3gp,3g2,mj2,matroska,webm,avi,flv,mpegts,mpeg,ogg,asf,h264,hevc";
/** The formats ffmpeg may read an image as (see {@link VIDEO_FORMATS}). */
const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe";
/**
* The image types the feed shows through a scaled-down preview, and the
* preview's format. GIFs keep their animation (served as they are); anything
* else has no preview to make.
*/
const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = {
"image/jpeg": "jpg",
"image/bmp": "jpg",
// May carry transparency, which JPEG would fill with black.
"image/png": "png",
"image/webp": "png",
};
/**
* The width of an image preview: comfortably covers the widest feed card on a
* high-density screen, a fraction of a phone photo's bytes.
*/
const PREVIEW_WIDTH = 960;
/**
* Runs ffmpeg with `args` into `outPath`.
*
* @throws {Error} When ffmpeg fails.
*/
async function runFfmpeg(args: string[], outPath: string): Promise<void> {
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
// Written beside and renamed over, so a concurrent reader never gets half.
const tmpPath = `${outPath}.${crypto.randomUUID()}.tmp`;
const { success } = await new Deno.Command("ffmpeg", {
args: ["-protocol_whitelist", "file", ...args, "-y", tmpPath],
stdout: "null",
stderr: "null",
}).output();
if (!success) {
await Deno.remove(tmpPath).catch(() => {});
throw new Error("ffmpeg failed");
}
await Deno.rename(tmpPath, outPath);
}
/** Writes a 320-pixel-wide JPEG still of a video, one second in. */
function videoStill(srcPath: string, outPath: string): Promise<void> {
return runFfmpeg([
"-format_whitelist",
VIDEO_FORMATS,
"-ss",
"00:00:01",
"-i",
srcPath,
"-frames:v",
"1",
"-vf",
"scale=320:-1",
"-f",
"image2",
"-c:v",
"mjpeg",
], outPath);
}
/** Writes a copy of an image scaled down to {@link PREVIEW_WIDTH}. */
function imagePreview(srcPath: string, outPath: string): Promise<void> {
return runFfmpeg([
"-format_whitelist",
IMAGE_FORMATS,
"-i",
srcPath,
"-frames:v",
"1",
"-vf",
`scale='min(${PREVIEW_WIDTH},iw)':-2`,
"-f",
"image2",
...(outPath.endsWith(".png")
? ["-c:v", "png"]
: ["-c:v", "mjpeg", "-q:v", "4"]),
], outPath);
}
/**
* The generations under way, by output path: one ffmpeg run per output, so a
* feed full of cards asking for the same new still at once waits on a single
* generation.
*/
const inFlight = new Map<string, Promise<void>>();
/**
* Makes `outPath` with `generate` unless it exists; false when it can't be
* made (no ffmpeg, or ffmpeg failed).
*/
async function ensureGenerated(
outPath: string,
generate: () => Promise<void>,
): Promise<boolean> {
if (await Deno.stat(outPath).then(() => true, () => false)) return true;
if (!await ffmpegAvailable()) return false;
let pending = inFlight.get(outPath);
if (!pending) {
pending = generate().finally(() => inFlight.delete(outPath));
inFlight.set(outPath, pending);
}
return await pending.then(() => true, () => false);
}
router.get("/:dumpId", async (ctx) => {
const { dumpId } = ctx.params;
if (!/^[0-9a-f-]{36}$/.test(dumpId)) {
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID");
}
// Token via query param (media elements can't send headers) — authorizes the
// owner's private dumps and gates the custom-thumbnail branch below too.
const token = ctx.request.url.searchParams.get("token");
const viewerId = token ? await authenticateMedia(token) : null;
const dump = getDump(dumpId, viewerId ?? undefined);
// The URL may carry a media token; keep it out of anyone else's logs.
ctx.response.headers.set("Referrer-Policy", "no-referrer");
// A private dump's thumbnail must not sit in a shared cache. The app's URLs
// carry the file's version (see dumpFileVersion): what's generated from the
// file is cached for good under the current one, and revalidated otherwise.
const scope = dump.isPrivate ? "private" : "public";
const generatedCache =
ctx.request.url.searchParams.get("v") === dumpFileVersion(dump)
? `${scope}, max-age=31536000, immutable`
: `${scope}, no-cache`;
const customThumb = getDumpThumbnailInfo(dumpId);
if (customThumb) {
// Replaceable without the URL changing: always revalidated (a 304 when
// unchanged).
const served = await serveUploadedFile(
ctx,
`${THUMBNAILS_DIR}/${customThumb.id}-custom`,
customThumb.mime,
0,
dump.isPrivate,
);
if (served) return;
}
const mime = dump.kind === "file" ? dump.fileMime ?? "" : "";
const srcPath = `${DUMPS_DIR}/${dump.id}`;
if (mime.startsWith("video/")) {
const thumbPath = `${THUMBNAILS_DIR}/${dump.id}.jpg`;
if (
!await ensureGenerated(thumbPath, () => videoStill(srcPath, thumbPath))
) {
throw new APIException(
APIErrorCode.NOT_FOUND,
404,
"Could not generate thumbnail",
);
}
if (
await serveFile(ctx, thumbPath, {
mime: "image/jpeg",
cacheControl: generatedCache,
})
) return;
}
if (mime.startsWith("image/")) {
// A scaled-down copy for the feed; the original when there's none to make
// (a GIF, no ffmpeg, an image ffmpeg can't read).
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
const ext = PREVIEWABLE_IMAGES[mime];
if (ext) {
const previewPath = `${THUMBNAILS_DIR}/${dump.id}.preview.${ext}`;
if (
await ensureGenerated(
previewPath,
() => imagePreview(srcPath, previewPath),
) &&
await serveFile(ctx, previewPath, {
mime: ext === "png" ? "image/png" : "image/jpeg",
cacheControl: generatedCache,
})
) return;
}
if (
/^image\/(png|jpeg|gif|webp|avif|bmp)$/.test(mime) &&
await serveFile(ctx, srcPath, { mime, cacheControl: generatedCache })
) return;
}
throw new APIException(
APIErrorCode.NOT_FOUND,
404,
"No thumbnail for this dump",
);
});
export default router;