diff --git a/bun.lock b/bun.lock index 2b1dbf0..320c900 100644 --- a/bun.lock +++ b/bun.lock @@ -5,13 +5,13 @@ "name": "cvsa", "dependencies": { "arg": "^5.0.2", - "postgres": "^3.4.5", + "postgres": "^3.4.7", }, "devDependencies": { - "@types/bun": "^1.2.15", - "prettier": "^3.5.3", + "@types/bun": "^1.2.22", + "prettier": "^3.6.2", "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.1.2", + "vitest": "^3.2.4", "vitest-tsconfig-paths": "^3.4.1", }, }, @@ -78,6 +78,7 @@ "@elysiajs/openapi": "^1.4.0", "chalk": "^5.6.2", "elysia": "^1.4.0", + "zod": "^4.1.11", }, "devDependencies": { "bun-types": "latest", @@ -2478,6 +2479,8 @@ "dot-prop/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "elysia-api/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], + "execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], diff --git a/packages/elysia/package.json b/packages/elysia/package.json index de88951..94c669b 100644 --- a/packages/elysia/package.json +++ b/packages/elysia/package.json @@ -10,7 +10,8 @@ "@elysiajs/cors": "^1.4.0", "@elysiajs/openapi": "^1.4.0", "chalk": "^5.6.2", - "elysia": "^1.4.0" + "elysia": "^1.4.0", + "zod": "^4.1.11" }, "devDependencies": { "bun-types": "latest", diff --git a/packages/elysia/routes/video/metadata.ts b/packages/elysia/routes/video/metadata.ts new file mode 100644 index 0000000..473ea51 --- /dev/null +++ b/packages/elysia/routes/video/metadata.ts @@ -0,0 +1,91 @@ +import { Elysia, t } from "elysia"; +import { dbMain } from "@core/drizzle"; +import { videoSnapshot } from "@core/drizzle/main/schema"; +import { bv2av } from "@elysia/lib/av_bv"; +import { getVideoInfo } from "@core/net/getVideoInfo"; +import { redis } from "@core/db/redis"; +import { ErrorResponseSchema } from "@elysia/src/schema"; +import type { VideoInfoData } from "@core/net/bilibili.d.ts"; + +async function retrieveFromCache(aid: number) { + const cacheKey = `cvsa:videoInfo:av${aid}`; + const cachedData = await redis.get(cacheKey); + if (cachedData) { + return JSON.parse(cachedData); + } + return null; +} + +async function setCache(aid: number, data: string) { + const cacheKey = `cvsa:videoInfo:av${aid}`; + return await redis.setex(cacheKey, 60, data); +} + +async function insertVideoSnapshot(data: VideoInfoData) { + const views = data.stat.view; + const danmakus = data.stat.danmaku; + const replies = data.stat.reply; + const likes = data.stat.like; + const coins = data.stat.coin; + const shares = data.stat.share; + const favorites = data.stat.favorite; + const aid = data.aid; + + await dbMain.insert(videoSnapshot).values({ + aid, + views, + danmakus, + replies, + likes, + coins, + shares, + favorites + }); +} + +export const getVideoMetadataHandler = new Elysia({ prefix: "/video" }).get( + "/:id/info", + async (c) => { + const id = c.params.id; + let aid: number | null = null; + + if (id.startsWith("BV1")) { + aid = bv2av(id as `BV1${string}`); + } else if (id.startsWith("av")) { + aid = Number.parseInt(id.slice(2)); + } else { + return c.status(400, { + code: "MALFORMED_SLOT", + message: "We cannot parse the video ID, or we currently do not support this format.", + errors: [] + }); + } + + const cachedData = await retrieveFromCache(aid); + if (cachedData) { + return cachedData; + } + + const data = await getVideoInfo(aid, "getVideoInfo"); + + if (typeof data == "number") { + return c.status(500, { + code: "THIRD_PARTY_ERROR", + message: `Got status code ${data} from bilibili API.`, + errors: [] + }); + } + + await setCache(aid, JSON.stringify(data)); + await insertVideoSnapshot(data); + + return data; + }, + { + response: { + 200: t.Any(), + 400: ErrorResponseSchema, + 500: ErrorResponseSchema + } + } +); diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 9a5b749..30feae7 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -5,6 +5,7 @@ import openapi from "@elysiajs/openapi"; import { cors } from "@elysiajs/cors"; import { getSongInfoHandler } from "@elysia/routes/song/info"; import { rootHandler } from "@elysia/routes/root"; +import { getVideoMetadataHandler } from "@elysia/routes/video/metadata"; const [host, port] = getBindingInfo(); logStartup(host, port); @@ -20,6 +21,7 @@ const app = new Elysia({ .use(openapi()) .use(rootHandler) .use(pingHandler) + .use(getVideoMetadataHandler) .use(getSongInfoHandler) .listen(15412); diff --git a/packages/elysia/src/schema.ts b/packages/elysia/src/schema.ts new file mode 100644 index 0000000..0e587ad --- /dev/null +++ b/packages/elysia/src/schema.ts @@ -0,0 +1,39 @@ +import { t } from "elysia"; + +export const errorCodes = [ + "INVALID_QUERY_PARAMS", + "UNKNOWN_ERROR", + "INVALID_PAYLOAD", + "MALFORMED_SLOT", + "INVALID_HEADER", + "BODY_TOO_LARGE", + "UNAUTHORIZED", + "INVALID_CREDENTIALS", + "ENTITY_NOT_FOUND", + "SERVER_ERROR", + "RATE_LIMIT_EXCEEDED", + "ENTITY_EXISTS", + "THIRD_PARTY_ERROR" +]; + +function generateErrorCodeRegex(strings: string[]): string { + if (strings.length === 0) { + return "(?!)"; + } + + const escapedStrings = strings.map((str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + + return `^(${escapedStrings.join("|")})$`; +} + +export const ErrorResponseSchema = t.Object({ + code: t.String({ pattern: generateErrorCodeRegex(errorCodes) }), + message: t.String(), + errors: t.Array(t.String()), + i18n: t.Optional( + t.Object({ + key: t.String(), + values: t.Optional(t.Record(t.String(), t.Union([t.String(), t.Number(), t.Date()]))) + }) + ) +});