add: search endpoint in backend
This commit is contained in:
parent
a54908607a
commit
ce548951d8
@ -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);
|
||||
@ -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]
|
||||
}),
|
||||
}));
|
||||
@ -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"
|
||||
}),
|
||||
]);
|
||||
|
||||
@ -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<typeof users>;
|
||||
export type UserType = InferSelectModel<typeof usersInCredentials>;
|
||||
export type SensitiveUserFields = "password" | "unqId";
|
||||
export type BilibiliMetadataType = InferSelectModel<typeof bilibiliMetadata>;
|
||||
export type VideoSnapshotType = InferSelectModel<typeof videoSnapshot>;
|
||||
export type LatestVideoSnapshotType = InferSelectModel<typeof latestVideoSnapshot>;
|
||||
export type SongType = InferSelectModel<typeof songs>;
|
||||
export type ProducerType = InferSelectModel<typeof producer>;
|
||||
162
packages/elysia/routes/search/index.ts
Normal file
162
packages/elysia/routes/search/index.ts
Normal file
@ -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()
|
||||
})
|
||||
}
|
||||
);
|
||||
@ -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";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user