1
0

add: a endpoint for getting song info

This commit is contained in:
alikia2x (寒寒) 2025-09-22 02:04:04 +08:00
parent ee29de726e
commit 93237075fe
8 changed files with 157 additions and 38 deletions

4
.gitignore vendored
View File

@ -43,4 +43,6 @@ docker-compose.yml
ucaptcha-config.yaml
temp/
temp/
meili

View File

@ -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

View File

@ -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<string>(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);
}

View File

@ -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: ""
}
}
);

View File

@ -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";

View File

@ -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;

View File

@ -23,26 +23,6 @@ export default function Home() {
<SearchIcon className="size-6" />
</Button>
</div>
<Button
className="w-full mt-4 h-10"
variant="secondary"
onClick={async () => {
try {
const { data } = await app.ping.get("");
if (data && data.message === "pong") {
toast("pong");
return;
}
toast("校验失败。");
} catch (e) {
console.error(e);
toast("发生错误。");
}
}}
>
Ping
</Button>
</main>
</div>
);

View File

@ -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");