From ce548951d80d426231ecec6e8d216b3a51d01e31 Mon Sep 17 00:00:00 2001 From: alikia2x Date: Sat, 8 Nov 2025 17:42:03 +0800 Subject: [PATCH] add: search endpoint in backend --- packages/core/drizzle/index.ts | 1 + packages/core/drizzle/main/relations.ts | 28 ++- packages/core/drizzle/main/schema.ts | 53 +++++- .../{outerSchema.d.ts => outerSchema.ts} | 6 +- packages/elysia/routes/search/index.ts | 162 ++++++++++++++++++ packages/elysia/src/index.ts | 2 + 6 files changed, 241 insertions(+), 11 deletions(-) rename packages/core/drizzle/{outerSchema.d.ts => outerSchema.ts} (61%) create mode 100644 packages/elysia/routes/search/index.ts diff --git a/packages/core/drizzle/index.ts b/packages/core/drizzle/index.ts index d5370c4..453212f 100644 --- a/packages/core/drizzle/index.ts +++ b/packages/core/drizzle/index.ts @@ -5,3 +5,4 @@ import { sqlCred, sql } from "@core/db/dbNew"; export const dbMain = drizzle(sql); export const dbCred = drizzle(sqlCred); +export const db = drizzle(sql); \ No newline at end of file diff --git a/packages/core/drizzle/main/relations.ts b/packages/core/drizzle/main/relations.ts index 80768e2..f7a9112 100644 --- a/packages/core/drizzle/main/relations.ts +++ b/packages/core/drizzle/main/relations.ts @@ -1,3 +1,29 @@ import { relations } from "drizzle-orm/relations"; -import { } from "./schema"; +import { songs, relationSinger, singer, relationsProducer } from "./schema"; +export const relationSingerRelations = relations(relationSinger, ({one}) => ({ + song: one(songs, { + fields: [relationSinger.songId], + references: [songs.id] + }), + singer: one(singer, { + fields: [relationSinger.singerId], + references: [singer.id] + }), +})); + +export const songsRelations = relations(songs, ({many}) => ({ + relationSingers: many(relationSinger), + relationsProducers: many(relationsProducer), +})); + +export const singerRelations = relations(singer, ({many}) => ({ + relationSingers: many(relationSinger), +})); + +export const relationsProducerRelations = relations(relationsProducer, ({one}) => ({ + song: one(songs, { + fields: [relationsProducer.songId], + references: [songs.id] + }), +})); \ No newline at end of file diff --git a/packages/core/drizzle/main/schema.ts b/packages/core/drizzle/main/schema.ts index d110933..189e8fb 100644 --- a/packages/core/drizzle/main/schema.ts +++ b/packages/core/drizzle/main/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, index, unique, serial, bigint, text, timestamp, uniqueIndex, integer, varchar, smallint, jsonb, boolean, bigserial, real, pgSchema, inet, pgSequence } from "drizzle-orm/pg-core" +import { pgTable, index, unique, serial, bigint, text, timestamp, uniqueIndex, integer, varchar, smallint, jsonb, boolean, foreignKey, bigserial, real, pgSchema, inet, pgSequence } from "drizzle-orm/pg-core" import { sql } from "drizzle-orm" export const credentials = pgSchema("credentials"); @@ -9,6 +9,8 @@ export const labelingResultIdSeq = pgSequence("labeling_result_id_seq", { start export const songsIdSeq = pgSequence("songs_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "2147483647", cache: "1", cycle: false }) export const videoSnapshotIdSeq = pgSequence("video_snapshot_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "2147483647", cache: "1", cycle: false }) export const viewsIncrementRateIdSeq = pgSequence("views_increment_rate_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "9223372036854775807", cache: "1", cycle: false }) +export const relationSingerIdSeq = pgSequence("relation_singer_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "2147483647", cache: "1", cycle: false }) +export const relationsProducerIdSeq = pgSequence("relations_producer_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "2147483647", cache: "1", cycle: false }) export const usersIdSeqInCredentials = credentials.sequence("users_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "2147483647", cache: "1", cycle: false }) export const relations = pgTable("relations", { @@ -66,6 +68,11 @@ export const humanClassifiedLables = pgTable("human_classified_lables", { index("idx_classified-labels-human_label").using("btree", table.label.asc().nullsLast().op("int2_ops")), ]); +export const singer = pgTable("singer", { + id: serial().primaryKey().notNull(), + name: text().notNull(), +}); + export const history = pgTable("history", { id: serial().primaryKey().notNull(), // You can use { mode: "bigint" } if numbers are exceeding js number limitations @@ -129,16 +136,10 @@ export const songs = pgTable("songs", { index("idx_netease_id").using("btree", table.neteaseId.asc().nullsLast().op("int8_ops")), index("idx_published_at").using("btree", table.publishedAt.asc().nullsLast().op("timestamptz_ops")), index("idx_type").using("btree", table.type.asc().nullsLast().op("int2_ops")), - uniqueIndex("songs_pkey").using("btree", table.id.asc().nullsLast().op("int4_ops")), uniqueIndex("unq_songs_aid").using("btree", table.aid.asc().nullsLast().op("int8_ops")), uniqueIndex("unq_songs_netease_id").using("btree", table.neteaseId.asc().nullsLast().op("int8_ops")), ]); -export const singer = pgTable("singer", { - id: serial().primaryKey().notNull(), - name: text().notNull(), -}); - export const videoSnapshot = pgTable("video_snapshot", { id: integer().default(sql`nextval('video_snapshot_id_seq'::regclass)`).notNull(), createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).default(sql`CURRENT_TIMESTAMP`).notNull(), @@ -173,6 +174,30 @@ export const bilibiliUser = pgTable("bilibili_user", { unique("unq_bili-user_uid").on(table.uid), ]); +export const producer = pgTable("producer", { + id: integer().primaryKey().notNull(), + name: text().notNull(), +}); + +export const relationSinger = pgTable("relation_singer", { + id: integer().default(sql`nextval('relation_singer_id_seq'::regclass)`).primaryKey().notNull(), + songId: integer("song_id").notNull(), + singerId: integer("singer_id").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) => [ + foreignKey({ + columns: [table.songId], + foreignColumns: [songs.id], + name: "fkey_song_id" + }), + foreignKey({ + columns: [table.singerId], + foreignColumns: [singer.id], + name: "fkey_singer_id" + }), +]); + export const snapshotSchedule = pgTable("snapshot_schedule", { id: bigserial({ mode: "bigint" }).notNull(), // You can use { mode: "bigint" } if numbers are exceeding js number limitations @@ -231,3 +256,17 @@ export const usersInCredentials = credentials.table("users", { uniqueIndex("users_unq_id_key").using("btree", table.unqId.asc().nullsLast().op("text_ops")), uniqueIndex("users_username_key").using("btree", table.username.asc().nullsLast().op("text_ops")), ]); + +export const relationsProducer = pgTable("relations_producer", { + id: integer().default(sql`nextval('relations_producer_id_seq'::regclass)`).primaryKey().notNull(), + songId: integer("song_id").notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).default(sql`CURRENT_TIMESTAMP`).notNull(), + producerId: integer("producer_id").notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).default(sql`CURRENT_TIMESTAMP`).notNull(), +}, (table) => [ + foreignKey({ + columns: [table.songId], + foreignColumns: [songs.id], + name: "fkey_relations_producer_songs_id" + }), +]); diff --git a/packages/core/drizzle/outerSchema.d.ts b/packages/core/drizzle/outerSchema.ts similarity index 61% rename from packages/core/drizzle/outerSchema.d.ts rename to packages/core/drizzle/outerSchema.ts index 30b5311..dda2cd1 100644 --- a/packages/core/drizzle/outerSchema.d.ts +++ b/packages/core/drizzle/outerSchema.ts @@ -1,10 +1,10 @@ import type { InferSelectModel } from "drizzle-orm"; -import { users } from "~db/cred/schema"; -import { bilibiliMetadata, latestVideoSnapshot, songs, videoSnapshot } from "~db/main/schema"; +import { usersInCredentials, bilibiliMetadata, latestVideoSnapshot, songs, videoSnapshot, producer } from "./main/schema"; -export type UserType = InferSelectModel; +export type UserType = InferSelectModel; export type SensitiveUserFields = "password" | "unqId"; export type BilibiliMetadataType = InferSelectModel; export type VideoSnapshotType = InferSelectModel; export type LatestVideoSnapshotType = InferSelectModel; export type SongType = InferSelectModel; +export type ProducerType = InferSelectModel; \ No newline at end of file diff --git a/packages/elysia/routes/search/index.ts b/packages/elysia/routes/search/index.ts new file mode 100644 index 0000000..adf04b0 --- /dev/null +++ b/packages/elysia/routes/search/index.ts @@ -0,0 +1,162 @@ +import { Elysia, t } from "elysia"; +import { db } from "@core/drizzle"; +import { bilibiliMetadata, latestVideoSnapshot, songs } from "@core/drizzle/main/schema"; +import { eq, like, or } from "drizzle-orm"; +import type { BilibiliMetadataType, ProducerType, SongType } from "@core/drizzle/outerSchema"; + +interface SongSearchResult { + type: "song"; + data: SongType; + rank: number; +} + +interface ProducerSearchResult { + type: "producer"; + data: ProducerType; + rank: number; +} + +interface BiliVideoSearchResult { + type: "bili-video"; + data: BilibiliMetadataType; + rank: number; // 0 to 1 +} + +const getSongSearchResult = async (searchQuery: string) => { + const data = await db + .select() + .from(songs) + .innerJoin(latestVideoSnapshot, eq(songs.aid, latestVideoSnapshot.aid)) + .where(like(songs.name, `%${searchQuery}%`)) + .limit(10); + + const results = data + .map((song) => { + if (!song.songs.name) return null; + const occurrences = (song.songs.name.match(new RegExp(searchQuery, "gi")) || []).length; + const viewsLog = Math.log10(song.latest_video_snapshot.views + 1); + return { + type: "song" as "song", + data: song, + occurrences, + viewsLog + }; + }) + .filter((d) => d !== null); + + // If no results, return empty array + if (results.length === 0) return []; + + // Normalize occurrences and viewsLog + const maxOccurrences = Math.max(...results.map((r) => r.occurrences)); + const minViewsLog = Math.min(...results.map((r) => r.viewsLog)); + const maxViewsLog = Math.max(...results.map((r) => r.viewsLog)); + const viewsLogRange = maxViewsLog - minViewsLog || 1; // Prevent division by zero + + // Calculate weighted rank (0-1 range) + // Weight: 0.6 for occurrences, 0.4 for viewsLog + const normalizedResults = results.map((result) => { + const normalizedOccurrences = maxOccurrences > 0 ? result.occurrences / maxOccurrences : 0; + const normalizedViewsLog = (result.viewsLog - minViewsLog) / viewsLogRange; + + // Weighted combination + const rank = normalizedOccurrences * 0.6 + normalizedViewsLog * 0.4; + + return { + type: result.type, + data: result.data.songs, + rank: Math.min(Math.max(rank, 0), 1) // Ensure rank is between 0 and 1 + }; + }); + + return normalizedResults; +}; + +const getVideoSearchResult = async (searchQuery: string) => { + const results = await db + .select() + .from(bilibiliMetadata) + .where( + or( + eq(bilibiliMetadata.bvid, searchQuery), + eq(bilibiliMetadata.aid, Number.parseInt(searchQuery) || 0) + ) + ) + .limit(10); + return results.map((video) => ({ + type: "bili-video" as "bili-video", + data: video, + rank: 1 // Exact match + })); +}; + +const BiliVideoDataSchema = t.Object({ + duration: t.Union([t.Number(), t.Null()]), + id: t.Number(), + aid: t.Number(), + publishedAt: t.Union([t.String(), t.Null()]), + createdAt: t.Union([t.String(), t.Null()]), + description: t.Union([t.String(), t.Null()]), + bvid: t.Union([t.String(), t.Null()]), + uid: t.Union([t.Number(), t.Null()]), + tags: t.Union([t.String(), t.Null()]), + title: t.Union([t.String(), t.Null()]), + status: t.Number(), + coverUrl: t.Union([t.String(), t.Null()]) +}); + +const SongDataSchema = t.Object({ + duration: t.Union([t.Number(), t.Null()]), + name: t.Union([t.String(), t.Null()]), + id: t.Number(), + aid: t.Union([t.Number(), t.Null()]), + publishedAt: t.Union([t.String(), t.Null()]), + type: t.Union([t.Number(), t.Null()]), + neteaseId: t.Union([t.Number(), t.Null()]), + createdAt: t.String(), + updatedAt: t.String(), + deleted: t.Boolean(), + image: t.Union([t.String(), t.Null()]), + producer: t.Union([t.String(), t.Null()]) +}); + +export const searchHandler = new Elysia({ prefix: "/search" }).get( + "/result", + async ({ query }) => { + const searchQuery = query.query; + const [songResults, videoResults] = await Promise.all([ + getSongSearchResult(searchQuery), + getVideoSearchResult(searchQuery) + ]); + + const combinedResults: (SongSearchResult | BiliVideoSearchResult)[] = [ + ...songResults, + ...videoResults + ]; + return combinedResults.sort((a, b) => b.rank - a.rank); + }, + { + response: { + 200: t.Array( + t.Union([ + t.Object({ + type: t.Literal("song"), + data: SongDataSchema, + rank: t.Number() + }), + t.Object({ + type: t.Literal("bili-video"), + data: BiliVideoDataSchema, + rank: t.Number() + }) + ]) + ), + 404: t.Object({ + message: t.String() + }) + }, + query: t.Object({ + query: t.String() + }) + } +); diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 2282448..bbf801d 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -9,6 +9,7 @@ import { getVideoMetadataHandler } from "@elysia/routes/video/metadata"; import { closeMileStoneHandler } from "@elysia/routes/song/milestone"; import { authHandler } from "@elysia/routes/auth"; import { onAfterHandler } from "./onAfterHandle"; +import { searchHandler } from "@elysia/routes/search"; const [host, port] = getBindingInfo(); logStartup(host, port); @@ -27,6 +28,7 @@ const app = new Elysia({ .use(getVideoMetadataHandler) .use(songInfoHandler) .use(closeMileStoneHandler) + .use(searchHandler) .listen(15412); export const VERSION = "0.7.0";