diff --git a/packages/backend/lib/auth.ts b/packages/backend/lib/auth.ts index 2c03942..51aa18c 100644 --- a/packages/backend/lib/auth.ts +++ b/packages/backend/lib/auth.ts @@ -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 { +export async function verifyUser( + username: string, + password: string +): Promise | null> { const user = await db .select() .from(usersInCredentials) @@ -34,7 +35,8 @@ export async function verifyUser(username: string, password: string): Promise { +): Promise<{ user: UserType; session: SessionType } | null> { const sessions = await db .select() .from(loginSessionsInCredentials) diff --git a/packages/backend/middlewares/auth.ts b/packages/backend/middlewares/auth.ts index e29d358..ebe6155 100644 --- a/packages/backend/middlewares/auth.ts +++ b/packages/backend/middlewares/auth.ts @@ -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." diff --git a/packages/backend/middlewares/openapi.ts b/packages/backend/middlewares/openapi.ts new file mode 100644 index 0000000..5c20daa --- /dev/null +++ b/packages/backend/middlewares/openapi.ts @@ -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 + } +}); diff --git a/packages/backend/routes/song/info.ts b/packages/backend/routes/song/info.ts index 427e4da..2abcfce 100644 --- a/packages/backend/routes/song/info.ts +++ b/packages/backend/routes/song/info.ts @@ -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); + ); diff --git a/packages/backend/routes/video/label.ts b/packages/backend/routes/video/label.ts index 94662ce..64965f4 100644 --- a/packages/backend/routes/video/label.ts +++ b/packages/backend/routes/video/label.ts @@ -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>(sql` + return db.execute>(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: { diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index fb1a4b8..7a2d7df 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -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) diff --git a/packages/core/drizzle/type.ts b/packages/core/drizzle/type.ts index dda2cd1..d4b55bb 100644 --- a/packages/core/drizzle/type.ts +++ b/packages/core/drizzle/type.ts @@ -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; export type SensitiveUserFields = "password" | "unqId"; @@ -7,4 +15,5 @@ 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 +export type ProducerType = InferSelectModel; +export type SessionType = InferSelectModel; diff --git a/packages/temp_frontend/.wrangler/tmp/deploy-41aj6n/no-op-worker.js b/packages/temp_frontend/.wrangler/tmp/deploy-41aj6n/no-op-worker.js deleted file mode 100644 index 2713ee4..0000000 --- a/packages/temp_frontend/.wrangler/tmp/deploy-41aj6n/no-op-worker.js +++ /dev/null @@ -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 diff --git a/packages/temp_frontend/.wrangler/tmp/deploy-41aj6n/no-op-worker.js.map b/packages/temp_frontend/.wrangler/tmp/deploy-41aj6n/no-op-worker.js.map deleted file mode 100644 index edb9e6a..0000000 --- a/packages/temp_frontend/.wrangler/tmp/deploy-41aj6n/no-op-worker.js.map +++ /dev/null @@ -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": [] -} diff --git a/packages/temp_frontend/app/routes.ts b/packages/temp_frontend/app/routes.ts index 9fd1de3..c0a713c 100644 --- a/packages/temp_frontend/app/routes.ts +++ b/packages/temp_frontend/app/routes.ts @@ -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; diff --git a/packages/temp_frontend/app/routes/labelling/ControlBar.tsx b/packages/temp_frontend/app/routes/labelling/ControlBar.tsx new file mode 100644 index 0000000..a94a496 --- /dev/null +++ b/packages/temp_frontend/app/routes/labelling/ControlBar.tsx @@ -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 ( +
+
+ + +
+ + +
+ + +
+
+ ); +} diff --git a/packages/temp_frontend/app/routes/labelling/LabelInstructions.tsx b/packages/temp_frontend/app/routes/labelling/LabelInstructions.tsx new file mode 100644 index 0000000..bcf9720 --- /dev/null +++ b/packages/temp_frontend/app/routes/labelling/LabelInstructions.tsx @@ -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 ( +
+
+ 该视频是否包含一首中V歌曲? + + + + + + + 打标说明 + +
+

+ 中V歌曲 + 意味着它是由中文虚拟歌姬演唱,或有歌声合成的人声且歌词中包含中文。歌曲可以是原创,也可以是非原创(如翻唱、翻调等)。 +

+

+ 请根据视频信息判断是否包含中V歌曲。 +
+ 请尽量优先参考屏幕上给出的信息,尤其是文字信息做出判断, + 因为这将是模型唯一能接收的信息。 +
+

+

+ + 特别指示: +
+
+

    +
  • 对于“AI孙燕姿”一类使用RVC等技术生成的人声,请选择“否”。
  • +
  • 对于外文歌姬(如初音未来)演唱的中文歌曲,请选择“是”。
  • +
  • 对于中文歌姬(如洛天依)演唱的外语歌曲,请选择“是”。
  • +
  • 对于自行制作的声库/歌姬(如自制UTAU/DiffSinger声库),只要歌词中有中文,请选择“是”。
  • +
+

+

如果你无法确定,请始终选择“是”。

+
+

键盘快捷键

+
    +
  • 键盘左键区的按键(G、F、3、1、Q、Z 等)表示“否”
  • +
  • 键盘右键区的按键(H、J、P、8、0、L 等)表示“是”
  • +
+
+
+
+
+
+ +
+ ); +} diff --git a/packages/temp_frontend/app/routes/labelling/VideoInfo.tsx b/packages/temp_frontend/app/routes/labelling/VideoInfo.tsx new file mode 100644 index 0000000..74ae874 --- /dev/null +++ b/packages/temp_frontend/app/routes/labelling/VideoInfo.tsx @@ -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(import.meta.env.VITE_API_URL!); + +type VideosResponse = Awaited["get"]>>["data"]; + + +interface VideoInfoProps { + video: Exclude[number]; +} + +export function VideoInfo({ video }: VideoInfoProps) { + const formatDuration = (duration: number) => { + return `${Math.floor(duration / 60)}:${(duration % 60).toString().padStart(2, "0")}`; + }; + + return ( +
+
+ {video.cover_url && ( + + Video cover + + )} +
+ + +
+
+

+ {video.bvid} · av{video.aid} +

+

+ 发布于 {formatDateTime(new Date(video.published_at!))} +
+ 播放:{(video.views ?? 0).toLocaleString()} + 时长:{formatDuration(video.duration || 0)} +

+

+ UP主: + + {video.username} + +

+

+ + 标签 +
+ {video.tags?.replaceAll(",", ",")} +
+

+
+
+
+
+ +
+

简介

+
+					{video.description || "暂无简介"}
+				
+
+
+ ); +} diff --git a/packages/temp_frontend/app/routes/labelling.tsx b/packages/temp_frontend/app/routes/labelling/index.tsx similarity index 50% rename from packages/temp_frontend/app/routes/labelling.tsx rename to packages/temp_frontend/app/routes/labelling/index.tsx index 9f2a566..bf727a2 100644 --- a/packages/temp_frontend/app/routes/labelling.tsx +++ b/packages/temp_frontend/app/routes/labelling/index.tsx @@ -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(import.meta.env.VITE_API_URL!); type VideosResponse = Awaited["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>([]); const [currentIndex, setCurrentIndex] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 ( @@ -155,124 +177,16 @@ export default function Home() { {currentVideo ? ( <> -
-

- 该视频是否包含一首中V歌曲? - -

-
- {currentVideo.cover_url && ( - - Video cover - - )} -
- - -
-
-

- {currentVideo.bvid} · av{currentVideo.aid} -

-

- 发布于 {formatDateTime(new Date(currentVideo.published_at!))}
- 播放:{(currentVideo.views ?? 0).toLocaleString()} -

-

- UP主: - - {currentVideo.username} - -

-

- - 标签 -
- {currentVideo.tags?.replaceAll(",",",")} -
-

-
-
-
-
- -
-

简介

-
-								{currentVideo.description || "暂无简介"}
-							
-
-
- -
-
- - -
- - -
- - -
-
+ + + navigateTo(currentIndex - 1)} + onNext={() => navigateTo(currentIndex + 1)} + onLabel={handleLabel} + /> ) : (
diff --git a/packages/temp_frontend/app/routes/song/[id]/info/index.tsx b/packages/temp_frontend/app/routes/song/[id]/info/index.tsx index 92c912e..f36e337 100644 --- a/packages/temp_frontend/app/routes/song/[id]/info/index.tsx +++ b/packages/temp_frontend/app/routes/song/[id]/info/index.tsx @@ -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);