diff --git a/.gitignore b/.gitignore index 5dd5b52..3719e98 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,6 @@ docker-compose.yml ucaptcha-config.yaml -temp/ \ No newline at end of file +temp/ + +meili \ No newline at end of file diff --git a/packages/core/drizzle/main/schema.ts b/packages/core/drizzle/main/schema.ts index bd05189..d0c7fce 100644 --- a/packages/core/drizzle/main/schema.ts +++ b/packages/core/drizzle/main/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, uniqueIndex, index, integer, bigint, varchar, text, timestamp, unique, serial, smallint, boolean, bigserial, uuid, pgSequence } from "drizzle-orm/pg-core" +import { pgTable, uniqueIndex, index, integer, bigint, varchar, text, timestamp, smallint, boolean, unique, serial, bigserial, uuid, pgSequence } from "drizzle-orm/pg-core" import { sql } from "drizzle-orm" @@ -32,18 +32,6 @@ export const bilibiliMetadata = pgTable("bilibili_metadata", { uniqueIndex("unq_all-data_aid").using("btree", table.aid.asc().nullsLast().op("int8_ops")), ]); -export const bilibiliUser = pgTable("bilibili_user", { - id: serial().primaryKey().notNull(), - // You can use { mode: "bigint" } if numbers are exceeding js number limitations - uid: bigint({ mode: "number" }).notNull(), - username: text().notNull(), - desc: text().notNull(), - fans: integer().notNull(), -}, (table) => [ - index("idx_bili-user_uid").using("btree", table.uid.asc().nullsLast().op("int8_ops")), - unique("unq_bili-user_uid").on(table.uid), -]); - export const labellingResult = pgTable("labelling_result", { id: integer().default(sql`nextval('labeling_result_id_seq'::regclass)`).notNull(), // You can use { mode: "bigint" } if numbers are exceeding js number limitations @@ -104,13 +92,13 @@ export const songs = pgTable("songs", { publishedAt: timestamp("published_at", { withTimezone: true, mode: 'string' }), duration: integer(), type: smallint(), - romanizedName: text("romanized_name"), // You can use { mode: "bigint" } if numbers are exceeding js number limitations neteaseId: bigint("netease_id", { mode: "number" }), createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).default(sql`CURRENT_TIMESTAMP`).notNull(), updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).default(sql`CURRENT_TIMESTAMP`).notNull(), deleted: boolean().default(false).notNull(), image: text(), + producer: text(), }, (table) => [ index("idx_aid").using("btree", table.aid.asc().nullsLast().op("int8_ops")), index("idx_hash_songs_aid").using("hash", table.aid.asc().nullsLast().op("int8_ops")), @@ -122,6 +110,20 @@ export const songs = pgTable("songs", { uniqueIndex("unq_songs_netease_id").using("btree", table.neteaseId.asc().nullsLast().op("int8_ops")), ]); +export const bilibiliUser = pgTable("bilibili_user", { + id: serial().primaryKey().notNull(), + // You can use { mode: "bigint" } if numbers are exceeding js number limitations + uid: bigint({ mode: "number" }).notNull(), + username: text().notNull(), + desc: text().notNull(), + fans: integer().notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).default(sql`CURRENT_TIMESTAMP`).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).default(sql`CURRENT_TIMESTAMP`).notNull(), +}, (table) => [ + index("idx_bili-user_uid").using("btree", table.uid.asc().nullsLast().op("int8_ops")), + unique("unq_bili-user_uid").on(table.uid), +]); + export const singer = pgTable("singer", { id: serial().primaryKey().notNull(), name: text().notNull(), @@ -136,12 +138,19 @@ export const relations = pgTable("relations", { targetId: bigint("target_id", { mode: "number" }).notNull(), targetType: text("target_type").notNull(), relation: text().notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).default(sql`CURRENT_TIMESTAMP`).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).default(sql`CURRENT_TIMESTAMP`).notNull(), }, (table) => [ index("idx_relations_source_id_source_type_relation").using("btree", table.sourceId.asc().nullsLast().op("int8_ops"), table.sourceType.asc().nullsLast().op("int8_ops"), table.relation.asc().nullsLast().op("text_ops")), index("idx_relations_target_id_target_type_relation").using("btree", table.targetId.asc().nullsLast().op("text_ops"), table.targetType.asc().nullsLast().op("text_ops"), table.relation.asc().nullsLast().op("text_ops")), unique("unq_relations").on(table.sourceId, table.sourceType, table.targetId, table.targetType, table.relation), ]); +export const globalKv = pgTable("global_kv", { + key: text().primaryKey().notNull(), + value: text().notNull(), +}); + export const snapshotSchedule = pgTable("snapshot_schedule", { id: bigserial({ mode: "bigint" }).notNull(), // You can use { mode: "bigint" } if numbers are exceeding js number limitations diff --git a/packages/elysia/lib/av_bv.ts b/packages/elysia/lib/av_bv.ts new file mode 100644 index 0000000..746bdae --- /dev/null +++ b/packages/elysia/lib/av_bv.ts @@ -0,0 +1,29 @@ +const XOR_CODE = 23442827791579n; +const MASK_CODE = 2251799813685247n; +const MAX_AID = 1n << 51n; +const BASE = 58n; + +const data = "FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf"; + +export function av2bv(aid: number) { + const bytes = ["B", "V", "1", "0", "0", "0", "0", "0", "0", "0", "0", "0"]; + let bvIndex = bytes.length - 1; + let tmp = (MAX_AID | BigInt(aid)) ^ XOR_CODE; + while (tmp > 0) { + bytes[bvIndex] = data[Number(tmp % BigInt(BASE))]; + tmp = tmp / BASE; + bvIndex -= 1; + } + [bytes[3], bytes[9]] = [bytes[9], bytes[3]]; + [bytes[4], bytes[7]] = [bytes[7], bytes[4]]; + return bytes.join("") as `BV1${string}`; +} + +export function bv2av(bvid: `BV1${string}`) { + const bvidArr = Array.from(bvid); + [bvidArr[3], bvidArr[9]] = [bvidArr[9], bvidArr[3]]; + [bvidArr[4], bvidArr[7]] = [bvidArr[7], bvidArr[4]]; + bvidArr.splice(0, 3); + const tmp = bvidArr.reduce((pre, bvidChar) => pre * BASE + BigInt(data.indexOf(bvidChar)), 0n); + return Number((tmp & MASK_CODE) ^ XOR_CODE); +} \ No newline at end of file diff --git a/packages/elysia/routes/song/info.ts b/packages/elysia/routes/song/info.ts new file mode 100644 index 0000000..4d539e4 --- /dev/null +++ b/packages/elysia/routes/song/info.ts @@ -0,0 +1,92 @@ +import { Elysia, t } from "elysia"; +import { dbMain } from "@core/drizzle"; +import { relations, singer, songs } from "@core/drizzle/main/schema"; +import { eq, and } from "drizzle-orm"; +import { bv2av } from "@elysia/lib/av_bv"; + +async function getSongIDFromBiliID(id: string) { + let aid: number; + if (id.startsWith("BV1")) { + aid = bv2av(id as `BV1${string}`); + } else { + aid = Number.parseInt(id); + } + const songID = await dbMain.select({ id: songs.id }).from(songs).where(eq(songs.aid, aid)).limit(1); + if (songID.length > 0) { + return songID[0].id; + } + return null; +} + +async function getSongID(id: string) { + let songID: number | null = null; + if (id.startsWith("BV1") || id.startsWith("av")) { + const r = await getSongIDFromBiliID(id); + r && (songID = r); + } + if (!songID) { + songID = Number.parseInt(id); + } + return songID; +} + +async function getSongInfo(id: number) { + const songInfo = await dbMain.select().from(songs).where(eq(songs.id, id)).limit(1); + return songInfo[0]; +} + +async function getSingers(id: number) { + const singers = await dbMain + .select({ + singers: singer.name + }) + .from(relations) + .innerJoin(singer, eq(relations.targetId, singer.id)) + .where(and(eq(relations.sourceId, id), eq(relations.sourceType, "song"), eq(relations.relation, "sing"))); + return singers.map((singer) => singer.singers); +} + +export const getSongInfoHandler = new Elysia({ prefix: "/song" }).get( + "/:id/info", + async (c) => { + const id = c.params.id; + const songID = await getSongID(id); + if (!songID) { + return c.status(404, { + message: "song not found" + }); + } + const info = await getSongInfo(songID); + if (!info) { + return c.status(404, { + message: "song not found" + }); + } + const singers = await getSingers(info.id); + return { + name: info.name, + aid: info.aid, + producer: info.producer, + duration: info.duration, + singers: singers + }; + }, + { + response: { + 200: t.Object({ + name: t.Union([t.String(), t.Null()]), + aid: t.Union([t.Number(), t.Null()]), + producer: t.Union([t.String(), t.Null()]), + duration: t.Union([t.Number(), t.Null()]), + singers: t.Array(t.String()) + }), + 404: t.Object({ + message: t.String() + }) + }, + detail: { + summary: "Get information of a song", + description: "" + } + } +); diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 9951c0f..d477c63 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -3,6 +3,7 @@ import { getBindingInfo, logStartup } from "./startMessage"; import { pingHandler } from "@elysia/routes/ping"; import openapi from "@elysiajs/openapi"; import { cors } from "@elysiajs/cors"; +import { getSongInfoHandler } from "@elysia/routes/song/info"; const [host, port] = getBindingInfo(); logStartup(host, port); @@ -15,6 +16,7 @@ const app = new Elysia({ .use(cors()) .use(openapi()) .use(pingHandler) + .use(getSongInfoHandler) .listen(15412); export const VERESION = "0.7.0"; diff --git a/packages/temp_frontend/app/routes.ts b/packages/temp_frontend/app/routes.ts index 610c688..da9a953 100644 --- a/packages/temp_frontend/app/routes.ts +++ b/packages/temp_frontend/app/routes.ts @@ -1,3 +1,3 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes"; -export default [index("routes/home.tsx"), route("song/[id]/info", "routes/song/[id]/info.tsx")] satisfies RouteConfig; +export default [index("routes/home.tsx"), route("song/:id/info", "routes/song/[id]/info.tsx")] satisfies RouteConfig; diff --git a/packages/temp_frontend/app/routes/home.tsx b/packages/temp_frontend/app/routes/home.tsx index 72adbb2..5de738f 100644 --- a/packages/temp_frontend/app/routes/home.tsx +++ b/packages/temp_frontend/app/routes/home.tsx @@ -23,26 +23,6 @@ export default function Home() { - - ); diff --git a/packages/temp_frontend/app/routes/song/[id]/info.tsx b/packages/temp_frontend/app/routes/song/[id]/info.tsx index 0d30de1..a3d04ea 100644 --- a/packages/temp_frontend/app/routes/song/[id]/info.tsx +++ b/packages/temp_frontend/app/routes/song/[id]/info.tsx @@ -1,9 +1,14 @@ import useSWR from "swr"; +import type { Route } from "./+types/info"; const API_URL = "https://api.projectcvsa.com"; -export default function SongInfo() { - const { data, error, isLoading } = useSWR(`${API_URL}/song/[id]/info`, async (url) => { +export async function clientLoader({ params }: Route.LoaderArgs) { + return { id: params.id }; +} + +export default function SongInfo({ loaderData }: Route.ComponentProps) { + const { data, error, isLoading } = useSWR(`${API_URL}/video/${loaderData.id}/info`, async (url) => { const response = await fetch(url); if (!response.ok) { throw new Error("Failed to fetch song info");