add: video labelling page
This commit is contained in:
parent
92f3d807ab
commit
423e602da5
19
.github/workflows/main.yml
vendored
19
.github/workflows/main.yml
vendored
@ -1,19 +0,0 @@
|
||||
name: Deploy
|
||||
on: [push]
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy the backend
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.HOST }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
key: ${{ secrets.KEY }}
|
||||
port: ${{ secrets.PORT }}
|
||||
script: |
|
||||
source ~/.bashrc
|
||||
cd /web/cvsa
|
||||
git pull
|
||||
/home/github-actions/.bun/bin/pm2 restart cvsa-be --update-env
|
||||
@ -52,6 +52,8 @@ export const BiliVideoSchema = z.object({
|
||||
coverUrl: z.string().nullable()
|
||||
});
|
||||
|
||||
export type BiliVideoType = z.infer<typeof BiliVideoSchema>;
|
||||
|
||||
export const SongSchema = z.object({
|
||||
duration: z.number().nullable(),
|
||||
name: z.string().nullable(),
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import Elysia from "elysia";
|
||||
import { loginHandler } from "./login";
|
||||
import { logoutHandler } from "./logout";
|
||||
import { getCurrentUserHandler } from "./user";
|
||||
|
||||
export const authHandler = new Elysia().use(loginHandler).use(logoutHandler);
|
||||
export const authHandler = new Elysia()
|
||||
.use(loginHandler)
|
||||
.use(logoutHandler)
|
||||
.use(getCurrentUserHandler);
|
||||
|
||||
30
packages/backend/routes/auth/user.ts
Normal file
30
packages/backend/routes/auth/user.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import requireAuth from "@backend/middlewares/auth";
|
||||
|
||||
export const getCurrentUserHandler = new Elysia().use(requireAuth).get(
|
||||
"/user",
|
||||
async ({ user, status }) => {
|
||||
if (!user) {
|
||||
return status(401, { message: "Unauthorized" });
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
role: user.role
|
||||
};
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
id: t.Integer(),
|
||||
username: t.String(),
|
||||
nickname: t.Union([t.String(), t.Null()]),
|
||||
role: t.String()
|
||||
}),
|
||||
401: t.Object({
|
||||
message: t.String()
|
||||
})
|
||||
}
|
||||
}
|
||||
);
|
||||
95
packages/backend/routes/video/label.ts
Normal file
95
packages/backend/routes/video/label.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { ErrorResponseSchema } from "@backend/src/schema";
|
||||
import z from "zod";
|
||||
import { BiliVideoSchema, BiliVideoType } from "@backend/lib/schema";
|
||||
import requireAuth from "@backend/middlewares/auth";
|
||||
import { sql, eq } from "drizzle-orm";
|
||||
import { bilibiliMetadata, db, videoTypeLabelInInternal } from "@core/drizzle";
|
||||
import { biliIDToAID } from "@backend/lib/bilibiliID";
|
||||
|
||||
const videoSchema = BiliVideoSchema.omit({ publishedAt: true })
|
||||
.omit({ createdAt: true })
|
||||
.omit({ coverUrl: true })
|
||||
.extend({
|
||||
views: z.number(),
|
||||
username: z.string(),
|
||||
uid: z.number(),
|
||||
published_at: z.string(),
|
||||
createdAt: z.string(),
|
||||
cover_url: z.string()
|
||||
});
|
||||
|
||||
export const getUnlabelledVideos = new Elysia({ prefix: "/videos" }).use(requireAuth).get(
|
||||
"/unlabelled",
|
||||
async () => {
|
||||
const videos = await db.execute<z.infer<typeof videoSchema>>(sql`
|
||||
SELECT bm.*, ls.views, bu.username, bu.uid
|
||||
FROM (
|
||||
SELECT *
|
||||
FROM bilibili_metadata
|
||||
TABLESAMPLE SYSTEM (0.1)
|
||||
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
|
||||
`);
|
||||
return videos;
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: z.array(videoSchema),
|
||||
400: ErrorResponseSchema,
|
||||
500: ErrorResponseSchema
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const postVideoLabel = new Elysia({ prefix: "/video" }).use(requireAuth).post(
|
||||
"/:id/label",
|
||||
async ({ params, body, status, user }) => {
|
||||
const id = params.id;
|
||||
const aid = biliIDToAID(id);
|
||||
const label = body.label;
|
||||
|
||||
if (!aid) {
|
||||
return status(400, {
|
||||
code: "MALFORMED_SLOT",
|
||||
message:
|
||||
"We cannot parse the video ID, or we currently do not support this format.",
|
||||
errors: []
|
||||
});
|
||||
}
|
||||
|
||||
const video = await db
|
||||
.select()
|
||||
.from(bilibiliMetadata)
|
||||
.where(eq(bilibiliMetadata.aid, aid))
|
||||
.limit(1);
|
||||
|
||||
if (video.length === 0) {
|
||||
return status(400, {
|
||||
code: "VIDEO_NOT_FOUND",
|
||||
message: "Video not found",
|
||||
errors: []
|
||||
});
|
||||
}
|
||||
|
||||
await db.insert(videoTypeLabelInInternal).values({
|
||||
aid,
|
||||
label,
|
||||
user: user!.unqId
|
||||
});
|
||||
|
||||
return status(201, {
|
||||
message: `Labelled video av${aid} as ${label}`
|
||||
});
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
label: t.Boolean()
|
||||
})
|
||||
}
|
||||
);
|
||||
@ -5,6 +5,7 @@ import { getVideoInfo } from "@core/net/getVideoInfo";
|
||||
import { redis } from "@core/db/redis";
|
||||
import { ErrorResponseSchema } from "@backend/src/schema";
|
||||
import type { VideoInfoData } from "@core/net/bilibili.d.ts";
|
||||
import { BiliAPIVideoMetadataSchema } from "@backend/lib/schema";
|
||||
|
||||
export async function retrieveVideoInfoFromCache(aid: number) {
|
||||
const cacheKey = `cvsa:videoInfo:av${aid}`;
|
||||
@ -59,7 +60,7 @@ export const getVideoMetadataHandler = new Elysia({ prefix: "/video" }).get(
|
||||
|
||||
const cachedData = await retrieveVideoInfoFromCache(aid);
|
||||
if (cachedData) {
|
||||
return cachedData.data;
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
const r = await getVideoInfo(aid, "getVideoInfo");
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { db, videoSnapshot } from "@core/drizzle";
|
||||
import { biliIDToAID, bv2av } from "@backend/lib/bilibiliID";
|
||||
import { biliIDToAID } from "@backend/lib/bilibiliID";
|
||||
import { ErrorResponseSchema } from "@backend/src/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import z from "zod";
|
||||
|
||||
@ -18,6 +18,7 @@ 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";
|
||||
|
||||
const [host, port] = getBindingInfo();
|
||||
logStartup(host, port);
|
||||
@ -67,6 +68,8 @@ const app = new Elysia({
|
||||
.use(addSongHandler)
|
||||
.use(deleteSongHandler)
|
||||
.use(songEtaHandler)
|
||||
.use(getUnlabelledVideos)
|
||||
.use(postVideoLabel)
|
||||
.get(
|
||||
"/song/:id",
|
||||
({ redirect, params }) => {
|
||||
|
||||
@ -7,5 +7,5 @@ export default defineConfig({
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL_MAIN!
|
||||
},
|
||||
schemaFilter: ["public", "credentials"]
|
||||
schemaFilter: ["public", "credentials", "internal"]
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { relations } from "drizzle-orm/relations";
|
||||
import { usersInCredentials, history, songs, relationsProducer, singer, relationSinger } from "./schema";
|
||||
import { usersInCredentials, history, songs, relationsProducer, singer, relationSinger, videoTypeLabelInInternal } from "./schema";
|
||||
|
||||
export const historyRelations = relations(history, ({one}) => ({
|
||||
usersInCredential: one(usersInCredentials, {
|
||||
@ -10,6 +10,7 @@ export const historyRelations = relations(history, ({one}) => ({
|
||||
|
||||
export const usersInCredentialsRelations = relations(usersInCredentials, ({many}) => ({
|
||||
histories: many(history),
|
||||
videoTypeLabelInInternals: many(videoTypeLabelInInternal),
|
||||
}));
|
||||
|
||||
export const relationsProducerRelations = relations(relationsProducer, ({one}) => ({
|
||||
@ -37,4 +38,11 @@ export const relationSingerRelations = relations(relationSinger, ({one}) => ({
|
||||
|
||||
export const singerRelations = relations(singer, ({many}) => ({
|
||||
relationSingers: many(relationSinger),
|
||||
}));
|
||||
|
||||
export const videoTypeLabelInInternalRelations = relations(videoTypeLabelInInternal, ({one}) => ({
|
||||
usersInCredential: one(usersInCredentials, {
|
||||
fields: [videoTypeLabelInInternal.user],
|
||||
references: [usersInCredentials.unqId]
|
||||
}),
|
||||
}));
|
||||
@ -2,8 +2,10 @@ import { pgTable, pgSchema, uniqueIndex, check, integer, text, timestamp, foreig
|
||||
import { sql } from "drizzle-orm"
|
||||
|
||||
export const credentials = pgSchema("credentials");
|
||||
export const internal = pgSchema("internal");
|
||||
export const userRoleInCredentials = credentials.enum("user_role", ['ADMIN', 'USER', 'OWNER'])
|
||||
|
||||
export const usersIdSeqInCredentials = credentials.sequence("users_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "2147483647", cache: "1", cycle: false })
|
||||
export const allDataIdSeq = pgSequence("all_data_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "2147483647", cache: "1", cycle: false })
|
||||
export const labelingResultIdSeq = pgSequence("labeling_result_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "2147483647", cache: "1", cycle: false })
|
||||
export const relationSingerIdSeq = pgSequence("relation_singer_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "2147483647", cache: "1", cycle: false })
|
||||
@ -11,7 +13,6 @@ export const relationsProducerIdSeq = pgSequence("relations_producer_id_seq", {
|
||||
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 usersIdSeqInCredentials = credentials.sequence("users_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "2147483647", cache: "1", cycle: false })
|
||||
|
||||
export const usersInCredentials = credentials.table("users", {
|
||||
id: integer().default(sql`nextval('credentials.users_id_seq'::regclass)`).notNull(),
|
||||
@ -330,3 +331,21 @@ export const bilibiliUser = pgTable("bilibili_user", {
|
||||
check("bilibili_user_created_at_not_null", sql`NOT NULL created_at`),
|
||||
check("bilibili_user_updated_at_not_null", sql`NOT NULL updated_at`),
|
||||
]);
|
||||
|
||||
export const videoTypeLabelInInternal = internal.table("video_type_label", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
aid: bigint({ mode: "number" }).notNull(),
|
||||
label: boolean().notNull(),
|
||||
user: text().default('i3wW8JdZ9sT3ASkk').notNull(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.user],
|
||||
foreignColumns: [usersInCredentials.unqId],
|
||||
name: "fkey_video_type_label_user"
|
||||
}),
|
||||
check("video_type_label_id_not_null", sql`NOT NULL id`),
|
||||
check("video_type_label_aid_not_null", sql`NOT NULL aid`),
|
||||
check("video_type_label_label_not_null", sql`NOT NULL label`),
|
||||
check("video_type_label_user_not_null", sql`NOT NULL "user"`),
|
||||
]);
|
||||
|
||||
@ -80,7 +80,7 @@ input[type="number"] {
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.92 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--secondary-foreground: oklch(0.305 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.95 0 0);
|
||||
@ -115,7 +115,7 @@ input[type="number"] {
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--secondary-foreground: oklch(0.85 0 0);
|
||||
--muted: oklch(0.188 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { toast } from "sonner";
|
||||
import { Search } from "./Search";
|
||||
|
||||
export function Layout({ children }: { children?: React.ReactNode }) {
|
||||
export function LayoutWithoutSearch({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="w-screen min-h-screen relative left-0 top-0 flex justify-center">
|
||||
<main className="w-full max-sm:mx-3 pt-14 sm:w-xl xl:w-2xl 2xl:w-3xl mb-20">
|
||||
<main className="w-full max-sm:mx-3 sm:px-10 pt-14 xl:pt-8 md:w-3xl xl:w-4xl mb-20">
|
||||
<div className="flex items-center justify-between">
|
||||
<a href="/">
|
||||
<h1 className="text-3xl mb-5">中V档案馆</h1>
|
||||
@ -13,13 +13,22 @@ export function Layout({ children }: { children?: React.ReactNode }) {
|
||||
<LoginOrLogout />
|
||||
</div>
|
||||
</div>
|
||||
<Search />
|
||||
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Layout({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<LayoutWithoutSearch>
|
||||
<Search />
|
||||
{children}
|
||||
</LayoutWithoutSearch>
|
||||
);
|
||||
}
|
||||
|
||||
const LoginOrLogout = () => {
|
||||
const session = localStorage.getItem("sessionID");
|
||||
if (session) {
|
||||
@ -37,22 +46,3 @@ const LoginOrLogout = () => {
|
||||
return <a href="/login">登录</a>;
|
||||
}
|
||||
};
|
||||
|
||||
export function LayoutWithoutSearch({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="w-screen min-h-screen relative left-0 top-0 flex justify-center">
|
||||
<main className="w-full max-sm:mx-3 pt-14 sm:w-xl xl:w-2xl 2xl:w-3xl mb-20">
|
||||
<div className="flex items-center justify-between">
|
||||
<a href="/">
|
||||
<h1 className="text-3xl mb-5">中V档案馆</h1>
|
||||
</a>
|
||||
<div className="h-8">
|
||||
<LoginOrLogout />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
<Toaster />
|
||||
<Toaster position="top-center" />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@ -8,4 +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"),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
@ -102,7 +102,7 @@ export const MilestoneVideos: React.FC = () => {
|
||||
|
||||
if (isLoading && milestoneData.length === 0) {
|
||||
return (
|
||||
<ScrollArea className="h-140 w-full">
|
||||
<ScrollArea className="h-140 xl:h-180 w-full">
|
||||
<div className="h-[0.1px]"></div>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
@ -137,7 +137,7 @@ export const MilestoneVideos: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ScrollArea className="h-140 w-full" ref={scrollContainer} onScroll={handleScroll}>
|
||||
<ScrollArea className="h-140 xl:h-180 w-full" ref={scrollContainer} onScroll={handleScroll}>
|
||||
{milestoneData.map((video) => (
|
||||
<MilestoneVideoCard
|
||||
key={video.bilibili_metadata.aid}
|
||||
|
||||
@ -3,79 +3,117 @@ import { Progress } from "@/components/ui/progress";
|
||||
import { formatDateTime } from "@/components/SearchResults";
|
||||
import { addHoursToNow, formatHours } from "../song/[id]/info";
|
||||
import { milestoneConfig, type CloseMilestoneInfo, type MilestoneType } from "./Milestone";
|
||||
import { DAY, HOUR, MINUTE, SECOND } from "@core/lib";
|
||||
|
||||
function timeAgo(timeStamp: Date | number, now: Date | number = Date.now()): string {
|
||||
const pastTime = typeof timeStamp === "number" ? timeStamp : timeStamp.getTime();
|
||||
const currentTime = typeof now === "number" ? now : now.getTime();
|
||||
|
||||
const diffMs = Math.abs(currentTime - pastTime);
|
||||
|
||||
if (diffMs < MINUTE) {
|
||||
const seconds = Math.floor(diffMs / SECOND);
|
||||
return `${seconds} 秒`;
|
||||
}
|
||||
|
||||
if (diffMs < HOUR) {
|
||||
const minutes = Math.floor(diffMs / MINUTE);
|
||||
if (diffMs % MINUTE === 0) {
|
||||
return `${minutes} 分钟`;
|
||||
}
|
||||
return `${minutes} 分钟`;
|
||||
}
|
||||
|
||||
if (diffMs < DAY) {
|
||||
const hours = Math.floor(diffMs / HOUR);
|
||||
const minutes = Math.floor((diffMs % HOUR) / MINUTE);
|
||||
return `${hours} 时 ${minutes} 分`;
|
||||
}
|
||||
|
||||
if (diffMs >= DAY) {
|
||||
const days = Math.floor(diffMs / DAY);
|
||||
const hours = Math.floor((diffMs % DAY) / HOUR);
|
||||
return `${days} 天 ${hours} 时`;
|
||||
}
|
||||
|
||||
return "刚刚";
|
||||
}
|
||||
|
||||
export const MilestoneVideoCard = ({
|
||||
video,
|
||||
milestoneType,
|
||||
video,
|
||||
milestoneType,
|
||||
}: {
|
||||
video: NonNullable<CloseMilestoneInfo>[number];
|
||||
milestoneType: MilestoneType;
|
||||
video: NonNullable<CloseMilestoneInfo>[number];
|
||||
milestoneType: MilestoneType;
|
||||
}) => {
|
||||
const config = milestoneConfig[milestoneType];
|
||||
const remainingViews = config.target - video.eta.currentViews;
|
||||
const progressPercentage = (video.eta.currentViews / config.target) * 100;
|
||||
const config = milestoneConfig[milestoneType];
|
||||
const remainingViews = config.target - video.eta.currentViews;
|
||||
const progressPercentage = (video.eta.currentViews / config.target) * 100;
|
||||
|
||||
return (
|
||||
<Card className="px-3 max-md:py-3 md:px-4 my-4 gap-0">
|
||||
<div className="w-full flex items-start space-x-4 mb-4">
|
||||
{video.bilibili_metadata.coverUrl && (
|
||||
<img
|
||||
src={video.bilibili_metadata.coverUrl}
|
||||
alt="视频封面"
|
||||
className="h-25 w-40 rounded-sm object-cover shrink-0"
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col w-full justify-between">
|
||||
<h3 className="text-sm sm:text-lg font-medium line-clamp-2 text-wrap mb-2">
|
||||
<a href={`/song/av${video.bilibili_metadata.aid}/info`} className="hover:underline">
|
||||
{video.bilibili_metadata.title}
|
||||
</a>
|
||||
</h3>
|
||||
return (
|
||||
<Card className="px-3 max-md:py-3 md:px-4 my-4 gap-0">
|
||||
<div className="w-full flex items-start space-x-4 mb-4">
|
||||
{video.bilibili_metadata.coverUrl && (
|
||||
<img
|
||||
src={video.bilibili_metadata.coverUrl}
|
||||
alt="视频封面"
|
||||
className="h-25 w-40 rounded-sm object-cover shrink-0"
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col w-full justify-between">
|
||||
<h3 className="text-sm sm:text-lg font-medium line-clamp-2 text-wrap mb-2">
|
||||
<a href={`/song/av${video.bilibili_metadata.aid}/info`} className="hover:underline">
|
||||
{video.bilibili_metadata.title}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>当前播放: {video.eta.currentViews.toLocaleString()}</span>
|
||||
<span>目标: {config.target.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>当前播放: {video.eta.currentViews.toLocaleString()}</span>
|
||||
<span>目标: {config.target.toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<Progress value={progressPercentage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-5 text-xs text-muted-foreground mb-2">
|
||||
<div>
|
||||
<p>剩余播放: {remainingViews.toLocaleString()}</p>
|
||||
<p>预计达成: {formatHours(video.eta.eta)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>播放速度: {Math.round(video.eta.speed)}/小时</p>
|
||||
<p>达成时间: {addHoursToNow(video.eta.eta)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={progressPercentage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-5 text-xs text-muted-foreground mb-2">
|
||||
<div>
|
||||
<p>剩余播放: {remainingViews.toLocaleString()}</p>
|
||||
<p>预计达成: {formatHours(video.eta.eta)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>播放速度: {Math.round(video.eta.speed)}/小时</p>
|
||||
<p>达成时间: {addHoursToNow(video.eta.eta)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
{video.bilibili_metadata.publishedAt && (
|
||||
<span className="stat-num">
|
||||
发布于 {formatDateTime(new Date(video.bilibili_metadata.publishedAt))}
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href={`https://www.bilibili.com/video/av${video.bilibili_metadata.aid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-pink-400 text-xs hover:underline"
|
||||
>
|
||||
观看视频
|
||||
</a>
|
||||
<a
|
||||
href={`/song/av${video.bilibili_metadata.aid}/info`}
|
||||
className="text-xs text-secondary-foreground hover:underline"
|
||||
>
|
||||
查看详情
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
{video.bilibili_metadata.publishedAt && (
|
||||
<span className="stat-num">
|
||||
发布于 {formatDateTime(new Date(video.bilibili_metadata.publishedAt))}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
数据更新于 {timeAgo(new Date(video.eta.updatedAt))}前
|
||||
</span>
|
||||
<a
|
||||
href={`https://www.bilibili.com/video/av${video.bilibili_metadata.aid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-pink-400 text-xs hover:underline"
|
||||
>
|
||||
观看视频
|
||||
</a>
|
||||
<a
|
||||
href={`/song/av${video.bilibili_metadata.aid}/info`}
|
||||
className="text-xs text-secondary-foreground hover:underline"
|
||||
>
|
||||
查看详情
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@ -14,16 +14,24 @@ export default function Home() {
|
||||
return (
|
||||
<Layout>
|
||||
<h2 className="text-2xl font-medium mt-8 mb-4">小工具</h2>
|
||||
<div className="flex max-sm:flex-col sm:items-center gap-7 mb-8">
|
||||
<a href="/time-calculator">
|
||||
<Button>时间计算器</Button>
|
||||
</a>
|
||||
<div className="flex max-sm:flex-col sm:items-center justify-between gap-7 mb-8">
|
||||
<div className="flex gap-3">
|
||||
<a href="/time-calculator">
|
||||
<Button>时间计算器</Button>
|
||||
</a>
|
||||
<a href="/labelling">
|
||||
<Button>歌曲打标</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex sm:w-96 gap-3">
|
||||
<Input placeholder="输入 BV 号或 av 号" value={input} onChange={(e) => setInput(e.target.value)} />
|
||||
<a href={`/song/${input}/add`}>
|
||||
<Button>收录视频</Button>
|
||||
</a>
|
||||
<a href={`/video/${input}/info`}>
|
||||
<Button>视频信息</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
284
packages/temp_frontend/app/routes/labelling.tsx
Normal file
284
packages/temp_frontend/app/routes/labelling.tsx
Normal file
@ -0,0 +1,284 @@
|
||||
import { Layout } from "@/components/Layout";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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";
|
||||
|
||||
// @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"];
|
||||
|
||||
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 fetchVideos = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data, error } = await app.videos.unlabelled.get({
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("sessionID") || ""}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data && data.length > 0) {
|
||||
setVideos((prev) => [...prev, ...data]);
|
||||
setHasMore(data.length === 20);
|
||||
} else {
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setError({ status: 500, value: { message: "网络错误" } });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMoreIfNeeded = useCallback(() => {
|
||||
if (hasMore && videos.length - currentIndex <= 6) {
|
||||
fetchVideos();
|
||||
}
|
||||
}, [hasMore, videos.length, currentIndex, fetchVideos]);
|
||||
|
||||
const labelVideo = async (videoId: string, label: boolean) => {
|
||||
const videoKey = `${videoId}-${label}`;
|
||||
|
||||
const maxRetries = 5;
|
||||
let retries = 0;
|
||||
|
||||
const attemptLabel = async (): Promise<boolean> => {
|
||||
try {
|
||||
const { error } = await app.video({ id: videoId }).label.post(
|
||||
{ label },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("sessionID") || ""}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
toast.success(`已标记视频 ${label ? "是" : "否"}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
retries++;
|
||||
if (retries < maxRetries) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * retries));
|
||||
return attemptLabel();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const success = await attemptLabel();
|
||||
|
||||
if (!success) {
|
||||
toast.error(`标记失败,请稍后重试`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLabel = async (label: boolean) => {
|
||||
const currentVideo = videos[currentIndex];
|
||||
if (!currentVideo) return;
|
||||
|
||||
labelVideo(currentVideo.bvid!, label);
|
||||
if (currentIndex < videos.length - 1) {
|
||||
setCurrentIndex((prev) => prev + 1);
|
||||
loadMoreIfNeeded();
|
||||
} else {
|
||||
fetchVideos();
|
||||
if (videos.length > currentIndex + 1) {
|
||||
setCurrentIndex((prev) => prev + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const navigateTo = (index: number) => {
|
||||
if (index >= 0 && index < videos.length) {
|
||||
setCurrentIndex(index);
|
||||
loadMoreIfNeeded();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
}, [fetchVideos]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMoreIfNeeded();
|
||||
}, [currentIndex, loadMoreIfNeeded]);
|
||||
|
||||
if (loading && videos.length === 0) {
|
||||
return (
|
||||
<Layout>
|
||||
<Title title="视频打标工具" />
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="mt-6 w-full aspect-video rounded-lg" />
|
||||
<div className="mt-6 flex justify-between items-baseline">
|
||||
<Skeleton className="w-60 h-10 rounded-sm" />
|
||||
<Skeleton className="w-25 h-10 rounded-sm" />
|
||||
</div>
|
||||
<Skeleton className="w-full h-20 rounded-lg" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && videos.length === 0) {
|
||||
return <Error error={error} />;
|
||||
}
|
||||
|
||||
const currentVideo = videos[currentIndex];
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Title title="视频打标工具" />
|
||||
|
||||
{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 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>
|
||||
</p>
|
||||
<p>
|
||||
<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>
|
||||
</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>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-lg">没有更多视频需要打标</p>
|
||||
<Button onClick={fetchVideos} className="mt-4" disabled={loading}>
|
||||
{loading ? "加载中..." : "重新加载"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@ -3,14 +3,10 @@ import { treaty } from "@elysiajs/eden";
|
||||
import type { App } from "@backend/src";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { Title } from "@/components/Title";
|
||||
import { toast } from "sonner";
|
||||
import { Error } from "@/components/Error";
|
||||
import { Layout } from "@/components/Layout";
|
||||
import { formatDateTime } from "@/components/SearchResults";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { av2bv } from "@backend/lib/bilibiliID";
|
||||
|
||||
// @ts-ignore idk
|
||||
const app = treaty<App>(import.meta.env.VITE_API_URL!);
|
||||
@ -109,6 +105,7 @@ export default function VideoInfo({ loaderData }: Route.ComponentProps) {
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p>UP主:<a className="underline" href={`https://space.bilibili.com/${videoInfo!.owner.mid}`}>{videoInfo!.owner.name}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user