import { Hono } from "hono"; import { cors } from "hono/cors"; import cache from "memory-cache"; import { join } from "path"; import fs from "fs"; import { Database } from "better-sqlite3"; import type { Frame } from "../backend/schema"; import { getDecodingTempDir, getRecordingsDir, getScreenshotsDir, waitForFileExists } from "../utils/index.js"; import { immediatelyExtractFrameFromVideo } from "../utils/index.js"; import { existsSync } from "fs"; const app = new Hono(); app.use("*", cors()); app.use(async (c, next) => { const key = cache.get("server:APIKey"); if (key && c.req.header("x-api-key") !== key) { c.res = undefined; c.res = c.json({ error: "Invalid API key" }, 401); } await next(); }); app.get("/ping", (c) => c.text("pong")); function getLatestFrames(db: Database, limit = 50): Frame[] { return db .prepare( ` SELECT id, createdAt, imgFilename, videoPath, videoFrameIndex FROM frame ORDER BY createdAt DESC LIMIT ? ` ) .all(limit) as Frame[]; } function getFramesUntilID(db: Database, untilID: number, limit = 50): Frame[] { return db .prepare( ` SELECT id, createdAt, imgFilename, videoPath, videoFrameIndex FROM frame WHERE id < ? ORDER BY createdAt DESC LIMIT ? ` ) .all(untilID, limit) as Frame[]; } app.get("/timeline", async (c) => { const query = c.req.query(); const limit = parseInt(query.limit) || undefined; const db = cache.get("server:dbConnection"); if (query.untilID) { return c.json(getFramesUntilID(db, parseInt(query.untilID), limit)); } else { return c.json(getLatestFrames(db, limit)); } }); app.get("/frame/:id", async (c) => { const { id } = c.req.param(); const db: Database = cache.get("server:dbConnection"); const frame = db .prepare( ` SELECT imgFilename, videoPath, videoFrameIndex, createdAt FROM frame WHERE id = ? ` ) .get(id) as Frame | undefined; if (!frame) return c.json({ error: "Frame not found" }, 404); const decodingTempDir = getDecodingTempDir(); const screenshotsDir = getScreenshotsDir(); const videoFilename = frame.videoPath; const frameIndex = frame.videoFrameIndex; const imageFilename = frame.imgFilename; const bareVideoFilename = videoFilename?.replace(".mp4", "") || null; const decodedImage = frameIndex ? `${bareVideoFilename}_${frameIndex.toString().padStart(4, "0")}.bmp` : null; let returnImagePath = ""; let needToBeDecoded = videoFilename !== null && frameIndex !== null && !frame.imgFilename; if (decodedImage && fs.existsSync(join(getDecodingTempDir(), decodedImage))) { needToBeDecoded = false; returnImagePath = join(decodingTempDir, decodedImage); } else if (imageFilename && fs.existsSync(join(screenshotsDir, imageFilename))) { returnImagePath = join(screenshotsDir, imageFilename); } if (needToBeDecoded) { const videoExists = fs.existsSync(join(getRecordingsDir(), videoFilename!)); if (!videoExists) { return c.json({ error: "Video not found" }, { status: 404 }); } const decodedFilename = immediatelyExtractFrameFromVideo( videoFilename!, frameIndex!, decodingTempDir ); const decodedPath = join(decodingTempDir, decodedFilename); await waitForFileExists(decodedPath); if (existsSync(decodedPath)) { const imageBuffer = fs.readFileSync(decodedPath); setTimeout(() => { fs.unlinkSync(decodedPath); }, 1000); return new Response(imageBuffer, { status: 200, headers: { "Content-Type": "image/bmp" } }); } else { return c.json({ error: "Frame cannot be decoded" }, { status: 500 }); } } else { const imageBuffer = fs.readFileSync(returnImagePath); const imageMimeType = imageFilename?.endsWith(".png") ? "image/png" : "image/jpeg"; return new Response(imageBuffer, { status: 200, headers: { "Content-Type": imageMimeType } }); } }); export default app;