1
0

add: labelling instructions

This commit is contained in:
alikia2x (寒寒) 2025-12-01 23:24:04 +08:00
parent 3ec949bdc5
commit bb01750816
WARNING! Although there is a key with this ID in the database it does not verify this commit! This commit is SUSPICIOUS.
GPG Key ID: 56209E0CCD8420C6
15 changed files with 501 additions and 325 deletions

View File

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

View File

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

View 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
}
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": []
}

View File

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

View 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>
);
}

View File

@ -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>GF31QZ </li>
<li>HJP80L </li>
</ul>
</div>
</div>
</DialogContent>
</Dialog>
</div>
<Label className="text-secondary-foreground mt-1 leading-5 block">
V歌曲意味着它是由中文虚拟歌姬演唱
</Label>
</div>
);
}

View 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>
);
}

View File

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

View File

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