ionian/packages/server/src/route/comic.ts
2024-04-06 08:19:56 +09:00

132 lines
3.4 KiB
TypeScript

import type { Context } from "koa";
import Router from "koa-router";
import { createReadableStreamFromZip, entriesByNaturalOrder, readZip } from "../util/zipwrap";
import type { ContentContext } from "./context";
import { since_last_modified } from "./util";
import type { ZipReader } from "@zip.js/zip.js";
import type { FileHandle } from "node:fs/promises";
import { Readable } from "node:stream";
/**
* zip stream cache.
*/
const ZipStreamCache = new Map<string, {
reader: ZipReader<FileHandle>,
handle: FileHandle,
refCount: number,
}>();
function markUseZip(path: string) {
const ret = ZipStreamCache.get(path);
if (ret) {
ret.refCount++;
}
return ret !== undefined;
}
async function acquireZip(path: string, marked = false) {
const ret = ZipStreamCache.get(path);
if (!ret) {
const obj = await readZip(path);
const check = ZipStreamCache.get(path);
if (check) {
check.refCount++;
// if the cache is updated, release the previous one.
releaseZip(path);
return check.reader;
}
// if the cache is not updated, set the new one.
ZipStreamCache.set(path, {
reader: obj.reader,
handle: obj.handle,
refCount: 1,
});
return obj.reader;
}
if (!marked) {
ret.refCount++;
}
return ret.reader;
}
function releaseZip(path: string) {
const obj = ZipStreamCache.get(path);
if (obj === undefined) {
console.warn(`warning! duplicate release at ${path}`);
return;
}
if (obj.refCount === 1) {
const { reader, handle } = obj;
reader.close().then(() => {
handle.close();
});
ZipStreamCache.delete(path);
} else {
obj.refCount--;
}
}
async function renderZipImage(ctx: Context, path: string, page: number) {
const image_ext = ["gif", "png", "jpeg", "bmp", "webp", "jpg"];
const marked = markUseZip(path);
const zip = await acquireZip(path, marked);
const entries = (await entriesByNaturalOrder(zip)).filter((x) => {
const ext = x.filename.split(".").pop();
return ext !== undefined && image_ext.includes(ext);
});
if (0 <= page && page < entries.length) {
const entry = entries[page];
const last_modified = entry.lastModDate;
if (since_last_modified(ctx, last_modified)) {
return;
}
const read_stream = await createReadableStreamFromZip(zip, entry);
const nodeReadableStream = new Readable();
nodeReadableStream._read = () => { };
read_stream.pipeTo(new WritableStream({
write(chunk) {
nodeReadableStream.push(chunk);
},
close() {
nodeReadableStream.push(null);
},
}));
nodeReadableStream.on("error", (err) => {
console.error(err);
releaseZip(path);
});
nodeReadableStream.on("close", () => {
releaseZip(path);
});
ctx.body = nodeReadableStream;
ctx.response.length = entry.uncompressedSize;
// console.log(`${entry.name}'s ${page}:${entry.size}`);
ctx.response.type = entry.filename.split(".").pop() as string;
ctx.status = 200;
ctx.set("Date", new Date().toUTCString());
ctx.set("Last-Modified", last_modified.toUTCString());
} else {
ctx.status = 404;
}
}
export class ComicRouter extends Router<ContentContext> {
constructor() {
super();
this.get("/", async (ctx, next) => {
await renderZipImage(ctx, ctx.state.location.path, 0);
});
this.get("/:page(\\d+)", async (ctx, next) => {
const page = Number.parseInt(ctx.params.page);
await renderZipImage(ctx, ctx.state.location.path, page);
});
this.get("/thumbnail", async (ctx, next) => {
await renderZipImage(ctx, ctx.state.location.path, 0);
});
}
}
export default ComicRouter;