add: labelling instructions
This commit is contained in:
parent
3ec949bdc5
commit
bb01750816
@ -1,18 +1,19 @@
|
||||
import Argon2id from "@rabbit-company/argon2id";
|
||||
import { db, usersInCredentials, loginSessionsInCredentials } from "@core/drizzle";
|
||||
import { eq, and, isNull, getTableColumns } from "drizzle-orm";
|
||||
import {
|
||||
db,
|
||||
usersInCredentials,
|
||||
loginSessionsInCredentials,
|
||||
UserType,
|
||||
SessionType
|
||||
} from "@core/drizzle";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { generate as generateId } from "@alikia/random-key";
|
||||
import logger from "@core/log";
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
nickname: string | null;
|
||||
role: string;
|
||||
unqId: string;
|
||||
}
|
||||
|
||||
export async function verifyUser(username: string, password: string): Promise<User | null> {
|
||||
export async function verifyUser(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<Omit<UserType, "password"> | null> {
|
||||
const user = await db
|
||||
.select()
|
||||
.from(usersInCredentials)
|
||||
@ -34,7 +35,8 @@ export async function verifyUser(username: string, password: string): Promise<Us
|
||||
username: foundUser.username,
|
||||
nickname: foundUser.nickname,
|
||||
role: foundUser.role,
|
||||
unqId: foundUser.unqId
|
||||
unqId: foundUser.unqId,
|
||||
createdAt: foundUser.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
@ -67,7 +69,7 @@ export async function createSession(
|
||||
|
||||
export async function validateSession(
|
||||
sessionId: string
|
||||
): Promise<{ user: User; session: any } | null> {
|
||||
): Promise<{ user: UserType; session: SessionType } | null> {
|
||||
const sessions = await db
|
||||
.select()
|
||||
.from(loginSessionsInCredentials)
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { validateSession, User } from "@backend/lib/auth";
|
||||
import { validateSession } from "@backend/lib/auth";
|
||||
import { SessionType, UserType } from "@core/drizzle";
|
||||
|
||||
export interface AuthenticatedContext {
|
||||
user: User;
|
||||
session: any;
|
||||
user: UserType;
|
||||
session: SessionType;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
@ -64,7 +65,7 @@ export const requireAuth = new Elysia({ name: "require-auth" })
|
||||
isAuthenticated: true
|
||||
};
|
||||
})
|
||||
.onBeforeHandle({ as: "scoped" }, ({ user, status }) => {
|
||||
.onBeforeHandle({ as: "scoped" }, ({ user, session, status }) => {
|
||||
if (!user) {
|
||||
return status(401, {
|
||||
message: "Authentication required."
|
||||
|
||||
22
packages/backend/middlewares/openapi.ts
Normal file
22
packages/backend/middlewares/openapi.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import openapi from "@elysiajs/openapi";
|
||||
import pkg from "../package.json";
|
||||
import * as z from "zod";
|
||||
import { fromTypes } from "@elysiajs/openapi";
|
||||
|
||||
export const openAPIMiddleware = openapi({
|
||||
documentation: {
|
||||
info: {
|
||||
title: "CVSA API Docs",
|
||||
version: pkg.version
|
||||
}
|
||||
},
|
||||
mapJsonSchema: {
|
||||
zod: z.toJSONSchema
|
||||
},
|
||||
references: fromTypes(),
|
||||
scalar: {
|
||||
theme: "kepler",
|
||||
hideClientButton: true,
|
||||
hideDarkModeToggle: true
|
||||
}
|
||||
});
|
||||
@ -1,6 +1,6 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { db, history, songs } from "@core/drizzle";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { db, eta, history, songs, videoSnapshot } from "@core/drizzle";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { bv2av } from "@backend/lib/bilibiliID";
|
||||
import { requireAuth } from "@backend/middlewares/auth";
|
||||
|
||||
@ -24,7 +24,7 @@ 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 (r) songID = r;
|
||||
}
|
||||
if (!songID) {
|
||||
songID = Number.parseInt(id);
|
||||
@ -41,136 +41,179 @@ async function getSongInfo(id: number) {
|
||||
return songInfo[0];
|
||||
}
|
||||
|
||||
const songInfoGetHandler = new Elysia({ prefix: "/song" }).get(
|
||||
"/:id/info",
|
||||
async ({ params, status }) => {
|
||||
export const songHandler = new Elysia({ prefix: "/song/:id" })
|
||||
.resolve(async ({ params }) => {
|
||||
const id = params.id;
|
||||
const songID = await getSongID(id);
|
||||
if (!songID) {
|
||||
return status(404, {
|
||||
code: "SONG_NOT_FOUND",
|
||||
message: "Given song cannot be found."
|
||||
});
|
||||
}
|
||||
const info = await getSongInfo(songID);
|
||||
if (!info) {
|
||||
return status(404, {
|
||||
code: "SONG_NOT_FOUND",
|
||||
message: "Given song cannot be found."
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: info.id,
|
||||
name: info.name,
|
||||
aid: info.aid,
|
||||
producer: info.producer,
|
||||
duration: info.duration,
|
||||
cover: info.image || undefined,
|
||||
publishedAt: info.publishedAt
|
||||
songID
|
||||
};
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
id: t.Number(),
|
||||
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()]),
|
||||
cover: t.Optional(t.String()),
|
||||
publishedAt: t.Union([t.String(), t.Null()])
|
||||
}),
|
||||
404: t.Object({
|
||||
code: t.String(),
|
||||
message: t.String()
|
||||
})
|
||||
})
|
||||
.get(
|
||||
"/info",
|
||||
async ({ status, songID }) => {
|
||||
if (!songID) {
|
||||
return status(404, {
|
||||
code: "SONG_NOT_FOUND",
|
||||
message: "Given song cannot be found."
|
||||
});
|
||||
}
|
||||
const info = await getSongInfo(songID);
|
||||
if (!info) {
|
||||
return status(404, {
|
||||
code: "SONG_NOT_FOUND",
|
||||
message: "Given song cannot be found."
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: info.id,
|
||||
name: info.name,
|
||||
aid: info.aid,
|
||||
producer: info.producer,
|
||||
duration: info.duration,
|
||||
cover: info.image || undefined,
|
||||
publishedAt: info.publishedAt
|
||||
};
|
||||
},
|
||||
headers: t.Object({
|
||||
Authorization: t.Optional(t.String())
|
||||
}),
|
||||
detail: {
|
||||
summary: "Get information of a song",
|
||||
description:
|
||||
"This endpoint retrieves detailed information about a song using its unique ID, \
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
id: t.Number(),
|
||||
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()]),
|
||||
cover: t.Optional(t.String()),
|
||||
publishedAt: t.Union([t.String(), t.Null()])
|
||||
}),
|
||||
404: t.Object({
|
||||
code: t.String(),
|
||||
message: t.String()
|
||||
})
|
||||
},
|
||||
headers: t.Object({
|
||||
Authorization: t.Optional(t.String())
|
||||
}),
|
||||
detail: {
|
||||
summary: "Get information of a song",
|
||||
description:
|
||||
"This endpoint retrieves detailed information about a song using its unique ID, \
|
||||
which can be provided in several formats. \
|
||||
The endpoint accepts a song ID in either a numerical format as the internal ID in our database\
|
||||
or as a bilibili video ID (either av or BV format). \
|
||||
It responds with the song's name, bilibili ID (av), producer, duration, and associated singers."
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const songInfoUpdateHandler = new Elysia({ prefix: "/song" }).use(requireAuth).patch(
|
||||
"/:id/info",
|
||||
async ({ params, status, body, user }) => {
|
||||
const id = params.id;
|
||||
const songID = await getSongID(id);
|
||||
if (!songID) {
|
||||
)
|
||||
.get("/snapshots", async ({ status, songID }) => {
|
||||
const r = await db.select().from(songs).where(eq(songs.id, songID)).limit(1);
|
||||
if (r.length == 0) {
|
||||
return status(404, {
|
||||
code: "SONG_NOT_FOUND",
|
||||
message: "Given song cannot be found."
|
||||
});
|
||||
}
|
||||
const info = await getSongInfo(songID);
|
||||
if (!info) {
|
||||
const song = r[0];
|
||||
const aid = song.aid;
|
||||
if (!aid) {
|
||||
return status(404, {
|
||||
message: "Given song is not associated with any bilibili video."
|
||||
});
|
||||
}
|
||||
return db
|
||||
.select()
|
||||
.from(videoSnapshot)
|
||||
.where(eq(videoSnapshot.aid, aid))
|
||||
.orderBy(desc(videoSnapshot.createdAt));
|
||||
})
|
||||
.get("/eta", async ({ status, songID }) => {
|
||||
const r = await db.select().from(songs).where(eq(songs.id, songID)).limit(1);
|
||||
if (r.length == 0) {
|
||||
return status(404, {
|
||||
code: "SONG_NOT_FOUND",
|
||||
message: "Given song cannot be found."
|
||||
});
|
||||
}
|
||||
const song = r[0];
|
||||
const aid = song.aid;
|
||||
if (!aid) {
|
||||
return status(404, {
|
||||
message: "Given song is not associated with any bilibili video."
|
||||
});
|
||||
}
|
||||
return db.select().from(eta).where(eq(eta.aid, aid));
|
||||
})
|
||||
.use(requireAuth)
|
||||
.patch(
|
||||
"/info",
|
||||
async ({ params, status, body, user, songID }) => {
|
||||
if (!songID) {
|
||||
return status(404, {
|
||||
code: "SONG_NOT_FOUND",
|
||||
message: "Given song cannot be found."
|
||||
});
|
||||
}
|
||||
const info = await getSongInfo(songID);
|
||||
if (!info) {
|
||||
return status(404, {
|
||||
code: "SONG_NOT_FOUND",
|
||||
message: "Given song cannot be found."
|
||||
});
|
||||
}
|
||||
|
||||
if (body.name) {
|
||||
await db.update(songs).set({ name: body.name }).where(eq(songs.id, songID));
|
||||
}
|
||||
if (body.producer) {
|
||||
await db
|
||||
.update(songs)
|
||||
.set({ producer: body.producer })
|
||||
.where(eq(songs.id, songID))
|
||||
.returning();
|
||||
}
|
||||
const updatedData = await db.select().from(songs).where(eq(songs.id, songID));
|
||||
await db.insert(history).values({
|
||||
objectId: songID,
|
||||
changeType: "update-song",
|
||||
changedBy: user!.unqId,
|
||||
data: updatedData.length > 0 ? {
|
||||
old: info,
|
||||
new: updatedData[0]
|
||||
} : null
|
||||
});
|
||||
return {
|
||||
message: "Successfully updated song info.",
|
||||
updated: updatedData.length > 0 ? updatedData[0] : null
|
||||
};
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
message: t.String(),
|
||||
updated: t.Any()
|
||||
}),
|
||||
401: t.Object({
|
||||
message: t.String()
|
||||
}),
|
||||
404: t.Object({
|
||||
message: t.String(),
|
||||
code: t.String()
|
||||
})
|
||||
if (body.name) {
|
||||
await db.update(songs).set({ name: body.name }).where(eq(songs.id, songID));
|
||||
}
|
||||
if (body.producer) {
|
||||
await db
|
||||
.update(songs)
|
||||
.set({ producer: body.producer })
|
||||
.where(eq(songs.id, songID))
|
||||
.returning();
|
||||
}
|
||||
const updatedData = await db.select().from(songs).where(eq(songs.id, songID));
|
||||
await db.insert(history).values({
|
||||
objectId: songID,
|
||||
changeType: "update-song",
|
||||
changedBy: user!.unqId,
|
||||
data:
|
||||
updatedData.length > 0
|
||||
? {
|
||||
old: info,
|
||||
new: updatedData[0]
|
||||
}
|
||||
: null
|
||||
});
|
||||
return {
|
||||
message: "Successfully updated song info.",
|
||||
updated: updatedData.length > 0 ? updatedData[0] : null
|
||||
};
|
||||
},
|
||||
body: t.Object({
|
||||
name: t.Optional(t.String()),
|
||||
producer: t.Optional(t.String())
|
||||
}),
|
||||
detail: {
|
||||
summary: "Update song information",
|
||||
description:
|
||||
"This endpoint allows authenticated users to update song metadata. It accepts partial updates \
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
message: t.String(),
|
||||
updated: t.Any()
|
||||
}),
|
||||
401: t.Object({
|
||||
message: t.String()
|
||||
}),
|
||||
404: t.Object({
|
||||
message: t.String(),
|
||||
code: t.String()
|
||||
})
|
||||
},
|
||||
body: t.Object({
|
||||
name: t.Optional(t.String()),
|
||||
producer: t.Optional(t.String())
|
||||
}),
|
||||
detail: {
|
||||
summary: "Update song information",
|
||||
description:
|
||||
"This endpoint allows authenticated users to update song metadata. It accepts partial updates \
|
||||
for song name and producer fields. The endpoint validates the song ID (accepting both internal database IDs \
|
||||
and bilibili video IDs in av/BV format), applies the requested changes, and logs the update in the history table \
|
||||
for audit purposes. Requires authentication."
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const songInfoHandler = new Elysia().use(songInfoGetHandler).use(songInfoUpdateHandler);
|
||||
);
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { ErrorResponseSchema } from "@backend/src/schema";
|
||||
import z from "zod";
|
||||
import { BiliVideoSchema, BiliVideoType } from "@backend/lib/schema";
|
||||
import { BiliVideoSchema } from "@backend/lib/schema";
|
||||
import requireAuth from "@backend/middlewares/auth";
|
||||
import { sql, eq } from "drizzle-orm";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { bilibiliMetadata, db, videoTypeLabelInInternal } from "@core/drizzle";
|
||||
import { biliIDToAID } from "@backend/lib/bilibiliID";
|
||||
|
||||
@ -22,7 +22,7 @@ const videoSchema = BiliVideoSchema.omit({ publishedAt: true })
|
||||
export const getUnlabelledVideos = new Elysia({ prefix: "/videos" }).use(requireAuth).get(
|
||||
"/unlabelled",
|
||||
async () => {
|
||||
const videos = await db.execute<z.infer<typeof videoSchema>>(sql`
|
||||
return db.execute<z.infer<typeof videoSchema>>(sql`
|
||||
SELECT bm.*, ls.views, bu.username, bu.uid
|
||||
FROM (
|
||||
SELECT *
|
||||
@ -35,8 +35,25 @@ export const getUnlabelledVideos = new Elysia({ prefix: "/videos" }).use(require
|
||||
ON ls.aid = bm.aid
|
||||
JOIN bilibili_user bu
|
||||
ON bu.uid = bm.uid
|
||||
`);
|
||||
return videos;
|
||||
UNION
|
||||
SELECT bm.*, ls.views, bu.username, bu.uid
|
||||
FROM (
|
||||
SELECT *
|
||||
FROM bilibili_metadata
|
||||
WHERE aid IN (
|
||||
SELECT aid
|
||||
FROM internal.video_type_label
|
||||
TABLESAMPLE SYSTEM (2)
|
||||
WHERE user != 'i3wW8JdZ9sT3ASkk'
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 20
|
||||
)
|
||||
) bm
|
||||
JOIN latest_video_snapshot ls
|
||||
ON ls.aid = bm.aid
|
||||
JOIN bilibili_user bu
|
||||
ON bu.uid = bm.uid
|
||||
`);
|
||||
},
|
||||
{
|
||||
response: {
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Elysia, file } from "elysia";
|
||||
import { Elysia, ErrorHandler, file } from "elysia";
|
||||
import { getBindingInfo, logStartup } from "./startMessage";
|
||||
import { pingHandler } from "@backend/routes/ping";
|
||||
import openapi from "@elysiajs/openapi";
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import { songInfoHandler } from "@backend/routes/song/info";
|
||||
import { songHandler } from "@backend/routes/song/info";
|
||||
import { rootHandler } from "@backend/routes/root";
|
||||
import { getVideoMetadataHandler } from "@backend/routes/video/metadata";
|
||||
import { closeMileStoneHandler } from "@backend/routes/song/milestone";
|
||||
@ -16,52 +16,35 @@ import { deleteSongHandler } from "@backend/routes/song/delete";
|
||||
import { songEtaHandler } from "@backend/routes/video/eta";
|
||||
import "./mq";
|
||||
import pkg from "../package.json";
|
||||
import * as z from "zod";
|
||||
import { fromTypes } from "@elysiajs/openapi";
|
||||
import { getUnlabelledVideos, postVideoLabel } from "@backend/routes/video/label";
|
||||
import { openAPIMiddleware } from "@backend/middlewares/openapi";
|
||||
|
||||
const [host, port] = getBindingInfo();
|
||||
logStartup(host, port);
|
||||
|
||||
const errorHandler: ErrorHandler = ({ code, status, error }) => {
|
||||
if (code === "NOT_FOUND")
|
||||
return status(404, {
|
||||
message: "The requested resource was not found."
|
||||
});
|
||||
if (code === "VALIDATION") return error.detail(error.message);
|
||||
return error;
|
||||
};
|
||||
|
||||
const app = new Elysia({
|
||||
serve: {
|
||||
hostname: host
|
||||
}
|
||||
})
|
||||
.onError(({ code, status, error }) => {
|
||||
if (code === "NOT_FOUND")
|
||||
return status(404, {
|
||||
message: "The requested resource was not found."
|
||||
});
|
||||
if (code === "VALIDATION") return error.detail(error.message);
|
||||
return error;
|
||||
})
|
||||
.onError(errorHandler)
|
||||
.use(onAfterHandler)
|
||||
.use(cors())
|
||||
.use(
|
||||
openapi({
|
||||
documentation: {
|
||||
info: {
|
||||
title: "CVSA API Docs",
|
||||
version: pkg.version
|
||||
}
|
||||
},
|
||||
mapJsonSchema: {
|
||||
zod: z.toJSONSchema
|
||||
},
|
||||
references: fromTypes(),
|
||||
scalar: {
|
||||
theme: "kepler",
|
||||
hideClientButton: true,
|
||||
hideDarkModeToggle: true
|
||||
}
|
||||
})
|
||||
)
|
||||
.use(openAPIMiddleware)
|
||||
.use(rootHandler)
|
||||
.use(pingHandler)
|
||||
.use(authHandler)
|
||||
.use(getVideoMetadataHandler)
|
||||
.use(songInfoHandler)
|
||||
.use(songHandler)
|
||||
.use(closeMileStoneHandler)
|
||||
.use(searchHandler)
|
||||
.use(getVideoSnapshotsHandler)
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { usersInCredentials, bilibiliMetadata, latestVideoSnapshot, songs, videoSnapshot, producer } from "./main/schema";
|
||||
import {
|
||||
usersInCredentials,
|
||||
bilibiliMetadata,
|
||||
latestVideoSnapshot,
|
||||
songs,
|
||||
videoSnapshot,
|
||||
producer,
|
||||
loginSessionsInCredentials
|
||||
} from "./main/schema";
|
||||
|
||||
export type UserType = InferSelectModel<typeof usersInCredentials>;
|
||||
export type SensitiveUserFields = "password" | "unqId";
|
||||
@ -7,4 +15,5 @@ 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>;
|
||||
export type ProducerType = InferSelectModel<typeof producer>;
|
||||
export type SessionType = InferSelectModel<typeof loginSessionsInCredentials>;
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
// ../../node_modules/.bun/wrangler@4.46.0/node_modules/wrangler/templates/no-op-worker.js
|
||||
var no_op_worker_default = {
|
||||
fetch() {
|
||||
return new Response("Not found", {
|
||||
status: 404,
|
||||
headers: {
|
||||
"Content-Type": "text/html"
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
export {
|
||||
no_op_worker_default as default
|
||||
};
|
||||
//# sourceMappingURL=no-op-worker.js.map
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": ["../../../../../node_modules/.bun/wrangler@4.46.0/node_modules/wrangler/templates/no-op-worker.js"],
|
||||
"sourceRoot": "/Users/alikia/Code/cvsa/packages/temp_frontend/.wrangler/tmp/deploy-41aj6n",
|
||||
"sourcesContent": ["export default {\n\tfetch() {\n\t\treturn new Response(\"Not found\", {\n\t\t\tstatus: 404,\n\t\t\theaders: {\n\t\t\t\t\"Content-Type\": \"text/html\",\n\t\t\t},\n\t\t});\n\t},\n};\n"],
|
||||
"mappings": ";AAAA,IAAO,uBAAQ;AAAA,EACd,QAAQ;AACP,WAAO,IAAI,SAAS,aAAa;AAAA,MAChC,QAAQ;AAAA,MACR,SAAS;AAAA,QACR,gBAAgB;AAAA,MACjB;AAAA,IACD,CAAC;AAAA,EACF;AACD;",
|
||||
"names": []
|
||||
}
|
||||
@ -8,5 +8,5 @@ export default [
|
||||
route("login", "routes/login.tsx"),
|
||||
route("video/:id/info", "routes/video/[id]/info/index.tsx"),
|
||||
route("time-calculator", "routes/time-calculator.tsx"),
|
||||
route("labelling", "routes/labelling.tsx"),
|
||||
route("labelling", "routes/labelling/index.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
52
packages/temp_frontend/app/routes/labelling/ControlBar.tsx
Normal file
52
packages/temp_frontend/app/routes/labelling/ControlBar.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
|
||||
interface ControlBarProps {
|
||||
currentIndex: number;
|
||||
videosLength: number;
|
||||
hasMore: boolean;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onLabel: (label: boolean) => void;
|
||||
}
|
||||
|
||||
export function ControlBar({ currentIndex, videosLength, hasMore, onPrevious, onNext, onLabel }: ControlBarProps) {
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-background border-t p-4 shadow-lg">
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onPrevious}
|
||||
disabled={currentIndex === 0}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
上一个
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button variant="destructive" onClick={() => onLabel(false)} className="flex items-center gap-2">
|
||||
<X className="h-4 w-4" />否
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => onLabel(true)}
|
||||
className="flex items-center gap-2 bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<Check className="h-4 w-4" />是
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onNext}
|
||||
disabled={currentIndex === videosLength - 1 && !hasMore}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
下一个
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface LabelInstructionsProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function LabelInstructions({ open, onOpenChange }: LabelInstructionsProps) {
|
||||
return (
|
||||
<div className="mt-4 mb-3">
|
||||
<div className="flex items-center">
|
||||
该视频是否包含一首<b>中V歌曲</b>?
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="link" className="p-0 h-auto text-secondary-foreground ml-2">
|
||||
点击查看说明
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>打标说明</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
<b>中V歌曲</b>
|
||||
意味着它是由中文虚拟歌姬演唱,或有歌声合成的人声且歌词中包含中文。歌曲可以是原创,也可以是非原创(如翻唱、翻调等)。
|
||||
</p>
|
||||
<p>
|
||||
请根据视频信息判断是否包含中V歌曲。
|
||||
<br />
|
||||
请尽量优先参考屏幕上给出的信息,尤其是文字信息做出判断,
|
||||
<b>因为这将是模型唯一能接收的信息。</b>
|
||||
<br />
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
特别指示:
|
||||
<br />
|
||||
</b>
|
||||
<ul className="list-disc list-inside text-sm">
|
||||
<li>对于“AI孙燕姿”一类使用RVC等技术生成的人声,请选择“否”。</li>
|
||||
<li>对于外文歌姬(如初音未来)演唱的<b>中文歌曲</b>,请选择“是”。</li>
|
||||
<li>对于中文歌姬(如洛天依)演唱的<b>外语歌曲</b>,请选择“是”。</li>
|
||||
<li>对于自行制作的声库/歌姬(如自制UTAU/DiffSinger声库),只要歌词中有中文,请选择“是”。</li>
|
||||
</ul>
|
||||
</p>
|
||||
<p><b>如果你无法确定,请始终选择“是”。</b></p>
|
||||
<div className="bg-muted p-4 rounded-md">
|
||||
<h4 className="font-medium mb-2">键盘快捷键</h4>
|
||||
<ul className="list-disc list-inside text-sm text-secondary-foreground">
|
||||
<li>键盘左键区的按键(G、F、3、1、Q、Z 等)表示“否”</li>
|
||||
<li>键盘右键区的按键(H、J、P、8、0、L 等)表示“是”</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<Label className="text-secondary-foreground mt-1 leading-5 block">
|
||||
中V歌曲意味着它是由中文虚拟歌姬演唱,或歌词中包含中文。歌曲可以是原创,也可以是非原创(如翻唱、翻调等)。
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
packages/temp_frontend/app/routes/labelling/VideoInfo.tsx
Normal file
95
packages/temp_frontend/app/routes/labelling/VideoInfo.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { formatDateTime } from "@/components/SearchResults";
|
||||
import { treaty } from "@elysiajs/eden";
|
||||
import type { App } from "@backend/src";
|
||||
|
||||
// @ts-expect-error anyway...
|
||||
const app = treaty<App>(import.meta.env.VITE_API_URL!);
|
||||
|
||||
type VideosResponse = Awaited<ReturnType<Awaited<typeof app.videos.unlabelled>["get"]>>["data"];
|
||||
|
||||
|
||||
interface VideoInfoProps {
|
||||
video: Exclude<VideosResponse, null>[number];
|
||||
}
|
||||
|
||||
export function VideoInfo({ video }: VideoInfoProps) {
|
||||
const formatDuration = (duration: number) => {
|
||||
return `${Math.floor(duration / 60)}:${(duration % 60).toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-24">
|
||||
<div className="flex flex-col sm:flex-row sm:gap-4">
|
||||
{video.cover_url && (
|
||||
<a
|
||||
href={`https://www.bilibili.com/video/${video.bvid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="min-w-full sm:min-w-60 md:min-w-80 max-w-full
|
||||
sm:max-w-60 md:max-w-80 aspect-video"
|
||||
>
|
||||
<img
|
||||
src={video.cover_url}
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
alt="Video cover"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
<div>
|
||||
<div className="max-sm:mt-6 flex items-center gap-2">
|
||||
<h1 className="text-2xl font-medium">
|
||||
<a
|
||||
href={`https://www.bilibili.com/video/${video.bvid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{video.title ? video.title : "未知视频标题"}
|
||||
</a>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-3">
|
||||
<div>
|
||||
<p>
|
||||
<span>{video.bvid}</span> · <span>av{video.aid}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>发布于 {formatDateTime(new Date(video.published_at!))}</span>
|
||||
<br />
|
||||
<span>播放:{(video.views ?? 0).toLocaleString()}</span>
|
||||
<span>时长:{formatDuration(video.duration || 0)}</span>
|
||||
</p>
|
||||
<p>
|
||||
UP主:
|
||||
<a
|
||||
className="underline"
|
||||
href={`https://space.bilibili.com/${video.uid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{video.username}
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<span>
|
||||
<b>标签</b>
|
||||
<br />
|
||||
{video.tags?.replaceAll(",", ",")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h3 className="font-medium text-lg mb-2">简介</h3>
|
||||
<pre className="max-w-full wrap-anywhere break-all text-on-surface-variant text-sm md:text-base whitespace-pre-wrap dark:text-dark-on-surface-variant font-zh">
|
||||
{video.description || "暂无简介"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,27 +1,31 @@
|
||||
import { Layout } from "@/components/Layout";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatDateTime } from "@/components/SearchResults";
|
||||
import { treaty } from "@elysiajs/eden";
|
||||
import type { App } from "@backend/src";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Error } from "@/components/Error";
|
||||
import { Title } from "@/components/Title";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronLeft, ChevronRight, Check, X } from "lucide-react";
|
||||
import { VideoInfo } from "./VideoInfo";
|
||||
import { ControlBar } from "@/routes/labelling/ControlBar";
|
||||
import { LabelInstructions } from "@/routes/labelling/LabelInstructions";
|
||||
|
||||
// @ts-expect-error anyway...
|
||||
const app = treaty<App>(import.meta.env.VITE_API_URL!);
|
||||
|
||||
type VideosResponse = Awaited<ReturnType<Awaited<typeof app.videos.unlabelled>["get"]>>["data"];
|
||||
|
||||
const leftKeys = ["1", "2", "3", "4", "5", "Q", "W", "E", "R", "T", "A", "S", "D", "F", "G", "Z", "X", "C", "V", "B"];
|
||||
const rightKeys = ["6", "7", "8", "9", "0", "Y", "U", "I", "O", "P", "H", "J", "K", "L", ";", "N", "M", ",", ".", "/"];
|
||||
|
||||
export default function Home() {
|
||||
const [videos, setVideos] = useState<Exclude<VideosResponse, null>>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<any>(null);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
||||
|
||||
const fetchVideos = useCallback(async () => {
|
||||
try {
|
||||
@ -57,8 +61,6 @@ export default function Home() {
|
||||
}, [hasMore, videos.length, currentIndex, fetchVideos]);
|
||||
|
||||
const labelVideo = async (videoId: string, label: boolean) => {
|
||||
const videoKey = `${videoId}-${label}`;
|
||||
|
||||
const maxRetries = 5;
|
||||
let retries = 0;
|
||||
|
||||
@ -127,6 +129,26 @@ export default function Home() {
|
||||
loadMoreIfNeeded();
|
||||
}, [currentIndex, loadMoreIfNeeded]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = e.key.toUpperCase();
|
||||
console.log(key);
|
||||
|
||||
if (leftKeys.includes(key)) {
|
||||
handleLabel(true);
|
||||
} else if (rightKeys.includes(key)) {
|
||||
handleLabel(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [currentIndex, videos]);
|
||||
|
||||
if (loading && videos.length === 0) {
|
||||
return (
|
||||
<Layout>
|
||||
@ -155,124 +177,16 @@ export default function Home() {
|
||||
|
||||
{currentVideo ? (
|
||||
<>
|
||||
<div className="mb-24">
|
||||
<p className="mt-4 mb-3">
|
||||
该视频是否包含一首<b>中V歌曲</b>?
|
||||
<Label className="text-secondary-foreground mt-1 leading-5">
|
||||
中V歌曲意味着它是由中文虚拟歌姬演唱,或歌词中包含中文。歌曲可以是原创,也可以是非原创(如翻唱、翻调等)。
|
||||
</Label>
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row sm:gap-4">
|
||||
{currentVideo.cover_url && (
|
||||
<a
|
||||
href={`https://www.bilibili.com/video/${currentVideo.bvid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="min-w-full sm:min-w-60 md:min-w-80 max-w-full
|
||||
sm:max-w-60 md:max-w-80 aspect-video"
|
||||
>
|
||||
<img
|
||||
src={currentVideo.cover_url}
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
alt="Video cover"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
<div>
|
||||
<div className="max-sm:mt-6 flex items-center gap-2">
|
||||
<h1 className="text-2xl font-medium">
|
||||
<a
|
||||
href={`https://www.bilibili.com/video/${currentVideo.bvid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{currentVideo.title ? currentVideo.title : "未知视频标题"}
|
||||
</a>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-3">
|
||||
<div>
|
||||
<p>
|
||||
<span>{currentVideo.bvid}</span> · <span>av{currentVideo.aid}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>发布于 {formatDateTime(new Date(currentVideo.published_at!))}</span><br/>
|
||||
<span>播放:{(currentVideo.views ?? 0).toLocaleString()}</span>
|
||||
</p>
|
||||
<p>
|
||||
UP主:
|
||||
<a
|
||||
className="underline"
|
||||
href={`https://space.bilibili.com/${currentVideo.uid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{currentVideo.username}
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<span>
|
||||
<b>标签</b>
|
||||
<br />
|
||||
{currentVideo.tags?.replaceAll(",",",")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h3 className="font-medium text-lg mb-2">简介</h3>
|
||||
<pre className="max-w-full wrap-anywhere break-all text-on-surface-variant text-sm md:text-base whitespace-pre-wrap dark:text-dark-on-surface-variant font-zh">
|
||||
{currentVideo.description || "暂无简介"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-background border-t p-4 shadow-lg">
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigateTo(currentIndex - 1)}
|
||||
disabled={currentIndex === 0}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
上一个
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => handleLabel(false)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<X className="h-4 w-4" />否
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => handleLabel(true)}
|
||||
className="flex items-center gap-2 bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<Check className="h-4 w-4" />是
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigateTo(currentIndex + 1)}
|
||||
disabled={currentIndex === videos.length - 1 && !hasMore}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
下一个
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<LabelInstructions open={instructionsOpen} onOpenChange={setInstructionsOpen} />
|
||||
<VideoInfo video={currentVideo} />
|
||||
<ControlBar
|
||||
currentIndex={currentIndex}
|
||||
videosLength={videos.length}
|
||||
hasMore={hasMore}
|
||||
onPrevious={() => navigateTo(currentIndex - 1)}
|
||||
onNext={() => navigateTo(currentIndex + 1)}
|
||||
onLabel={handleLabel}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
@ -266,8 +266,8 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const getEta = async (aid: number) => {
|
||||
const { data, error } = await app.video({ id: `av${aid}` }).eta.get();
|
||||
const getEta = async () => {
|
||||
const { data, error } = await app.video({ id: loaderData.id }).eta.get();
|
||||
if (error) {
|
||||
console.log(error);
|
||||
return;
|
||||
@ -275,8 +275,8 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
|
||||
setEtaData(data);
|
||||
};
|
||||
|
||||
const getSnapshots = async (aid: number) => {
|
||||
const { data, error } = await app.video({ id: `av${aid}` }).snapshots.get();
|
||||
const getSnapshots = async () => {
|
||||
const { data, error } = await app.song({ id: loaderData.id }).snapshots.get();
|
||||
if (error) {
|
||||
console.log(error);
|
||||
return;
|
||||
@ -296,16 +296,10 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
useEffect(() => {
|
||||
getInfo();
|
||||
getSnapshots();
|
||||
getEta();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!songInfo) return;
|
||||
const aid = songInfo.aid;
|
||||
if (!aid) return;
|
||||
getSnapshots(aid);
|
||||
getEta(aid);
|
||||
}, [songInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (songInfo?.name) {
|
||||
setSongName(songInfo.name);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user