diff --git a/bun.lock b/bun.lock index b0cc5a9..aa7f3f2 100644 --- a/bun.lock +++ b/bun.lock @@ -201,8 +201,9 @@ "@elysiajs/eden": "^1.4.1", "@nivo/core": "^0.99.0", "@nivo/line": "^0.99.0", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@react-router/node": "^7.7.1", "@react-router/serve": "^7.7.1", @@ -213,6 +214,7 @@ "next-themes": "^0.4.6", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-if": "^4.1.6", "react-router": "^7.7.1", "recharts": "^3.2.1", "sonner": "^2.0.7", @@ -761,6 +763,8 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], @@ -787,7 +791,7 @@ "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], - "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], @@ -3019,6 +3023,14 @@ "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@react-router/dev/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "@react-router/serve/express": ["express@4.21.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "0.19.0", "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA=="], diff --git a/packages/crawler/db/snapshotSchedule.ts b/packages/crawler/db/snapshotSchedule.ts index e8e4e99..7205f5f 100644 --- a/packages/crawler/db/snapshotSchedule.ts +++ b/packages/crawler/db/snapshotSchedule.ts @@ -99,13 +99,43 @@ interface Snapshot { views: number; } -export async function findClosestSnapshot(sql: Psql, aid: number, targetTime: Date): Promise { +export async function findClosestSnapshot( + sql: Psql, + aid: number, + targetTime: Date +): Promise { const result = await sql<{ created_at: string; views: number }[]>` - SELECT created_at, views - FROM video_snapshot - WHERE aid = ${aid} - ORDER BY ABS(EXTRACT(EPOCH FROM (created_at - ${targetTime.toISOString()}::timestamptz))) - LIMIT 1 + WITH target_time AS NOT MATERIALIZED ( + SELECT ${targetTime.toISOString()}::timestamptz AS t + ), + before AS NOT MATERIALIZED ( + SELECT + created_at, + views, + EXTRACT(EPOCH FROM (t - created_at)) AS distance + FROM video_snapshot, target_time + WHERE aid = ${aid} + AND created_at <= t + ORDER BY created_at DESC + LIMIT 1 + ), + after AS NOT MATERIALIZED ( + SELECT + created_at, + views, + EXTRACT(EPOCH FROM (created_at - t)) AS distance + FROM video_snapshot, target_time + WHERE aid = ${aid} + AND created_at >= t + ORDER BY created_at ASC + LIMIT 1 + ) + SELECT created_at, views + FROM ( + SELECT *, ROW_NUMBER() OVER (ORDER BY distance) AS rn + FROM (SELECT * FROM before UNION ALL SELECT * FROM after) AS combined + ) AS ranked + WHERE rn = 1; `; if (result.length === 0) return null; const row = result[0]; @@ -115,7 +145,11 @@ export async function findClosestSnapshot(sql: Psql, aid: number, targetTime: Da }; } -export async function findSnapshotBefore(sql: Psql, aid: number, targetTime: Date): Promise { +export async function findSnapshotBefore( + sql: Psql, + aid: number, + targetTime: Date +): Promise { const result = await sql<{ created_at: string; views: number }[]>` SELECT created_at, views FROM video_snapshot @@ -133,12 +167,15 @@ export async function findSnapshotBefore(sql: Psql, aid: number, targetTime: Dat } export async function hasAtLeast2Snapshots(sql: Psql, aid: number) { - const res = await sql<{ count: number }[]>` - SELECT COUNT(*) - FROM video_snapshot + const res = await sql<{ exists: boolean }[]>` + SELECT EXISTS ( + SELECT 1 + FROM video_snapshot WHERE aid = ${aid} + LIMIT 2 + ) AS exists `; - return res[0].count >= 2; + return res[0].exists; } export async function getLatestSnapshot(sql: Psql, aid: number): Promise { @@ -192,7 +229,9 @@ export async function scheduleSnapshot( if (!latestActiveSchedule) { return; } - const latestScheduleStartedAt = new Date(parseTimestampFromPsql(latestActiveSchedule.started_at)); + const latestScheduleStartedAt = new Date( + parseTimestampFromPsql(latestActiveSchedule.started_at) + ); if (latestScheduleStartedAt > adjustedTime) { await sql` UPDATE snapshot_schedule @@ -211,7 +250,11 @@ export async function scheduleSnapshot( if (type !== "milestone" && type !== "new" && adjustTime) { adjustedTime = await adjustSnapshotTime(new Date(targetTime), 3000, redis); } - logger.log(`Scheduled snapshot for ${aid} at ${adjustedTime.toISOString()}`, "mq", "fn:scheduleSnapshot"); + logger.log( + `Scheduled snapshot for ${aid} at ${adjustedTime.toISOString()}`, + "mq", + "fn:scheduleSnapshot" + ); return sql` INSERT INTO snapshot_schedule (aid, type, started_at) diff --git a/packages/crawler/mq/exec/directSnapshot.ts b/packages/crawler/mq/exec/directSnapshot.ts new file mode 100644 index 0000000..c288591 --- /dev/null +++ b/packages/crawler/mq/exec/directSnapshot.ts @@ -0,0 +1,17 @@ +import { Job } from "bullmq"; +import { insertVideoSnapshot } from "mq/task/getVideoStats"; +import { sql } from "@core/db/dbNew"; +import { lockManager } from "@core/mq/lockManager"; + +export const directSnapshotWorker = async (job: Job): Promise => { + const lock = await lockManager.isLocked(`directSnapshot-${job.data.aid}`); + if (lock) { + return; + } + const aid = job.data.aid; + if (!aid) { + throw new Error("aid does not exists"); + } + await insertVideoSnapshot(sql, aid, "snapshotMilestoneVideo"); + await lockManager.acquireLock(`directSnapshot-${job.data.aid}`, 75); +}; diff --git a/packages/crawler/mq/exec/getVideoInfo.ts b/packages/crawler/mq/exec/getVideoInfo.ts index 4ff50d3..403c7df 100644 --- a/packages/crawler/mq/exec/getVideoInfo.ts +++ b/packages/crawler/mq/exec/getVideoInfo.ts @@ -5,9 +5,10 @@ import { sql } from "@core/db/dbNew"; export const getVideoInfoWorker = async (job: Job): Promise => { const aid = job.data.aid; + const insert = job.data.insertSongs || false; if (!aid) { logger.warn("aid does not exists", "mq", "job:getVideoInfo"); return; } - await insertVideoInfo(sql, aid); + await insertVideoInfo(sql, aid, insert); }; diff --git a/packages/crawler/mq/task/collectSongs.ts b/packages/crawler/mq/task/collectSongs.ts index b187af7..e16f575 100644 --- a/packages/crawler/mq/task/collectSongs.ts +++ b/packages/crawler/mq/task/collectSongs.ts @@ -17,6 +17,21 @@ export async function collectSongs() { } export async function insertIntoSongs(sql: Psql, aid: number) { + const songExistsAndDeleted = await sql` + SELECT EXISTS ( + SELECT 1 + FROM songs + WHERE aid = ${aid} + AND deleted = true + ); + `; + if (songExistsAndDeleted[0].exists) { + await sql` + UPDATE songs + SET deleted = false + WHERE aid = ${aid} + `; + } await sql` INSERT INTO songs (aid, published_at, duration, image, producer) VALUES ( diff --git a/packages/crawler/mq/task/getVideoDetails.ts b/packages/crawler/mq/task/getVideoDetails.ts index 5fb0b65..411482b 100644 --- a/packages/crawler/mq/task/getVideoDetails.ts +++ b/packages/crawler/mq/task/getVideoDetails.ts @@ -2,13 +2,18 @@ import { getVideoDetails } from "net/getVideoDetails"; import { formatTimestampToPsql } from "utils/formatTimestampToPostgre"; import logger from "@core/log"; import { ClassifyVideoQueue } from "mq/index"; -import { userExistsInBiliUsers, videoExistsInAllData } from "../../db/bilibili_metadata"; +import { userExistsInBiliUsers, videoExistsInAllData } from "db/bilibili_metadata"; import { HOUR, SECOND } from "@core/lib"; import type { Psql } from "@core/db/psql.d"; +import { insertIntoSongs } from "./collectSongs"; -export async function insertVideoInfo(sql: Psql, aid: number) { +export async function insertVideoInfo(sql: Psql, aid: number, insertSongs = false) { const videoExists = await videoExistsInAllData(sql, aid); - if (videoExists) { + if (videoExists && !insertSongs) { + return; + } + if (videoExists && insertSongs) { + await insertIntoSongs(sql, aid); return; } const data = await getVideoDetails(aid); @@ -58,5 +63,10 @@ export async function insertVideoInfo(sql: Psql, aid: number) { `; logger.log(`Inserted video metadata for aid: ${aid}`, "mq"); - await ClassifyVideoQueue.add("classifyVideo", { aid }); + + if (!insertSongs) { + await ClassifyVideoQueue.add("classifyVideo", { aid }); + return; + } + await insertIntoSongs(sql, aid); } diff --git a/packages/crawler/src/worker.ts b/packages/crawler/src/worker.ts index 19a10eb..110e4ce 100644 --- a/packages/crawler/src/worker.ts +++ b/packages/crawler/src/worker.ts @@ -17,6 +17,7 @@ import logger from "@core/log"; import { lockManager } from "@core/mq/lockManager"; import { WorkerError } from "mq/schema"; import { collectQueueMetrics } from "mq/exec/collectQueueMetrics"; +import { directSnapshotWorker } from "mq/exec/directSnapshot"; const releaseLockForJob = async (name: string) => { await lockManager.releaseLock(name); @@ -77,6 +78,8 @@ const snapshotWorker = new Worker( "snapshot", async (job: Job) => { switch (job.name) { + case "directSnapshot": + return await directSnapshotWorker(job); case "snapshotVideo": return await snapshotVideoWorker(job); case "snapshotTick": diff --git a/packages/elysia/lib/mq.ts b/packages/elysia/lib/mq.ts index 02a495c..4c8256d 100644 --- a/packages/elysia/lib/mq.ts +++ b/packages/elysia/lib/mq.ts @@ -4,3 +4,7 @@ import { redis } from "@core/db/redis"; export const LatestVideosQueue = new Queue("latestVideos", { connection: redis as ConnectionOptions }); + +export const SnapshotQueue = new Queue("snapshot", { + connection: redis as ConnectionOptions +}); \ No newline at end of file diff --git a/packages/elysia/routes/song/add.ts b/packages/elysia/routes/song/add.ts index 8ee2f42..5825cd2 100644 --- a/packages/elysia/routes/song/add.ts +++ b/packages/elysia/routes/song/add.ts @@ -1,41 +1,79 @@ import { Elysia, t } from "elysia"; -import { dbMain } from "@core/drizzle"; -import { relations, singer, songs } from "@core/drizzle/main/schema"; -import { eq, and } from "drizzle-orm"; -import { biliIDToAID, bv2av } from "@elysia/lib/bilibiliID"; +import { biliIDToAID } from "@elysia/lib/bilibiliID"; import { requireAuth } from "@elysia/middlewares/auth"; import { LatestVideosQueue } from "@elysia/lib/mq"; -const addSongHandler = new Elysia() +export const addSongHandler = new Elysia() .use(requireAuth) .post( - "/song/bilibili", - async ({ params, status, body, user }) => { + "/song/import/bilibili", + async ({ body, status }) => { const id = body.id; const aid = biliIDToAID(id); - const job = LatestVideosQueue.add("getVideoInfo", { - aid: aid - }) - return { - message: "Successfully updated song info.", - }; + const job = await LatestVideosQueue.add("getVideoInfo", { + aid: aid, + insertSongs: true + }); + if (!job.id) { + return status(500, { + message: "Failed to enqueue job to add song." + }); + } + return status(201, { + message: "Successfully created import session.", + jobID: job.id + }); }, { response: { - 200: t.Object({ + 201: t.Object({ message: t.String(), - updated: t.Any() + jobID: t.String() }), 401: t.Object({ message: t.String() }), - 404: t.Object({ - message: t.String(), - code: t.String() + 500: t.Object({ + message: t.String() }) }, body: t.Object({ id: t.String() }) } + ) + .get( + "/song/import/:id/status", + async ({ params, status }) => { + const jobID = params.id; + const job = await LatestVideosQueue.getJob(jobID); + if (!job) { + return status(404, { + message: "Job not found." + }); + } + const state = await job.getState(); + return { + id: job.id!, + state, + result: job.returnvalue, + failedReason: job.failedReason + }; + }, + { + response: { + 200: t.Object({ + id: t.String(), + state: t.String(), + result: t.Optional(t.Any()), + failedReason: t.Optional(t.String()) + }), + 404: t.Object({ + message: t.String() + }) + }, + params: t.Object({ + id: t.String() + }) + } ); diff --git a/packages/elysia/routes/song/delete.ts b/packages/elysia/routes/song/delete.ts new file mode 100644 index 0000000..dd3499a --- /dev/null +++ b/packages/elysia/routes/song/delete.ts @@ -0,0 +1,32 @@ +import { Elysia, t } from "elysia"; +import { requireAuth } from "@elysia/middlewares/auth"; +import { db } from "@core/drizzle"; +import { songs } from "@core/drizzle/main/schema"; +import { eq } from "drizzle-orm"; + +export const deleteSongHandler = new Elysia({ prefix: "/song" }).use(requireAuth).delete( + "/:id", + async ({ params }) => { + const id = Number(params.id); + await db.update(songs).set({ deleted: true }).where(eq(songs.id, id)); + return { + message: `Successfully deleted song ${id}.` + }; + }, + { + response: { + 200: t.Object({ + message: t.String() + }), + 401: t.Object({ + message: t.String() + }), + 500: t.Object({ + message: t.String() + }) + }, + params: t.Object({ + id: t.String() + }) + } +); diff --git a/packages/elysia/routes/song/info.ts b/packages/elysia/routes/song/info.ts index 79f5519..23326e8 100644 --- a/packages/elysia/routes/song/info.ts +++ b/packages/elysia/routes/song/info.ts @@ -38,7 +38,11 @@ async function getSongID(id: string) { } async function getSongInfo(id: number) { - const songInfo = await dbMain.select().from(songs).where(eq(songs.id, id)).limit(1); + const songInfo = await dbMain + .select() + .from(songs) + .where(and(eq(songs.id, id), eq(songs.deleted, false))) + .limit(1); return songInfo[0]; } @@ -79,23 +83,27 @@ const songInfoGetHandler = new Elysia({ prefix: "/song" }).get( } const singers = await getSingers(info.id); return { + id: info.id, name: info.name, aid: info.aid, producer: info.producer, duration: info.duration, singers: singers, - cover: info.image || undefined + cover: info.image || undefined, + publishedAt: info.publishedAt }; }, { 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()]), singers: t.Array(t.String()), - cover: t.Optional(t.String()) + cover: t.Optional(t.String()), + publishedAt: t.Union([t.String(), t.Null()]), }), 404: t.Object({ code: t.String(), @@ -103,7 +111,7 @@ const songInfoGetHandler = new Elysia({ prefix: "/song" }).get( }) }, headers: t.Object({ - "Authorization": t.Optional(t.String()) + Authorization: t.Optional(t.String()) }), detail: { summary: "Get information of a song", @@ -117,62 +125,60 @@ const songInfoGetHandler = new Elysia({ prefix: "/song" }).get( } ); -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) { - 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 dbMain.update(songs).set({ name: body.name }).where(eq(songs.id, songID)); - } - if (body.producer) { - await dbMain - .update(songs) - .set({ producer: body.producer }) - .where(eq(songs.id, songID)) - .returning(); - } - const updatedData = await dbMain.select().from(songs).where(eq(songs.id, songID)); - 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() - }) - }, - body: t.Object({ - name: t.Optional(t.String()), - producer: t.Optional(t.String()) - }) +const songInfoUpdateHandler = new Elysia({ prefix: "/song" }).use(requireAuth).patch( + "/:id/info", + async ({ params, status, body }) => { + 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." + }); + } + + if (body.name) { + await dbMain.update(songs).set({ name: body.name }).where(eq(songs.id, songID)); + } + if (body.producer) { + await dbMain + .update(songs) + .set({ producer: body.producer }) + .where(eq(songs.id, songID)) + .returning(); + } + const updatedData = await dbMain.select().from(songs).where(eq(songs.id, songID)); + 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() + }) + }, + body: t.Object({ + name: t.Optional(t.String()), + producer: t.Optional(t.String()) + }) + } +); export const songInfoHandler = new Elysia().use(songInfoGetHandler).use(songInfoUpdateHandler); diff --git a/packages/elysia/routes/video/snapshots.ts b/packages/elysia/routes/video/snapshots.ts index 22c5f9a..77c98ea 100644 --- a/packages/elysia/routes/video/snapshots.ts +++ b/packages/elysia/routes/video/snapshots.ts @@ -5,6 +5,7 @@ import { bv2av } from "@elysia/lib/bilibiliID"; import { ErrorResponseSchema } from "@elysia/src/schema"; import { eq, desc } from "drizzle-orm"; import z from "zod"; +import { SnapshotQueue } from "@elysia/lib/mq"; export const getVideoSnapshotsHandler = new Elysia({ prefix: "/video" }).get( "/:id/snapshots", @@ -31,6 +32,12 @@ export const getVideoSnapshotsHandler = new Elysia({ prefix: "/video" }).get( .where(eq(videoSnapshot.aid, aid)) .orderBy(desc(videoSnapshot.createdAt)); + if (data.length === 0) { + await SnapshotQueue.add("directSnapshot", { + aid + }); + } + return data; }, { diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 3f5babe..0af735e 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -11,6 +11,8 @@ import { authHandler } from "@elysia/routes/auth"; import { onAfterHandler } from "./onAfterHandle"; import { searchHandler } from "@elysia/routes/search"; import { getVideoSnapshotsHandler } from "@elysia/routes/video/snapshots"; +import { addSongHandler } from "@elysia/routes/song/add"; +import { deleteSongHandler } from "@elysia/routes/song/delete"; const [host, port] = getBindingInfo(); logStartup(host, port); @@ -20,14 +22,13 @@ const app = new Elysia({ hostname: host } }) - .onError(({ code, status }) => { + .onError(({ code, status, error }) => { if (code === "NOT_FOUND") return status(404, { message: "The requested resource was not found." }); - return status(500, { - message: "An internal server error occurred." - }); + if (code === "VALIDATION") return error.detail(error.message); + return error; }) .use(onAfterHandler) .use(cors()) @@ -40,6 +41,8 @@ const app = new Elysia({ .use(closeMileStoneHandler) .use(searchHandler) .use(getVideoSnapshotsHandler) + .use(addSongHandler) + .use(deleteSongHandler) .listen(15412); export const VERSION = "0.7.0"; diff --git a/packages/temp_frontend/app/app.css b/packages/temp_frontend/app/app.css index 8c5e2ba..2463aee 100644 --- a/packages/temp_frontend/app/app.css +++ b/packages/temp_frontend/app/app.css @@ -12,6 +12,25 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +input[type="number"] { + -moz-appearance: textfield; +} + +.stat-num { + font-family: "Inter", sans-serif; + font-feature-settings: "tnum"; +} + +.xAxis .recharts-cartesian-axis-ticks { + transform: translateX(-7px); +} + @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); diff --git a/packages/temp_frontend/app/components/ui/alert-dialog.tsx b/packages/temp_frontend/app/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..646aac2 --- /dev/null +++ b/packages/temp_frontend/app/components/ui/alert-dialog.tsx @@ -0,0 +1,155 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib//utils" +import { buttonVariants } from "@/components/ui//button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/packages/temp_frontend/app/components/ui/button.tsx b/packages/temp_frontend/app/components/ui/button.tsx index af386fe..6beb00d 100644 --- a/packages/temp_frontend/app/components/ui/button.tsx +++ b/packages/temp_frontend/app/components/ui/button.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils"; +import { cn } from "@/lib//utils"; const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", diff --git a/packages/temp_frontend/app/routes.ts b/packages/temp_frontend/app/routes.ts index 557c9ed..c21bf2a 100644 --- a/packages/temp_frontend/app/routes.ts +++ b/packages/temp_frontend/app/routes.ts @@ -2,7 +2,8 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes"; export default [ index("routes/home.tsx"), - route("song/:id/info", "routes/song/[id]/info.tsx"), + route("song/:id/info", "routes/song/[id]/info/index.tsx"), + route("song/:id/add", "routes/song/[id]/add.tsx"), route("chart-demo", "routes/chartDemo.tsx"), route("search", "routes/search/index.tsx"), route("login", "routes/login.tsx"), diff --git a/packages/temp_frontend/app/routes/song/[id]/add.tsx b/packages/temp_frontend/app/routes/song/[id]/add.tsx index f18e270..4f98ace 100644 --- a/packages/temp_frontend/app/routes/song/[id]/add.tsx +++ b/packages/temp_frontend/app/routes/song/[id]/add.tsx @@ -1,9 +1,9 @@ -import type { Route } from "./+types/info"; +import type { Route } from "./+types/add"; import { treaty } from "@elysiajs/eden"; import type { App } from "@elysia/src"; import { useEffect, useState } from "react"; import { Skeleton } from "@/components/ui/skeleton"; -import { TriangleAlert } from "lucide-react"; +import { TriangleAlert, CheckCircle, Clock, AlertCircle } from "lucide-react"; import { Title } from "@/components/Title"; import { Search } from "@/components/Search"; import { Error } from "@/components/Error"; @@ -11,53 +11,193 @@ import { Layout } from "@/components/Layout"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { toast } from "sonner"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +// @ts-ignore idk const app = treaty(import.meta.env.VITE_API_URL!); type SongInfo = Awaited["info"]["get"]>>["data"]; type SongInfoError = Awaited["info"]["get"]>>["error"]; +type ImportStatus = { + id: string; + state: string; + result?: any; + failedReason?: string; +}; export async function clientLoader({ params }: Route.LoaderArgs) { return { id: params.id }; } export default function SongInfo({ loaderData }: Route.ComponentProps) { - const [data, setData] = useState(null); - const [error, setError] = useState(null); + const [isImporting, setIsImporting] = useState(false); + const [importStatus, setImportStatus] = useState(null); + const [importInterval, setImportInterval] = useState(null); - if (!data && !error) { - return ( - - - <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> - </Layout> + const importSong = async () => { + const response = await app.song.import.bilibili.post( + { id: loaderData.id }, + { + headers: { + Authorization: `Bearer ${localStorage.getItem("sessionID") || ""}`, + }, + }, ); - } - if (error?.status === 404) { - return ( - <div className="w-screen min-h-screen flex items-center justify-center"> - <Title title="未找到曲目" /> - <div className="max-w-md w-full bg-gray-100 dark:bg-neutral-900 rounded-2xl shadow-lg p-6 flex flex-col gap-4 items-center text-center"> - <div className="w-16 h-16 flex items-center justify-center rounded-full bg-red-500 text-white text-3xl"> - <TriangleAlert size={34} className="-translate-y-0.5" /> - </div> - <h1 className="text-3xl font-semibold text-neutral-900 dark:text-neutral-100">无法找到曲目</h1> - <a href={`/song/${loaderData.id}/add`} className="text-secondary-foreground"> - 点此收录 - </a> - </div> - </div> - ); - } + if (response.error) { + toast.error(`导入失败:${response.error.value.message || "未知错误"}`); + setIsImporting(false); + return; + } - if (error) { - return <Error error={error} />; - } + // @ts-ignore - Type issues with Eden treaty + const jobID = response.data?.jobID; + if (!jobID) { + toast.error("导入失败:未收到任务ID"); + setIsImporting(false); + return; + } + toast.success("歌曲导入任务已提交,正在处理中..."); + // Start polling for import status + const interval = setInterval(async () => { + const { data, error } = await app.song.import({ id: jobID }).status.get({ + headers: { + Authorization: `Bearer ${localStorage.getItem("sessionID") || ""}`, + }, + }); + if (error) { + toast.error(`导入失败:${error.value.message || "未知错误"}`); + setIsImporting(false); + clearInterval(interval); + return; + } + if (!data) { + return; + } + setImportStatus(data); + if (data.state !== "completed" && data.state !== "failed") { + return; + } + clearInterval(interval); + setIsImporting(false); + if (data.state !== "completed") { + toast.error(`导入失败:${data.failedReason || "未知错误"}`); + return; + } + toast.success("歌曲导入成功!"); + // Redirect to song info page after successful import + setTimeout(() => { + window.location.href = `/song/${loaderData.id}/info`; + }, 2000); + }, 2000); + setImportInterval(interval); + }; - return <Layout></Layout>; + const handleImportSong = async () => { + setIsImporting(true); + try { + await importSong(); + } catch (err) { + toast.error("导入失败:网络错误"); + setIsImporting(false); + } + }; + + useEffect(() => { + return () => { + if (importInterval) { + clearInterval(importInterval); + } + }; + }, [importInterval]); + + const getStatusIcon = (state: string) => { + switch (state) { + case "completed": + return <CheckCircle className="h-6 w-6 text-green-500" />; + case "failed": + return <AlertCircle className="h-6 w-6 text-red-500" />; + case "active": + return <Clock className="h-6 w-6 text-blue-500 animate-spin" />; + default: + return <Clock className="h-6 w-6 text-gray-500" />; + } + }; + + const getStatusText = (state: string) => { + switch (state) { + case "completed": + return "导入完成"; + case "failed": + return "导入失败"; + case "active": + return "正在导入"; + case "waiting": + return "等待处理"; + case "delayed": + return "延迟处理"; + default: + return "未知状态"; + } + }; + + return ( + <Layout> + <Title title="收录歌曲" /> + <Card className="mx-auto mt-8"> + <CardHeader> + <CardTitle>收录歌曲</CardTitle> + <CardDescription>将 Bilibili 视频 ID "{loaderData.id}" 收录为歌曲</CardDescription> + </CardHeader> + <CardContent className="space-y-6"> + {!importStatus ? ( + <div className="text-center space-y-4"> + <p className="text-lg"> + 您将要收录视频 ID: <strong>{loaderData.id}</strong> + </p> + <Button onClick={handleImportSong} disabled={isImporting} size="lg"> + {isImporting ? "提交中..." : "开始收录"} + </Button> + </div> + ) : ( + <div className="space-y-4"> + <div className="flex items-center gap-3 p-4 border rounded-lg"> + {getStatusIcon(importStatus.state)} + <div className="flex-1"> + <p className="font-medium">{getStatusText(importStatus.state)}</p> + <p className="text-sm text-gray-500">任务 ID: {importStatus.id}</p> + {importStatus.failedReason && ( + <p className="text-sm text-red-500 mt-1"> + 失败原因: {importStatus.failedReason} + </p> + )} + </div> + </div> + + {importStatus.state === "completed" && ( + <div className="text-center"> + <p className="text-green-600 mb-4">歌曲收录成功!正在跳转到歌曲页面...</p> + <Button + onClick={() => (window.location.href = `/song/${loaderData.id}/info`)} + variant="outline" + > + 立即查看 + </Button> + </div> + )} + + {importStatus.state === "failed" && ( + <div className="text-center"> + <Button onClick={handleImportSong} disabled={isImporting}> + {isImporting ? "重新提交中..." : "重新尝试"} + </Button> + </div> + )} + </div> + )} + </CardContent> + </Card> + </Layout> + ); } diff --git a/packages/temp_frontend/app/routes/song/[id]/info.tsx b/packages/temp_frontend/app/routes/song/[id]/info.tsx deleted file mode 100644 index 1a460ab..0000000 --- a/packages/temp_frontend/app/routes/song/[id]/info.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import type { Route } from "./+types/info"; -import { treaty } from "@elysiajs/eden"; -import type { App } from "@elysia/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 { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { formatDateTime } from "@/components/SearchResults"; - -// @ts-expect-error anyway... -const app = treaty<App>(import.meta.env.VITE_API_URL!); - -type SongInfo = Awaited<ReturnType<ReturnType<typeof app.song>["info"]["get"]>>["data"]; -type Snapshots = Awaited<ReturnType<ReturnType<typeof app.video>["snapshots"]["get"]>>["data"]; -type SongInfoError = Awaited<ReturnType<ReturnType<typeof app.song>["info"]["get"]>>["error"]; -type SnapshotsError = Awaited<ReturnType<ReturnType<typeof app.video>["snapshots"]["get"]>>["error"]; - -export async function clientLoader({ params }: Route.LoaderArgs) { - return { id: params.id }; -} - -const SnapshotsView = ({ snapshots }: { snapshots: Snapshots | null }) => { - if (!snapshots) { - return ( - <> - {/* <h2 className="mt-6 text-2xl font-medium mb-4">历史快照</h2> */} - <Skeleton className="w-full h-5 rounded-lg mt-4" /> - </> - ); - } - return ( - <div className="mt-4"> - <p> - 播放: {snapshots[0].views.toLocaleString()},更新于{formatDateTime(new Date(snapshots[0].createdAt))} - </p> - {/* <h2 className="mt-6 text-2xl font-medium mb-4">历史快照</h2> - <table> - <thead> - <tr> - <th className="text-left pr-4">日期</th> - <th className="text-left pr-4">播放量</th> - <th className="text-left pr-4">弹幕数</th> - <th className="text-left pr-4">点赞数</th> - <th className="text-left pr-4">收藏数</th> - <th className="text-left pr-4">硬币数</th> - </tr> - </thead> - <tbody> - {snapshots.map((snapshot: Exclude<Snapshots, null>[number]) => ( - <tr key={snapshot.id}> - <td className="pr-4">{new Date(snapshot.createdAt).toLocaleDateString()}</td> - <td className="pr-4">{snapshot.views}</td> - <td className="pr-4">{snapshot.danmakus}</td> - <td className="pr-4">{snapshot.likes}</td> - <td className="pr-4">{snapshot.favorites}</td> - <td className="pr-4">{snapshot.coins}</td> - </tr> - ))} - </tbody> - </table> */} - </div> - ); -}; - -export default function SongInfo({ loaderData }: Route.ComponentProps) { - const [data, setData] = useState<SongInfo | null>(null); - const [snapshots, setSnapshots] = useState<Snapshots | null>(null); - const [error, setError] = useState<SongInfoError | SnapshotsError | null>(null); - const [isDialogOpen, setIsDialogOpen] = useState(false); - const [songName, setSongName] = useState(""); - - const getSnapshots = async (aid: number) => { - const { data, error } = await app.video({ id: `av${aid}` }).snapshots.get(); - if (error) { - console.log(error); - setError(error); - return; - } - setSnapshots(data); - }; - - const getInfo = async () => { - const { data, error } = await app.song({ id: loaderData.id }).info.get(); - if (error) { - console.log(error); - setError(error); - return; - } - setData(data); - }; - - useEffect(() => { - getInfo(); - }, []); - - useEffect(() => { - if (!data) return; - const aid = data.aid; - if (!aid) return; - getSnapshots(aid); - }, [data]); - - // Update local song name when data changes - useEffect(() => { - if (data?.name) { - setSongName(data.name); - } - }, [data?.name]); - - if (!data && !error) { - return ( - <Layout> - <Title title="加载中" /> - <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> - </Layout> - ); - } - - if (error?.status === 404) { - return ( - <div className="w-screen min-h-screen flex items-center justify-center"> - <Title title="未找到曲目" /> - <div className="max-w-md w-full bg-gray-100 dark:bg-neutral-900 rounded-2xl shadow-lg p-6 flex flex-col gap-4 items-center text-center"> - <div className="w-16 h-16 flex items-center justify-center rounded-full bg-red-500 text-white text-3xl"> - <TriangleAlert size={34} className="-translate-y-0.5" /> - </div> - <h1 className="text-3xl font-semibold text-neutral-900 dark:text-neutral-100">无法找到曲目</h1> - <a href={`/song/${loaderData.id}/add`} className="text-secondary-foreground"> - 点此收录 - </a> - </div> - </div> - ); - } - - if (error) { - return <Error error={error} />; - } - - const formatDuration = (duration: number) => { - return `${Math.floor(duration / 60)}:${(duration % 60).toString().padStart(2, "0")}`; - }; - - const handleSongNameChange = async () => { - if (songName.trim() === "") return; - - const { data, error } = await app.song({ id: loaderData.id }).info.patch( - { name: songName }, - { - headers: { - Authorization: `Bearer ${localStorage.getItem("sessionID") || ""}`, - }, - }, - ); - setIsDialogOpen(false); - // Refresh the data to show the updated name - if (error || !data) { - toast.error(`无法更新:${error.value.message || "未知错误"}`); - } - getInfo(); - }; - - return ( - <Layout> - <Title title={data!.name ? data!.name : "未知歌曲名"} /> - {data!.cover && ( - <img - src={data!.cover} - referrerPolicy="no-referrer" - className="w-full aspect-video object-cover rounded-lg mt-6" - /> - )} - <div className="mt-6 flex justify-between"> - <div className="flex items-center gap-2"> - <h1 className="text-4xl font-medium" onDoubleClick={() => setIsDialogOpen(true)}> - {data!.name ? data!.name : "未知歌曲名"} - </h1> - <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> - <DialogContent> - <DialogHeader> - <DialogTitle>编辑歌曲名称</DialogTitle> - </DialogHeader> - <div className="space-y-4"> - <Input - value={songName} - onChange={(e) => setSongName(e.target.value)} - placeholder="请输入歌曲名称" - className="w-full" - /> - <div className="flex justify-end gap-2"> - <Button variant="outline" onClick={() => setIsDialogOpen(false)}> - 取消 - </Button> - <Button onClick={handleSongNameChange}>保存</Button> - </div> - </div> - </DialogContent> - </Dialog> - </div> - <div className="flex flex-col items-end h-10 whitespace-nowrap"> - <span className="leading-5 text-neutral-800 dark:text-neutral-200"> - {data!.duration ? formatDuration(data!.duration) : "未知时长"} - </span> - <span className="text-lg leading-5 text-neutral-800 dark:text-neutral-200 font-bold"> - {data!.producer ? data!.producer : "未知P主"} - </span> - </div> - </div> - <SnapshotsView snapshots={snapshots} /> - </Layout> - ); -} diff --git a/packages/temp_frontend/app/routes/song/[id]/info/data-table.tsx b/packages/temp_frontend/app/routes/song/[id]/info/data-table.tsx new file mode 100644 index 0000000..b715a61 --- /dev/null +++ b/packages/temp_frontend/app/routes/song/[id]/info/data-table.tsx @@ -0,0 +1,124 @@ +"use client"; + +import * as React from "react"; +import { + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, + type SortingState, +} from "@tanstack/react-table"; + +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +interface DataTableProps<TData, TValue> { + columns: ColumnDef<TData, TValue>[]; + data: TData[]; +} + +export function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) { + const [sorting, setSorting] = React.useState<SortingState>([]); + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + state: { + sorting, + }, + }); + + return ( + <div className="max-w-[calc(100vw-1.5rem)]"> + <div className="rounded-md border"> + <Table> + <TableHeader> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id}> + {headerGroup.headers.map((header) => { + return ( + <TableHead key={header.id}> + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + </TableHead> + ); + })} + </TableRow> + ))} + </TableHeader> + <TableBody> + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + <TableRow key={row.id} data-state={row.getIsSelected() && "selected"}> + {row.getVisibleCells().map((cell) => ( + <TableCell key={cell.id} className="stat-num"> + {flexRender(cell.column.columnDef.cell, cell.getContext())} + </TableCell> + ))} + </TableRow> + )) + ) : ( + <TableRow> + <TableCell colSpan={columns.length} className="h-24 text-center"> + 暂无数据 + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + <div className="flex items-center justify-between py-4"> + <div className="text-sm text-muted-foreground"> + {table.getState().pagination.pageIndex + 1} / {table.getPageCount()} 页 + </div> + <div className="flex items-center space-x-2"> + <Button + variant="outline" + size="sm" + onClick={() => table.previousPage()} + disabled={!table.getCanPreviousPage()} + > + 上一页 + </Button> + <div className="flex items-center space-x-2"> + <span className="text-sm">跳转到</span> + <Input + type="number" + min="1" + max={table.getPageCount()} + defaultValue={table.getState().pagination.pageIndex + 1} + className="w-15 h-8 text-center" + onKeyDown={(e) => { + if (e.key === "Enter") { + const page = e.currentTarget.value ? Number(e.currentTarget.value) - 1 : 0; + table.setPageIndex(Math.max(0, Math.min(page, table.getPageCount() - 1))); + } + }} + onBlur={(e) => { + const page = e.currentTarget.value ? Number(e.currentTarget.value) - 1 : 0; + table.setPageIndex(Math.max(0, Math.min(page, table.getPageCount() - 1))); + }} + /> + <span className="text-sm">页</span> + </div> + <Button + variant="outline" + size="sm" + onClick={() => table.nextPage()} + disabled={!table.getCanNextPage()} + > + 下一页 + </Button> + </div> + </div> + </div> + ); +} diff --git a/packages/temp_frontend/app/routes/song/[id]/info/index.tsx b/packages/temp_frontend/app/routes/song/[id]/info/index.tsx new file mode 100644 index 0000000..9d33fcb --- /dev/null +++ b/packages/temp_frontend/app/routes/song/[id]/info/index.tsx @@ -0,0 +1,336 @@ +import type { Route } from "./+types/index"; +import { treaty } from "@elysiajs/eden"; +import type { App } from "@elysia/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 { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { formatDateTime } from "@/components/SearchResults"; +import { ViewsChart } from "./views-chart"; +import { processSnapshots } from "./lib"; +import { DataTable } from "./data-table"; +import { columns, type Snapshot } from "./columns"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { If, Then } from "react-if"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { av2bv } from "@elysia/lib/bilibiliID"; + +// @ts-ignore idk +const app = treaty<App>(import.meta.env.VITE_API_URL!); + +type SongInfo = Awaited<ReturnType<ReturnType<typeof app.song>["info"]["get"]>>["data"]; +export type Snapshots = Awaited<ReturnType<ReturnType<typeof app.video>["snapshots"]["get"]>>["data"]; +type SongInfoError = Awaited<ReturnType<ReturnType<typeof app.song>["info"]["get"]>>["error"]; +type SnapshotsError = Awaited<ReturnType<ReturnType<typeof app.video>["snapshots"]["get"]>>["error"]; + +export async function clientLoader({ params }: Route.LoaderArgs) { + return { id: params.id }; +} + +const StatsTable = ({ snapshots }: { snapshots: Snapshots | null }) => { + if (!snapshots || snapshots.length === 0) { + return null; + } + + const tableData: Snapshot[] = snapshots.map((snapshot) => ({ + createdAt: snapshot.createdAt, + views: snapshot.views, + likes: snapshot.likes || 0, + favorites: snapshot.favorites || 0, + coins: snapshot.coins || 0, + danmakus: snapshot.danmakus || 0, + shares: snapshot.shares || 0, + })); + + return <DataTable columns={columns} data={tableData} />; +}; + +const SnapshotsView = ({ snapshots }: { snapshots: Snapshots | null }) => { + if (!snapshots) { + return <Skeleton className="w-full h-50 rounded-lg mt-4" />; + } + + if (snapshots.length === 0) { + return ( + <div className="mt-4"> + <p>暂无数据</p> + </div> + ); + } + + const processedData = processSnapshots(snapshots); + + return ( + <div className="mt-4"> + <p> + 播放: {snapshots[0].views.toLocaleString()} + <span className="text-secondary-foreground"> 更新于 {formatDateTime(new Date(snapshots[0].createdAt))}</span> + </p> + <Tabs defaultValue="chart" className="mt-4"> + <div className="flex justify-between mb-4"> + <h2 className="text-2xl font-medium">数据</h2> + <TabsList> + <TabsTrigger value="chart">图表</TabsTrigger> + <TabsTrigger value="table">表格</TabsTrigger> + </TabsList> + </div> + + <TabsContent value="chart"> + <ViewsChart chartData={processedData} /> + </TabsContent> + <TabsContent value="table"> + <StatsTable snapshots={snapshots} /> + </TabsContent> + </Tabs> + </div> + ); +}; + +export default function SongInfo({ loaderData }: Route.ComponentProps) { + const [songInfo, setData] = useState<SongInfo | null>(null); + const [snapshots, setSnapshots] = useState<Snapshots | null>(null); + const [error, setError] = useState<SongInfoError | SnapshotsError | null>(null); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [songName, setSongName] = useState(""); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + const getSnapshots = async (aid: number) => { + const { data, error } = await app.video({ id: `av${aid}` }).snapshots.get(); + if (error) { + console.log(error); + setError(error); + return; + } + setSnapshots(data); + }; + + const getInfo = async () => { + const { data, error } = await app.song({ id: loaderData.id }).info.get(); + if (error) { + console.log(error); + setError(error); + return; + } + setData(data); + }; + + useEffect(() => { + getInfo(); + }, []); + + useEffect(() => { + if (!songInfo) return; + const aid = songInfo.aid; + if (!aid) return; + getSnapshots(aid); + }, [songInfo]); + + useEffect(() => { + if (songInfo?.name) { + setSongName(songInfo.name); + } + }, [songInfo?.name]); + + if (!songInfo && !error) { + return ( + <Layout> + <Title title="加载中" /> + <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> + </Layout> + ); + } + + if (error?.status === 404) { + return ( + <div className="w-screen min-h-screen flex items-center justify-center"> + <Title title="未找到曲目" /> + <div className="max-w-md w-full bg-gray-100 dark:bg-neutral-900 rounded-2xl shadow-lg p-6 flex flex-col gap-4 items-center text-center"> + <div className="w-16 h-16 flex items-center justify-center rounded-full bg-red-500 text-white text-3xl"> + <TriangleAlert size={34} className="-translate-y-0.5" /> + </div> + <h1 className="text-3xl font-semibold text-neutral-900 dark:text-neutral-100">无法找到曲目</h1> + <a href={`/song/${loaderData.id}/add`} className="text-secondary-foreground"> + 点此收录 + </a> + </div> + </div> + ); + } + + if (error) { + return <Error error={error} />; + } + + const formatDuration = (duration: number) => { + return `${Math.floor(duration / 60)}:${(duration % 60).toString().padStart(2, "0")}`; + }; + + const handleSongNameChange = async () => { + if (songName.trim() === "") return; + setIsSaving(true); + const { data, error } = await app.song({ id: loaderData.id }).info.patch( + { name: songName }, + { + headers: { + Authorization: `Bearer ${localStorage.getItem("sessionID") || ""}`, + }, + }, + ); + setIsDialogOpen(false); + setIsSaving(false); + if (error || !data) { + toast.error(`无法更新:${error.value.message || "未知错误"}`); + } + getInfo(); + }; + + const handleDeleteSong = async () => { + if (!songInfo) return; + setIsDeleting(true); + try { + const { data, error } = await app.song({ id: songInfo.id }).delete(undefined, { + headers: { + Authorization: `Bearer ${localStorage.getItem("sessionID") || ""}`, + }, + }); + + if (error) { + toast.error(`删除失败:${error.value.message || "未知错误"}`); + return; + } + + toast.success("歌曲删除成功"); + // Redirect to home page after successful deletion + setTimeout(() => { + window.location.href = "/"; + }, 1000); + } catch (err) { + toast.error("删除失败:网络错误"); + } finally { + setIsDeleting(false); + setIsDeleteDialogOpen(false); + } + }; + + return ( + <Layout> + <Title title={songInfo!.name ? songInfo!.name : "未知歌曲名"} /> + {songInfo!.cover && ( + <img + src={songInfo!.cover} + referrerPolicy="no-referrer" + className="w-full aspect-video object-cover rounded-lg mt-6" + /> + )} + <div className="mt-6 flex items-center gap-2"> + <h1 className="text-4xl font-medium" onDoubleClick={() => setIsDialogOpen(true)}> + {songInfo!.name ? songInfo!.name : "未知歌曲名"} + </h1> + <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>编辑歌曲名称</DialogTitle> + </DialogHeader> + <div className="space-y-4"> + <Input + value={songName} + onChange={(e) => setSongName(e.target.value)} + placeholder="请输入歌曲名称" + className="w-full" + /> + <div className="flex justify-end gap-2"> + <Button variant="outline" onClick={() => setIsDialogOpen(false)}> + 取消 + </Button> + <Button onClick={handleSongNameChange}>{isSaving ? "保存中..." : "保存"}</Button> + </div> + </div> + </DialogContent> + </Dialog> + </div> + <div className="flex justify-between mt-3"> + <div> + <If condition={songInfo!.aid}> + <Then> + <p> + <span>av{songInfo!.aid}</span> · <span>{av2bv(songInfo!.aid!)}</span> + </p> + </Then> + </If> + <p> + <If condition={songInfo!.duration}> + <Then> + <span> + 时长: + {formatDuration(songInfo!.duration!)} + </span> + </Then> + </If> + <span> · </span> + <If condition={songInfo!.publishedAt}> + <Then> + <span>发布于 {formatDateTime(new Date(songInfo!.publishedAt!))}</span> + </Then> + </If> + </p> + + <span> + P主: + {songInfo!.producer ? songInfo!.producer : "未知P主"} + </span> + </div> + <div className="flex flex-col gap-3"> + {songInfo!.aid && ( + <Button className="bg-pink-400"> + <a href={`https://www.bilibili.com/video/${av2bv(songInfo!.aid)}`}>哔哩哔哩</a> + </Button> + )} + <AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}> + <AlertDialogTrigger asChild> + <Button variant="destructive">删除</Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>确认删除</AlertDialogTitle> + <AlertDialogDescription>你确定要删除本歌曲吗?</AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>取消</AlertDialogCancel> + <AlertDialogAction + onClick={handleDeleteSong} + disabled={isDeleting} + className="bg-red-600 hover:bg-red-700" + > + {isDeleting ? "删除中..." : "确认删除"} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + </div> + <SnapshotsView snapshots={snapshots} /> + </Layout> + ); +} diff --git a/packages/temp_frontend/app/routes/song/[id]/info/lib.ts b/packages/temp_frontend/app/routes/song/[id]/info/lib.ts new file mode 100644 index 0000000..60711e3 --- /dev/null +++ b/packages/temp_frontend/app/routes/song/[id]/info/lib.ts @@ -0,0 +1,120 @@ +import { HOUR, MINUTE } from "@core/lib"; +import type { Snapshots } from "./index"; + +const getDataIntervalMins = (interval: number) => { + if (interval >= 6 * HOUR) { + return 6 * 60; + } + else if (interval >= 1 * HOUR) { + return 60; + } + else if (interval >= 15 * MINUTE) { + return 15; + } + else if (interval >= 5 * MINUTE) { + return 5; + } + return 1; +} + +export const processSnapshots = (snapshots: Snapshots | null, timeRangeHours: number = 14 * 24) => { + if (!snapshots || snapshots.length === 0) { + return []; + } + + const sortedSnapshots = [...snapshots].sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + + const oldestDate = new Date(sortedSnapshots[0].createdAt); + + const targetStartTime = new Date(new Date().getTime() - timeRangeHours * HOUR); + + const startTime = oldestDate > targetStartTime ? oldestDate : targetStartTime; + + const hourlyTimePoints: Date[] = []; + const currentTime = new Date(sortedSnapshots[sortedSnapshots.length - 1].createdAt); + const timeDiff = currentTime.getTime() - startTime.getTime(); + const length = sortedSnapshots.filter((s) => new Date(s.createdAt) >= startTime).length; + const avgInterval = timeDiff / length; + const dataIntervalMins = getDataIntervalMins(avgInterval); + + for (let time = new Date(startTime); time <= currentTime; time.setMinutes(time.getMinutes() + dataIntervalMins)) { + hourlyTimePoints.push(new Date(time)); + } + + const processedData = hourlyTimePoints + .map((timePoint) => { + const previousSnapshots = sortedSnapshots.filter((s) => new Date(s.createdAt) <= timePoint); + + const nextSnapshots = sortedSnapshots.filter((s) => new Date(s.createdAt) >= timePoint); + + const previousSnapshot = previousSnapshots[previousSnapshots.length - 1]; + const nextSnapshot = nextSnapshots[0]; + + if (!previousSnapshot && !nextSnapshot) { + return null; + } + + if (previousSnapshot && new Date(previousSnapshot.createdAt).getTime() === timePoint.getTime()) { + return { + createdAt: timePoint.toISOString(), + views: previousSnapshot.views, + likes: previousSnapshot.likes || 0, + favorites: previousSnapshot.favorites || 0, + coins: previousSnapshot.coins || 0, + danmakus: previousSnapshot.danmakus || 0, + }; + } + + if (previousSnapshot && !nextSnapshot) { + return { + createdAt: timePoint.toISOString(), + views: previousSnapshot.views, + likes: previousSnapshot.likes || 0, + favorites: previousSnapshot.favorites || 0, + coins: previousSnapshot.coins || 0, + danmakus: previousSnapshot.danmakus || 0, + }; + } + + if (!previousSnapshot && nextSnapshot) { + return { + createdAt: timePoint.toISOString(), + views: nextSnapshot.views, + likes: nextSnapshot.likes || 0, + favorites: nextSnapshot.favorites || 0, + coins: nextSnapshot.coins || 0, + danmakus: nextSnapshot.danmakus || 0, + }; + } + + const prevTime = new Date(previousSnapshot.createdAt).getTime(); + const nextTime = new Date(nextSnapshot.createdAt).getTime(); + const currentTime = timePoint.getTime(); + + const ratio = (currentTime - prevTime) / (nextTime - prevTime); + + return { + createdAt: timePoint.toISOString(), + views: Math.round(previousSnapshot.views + (nextSnapshot.views - previousSnapshot.views) * ratio), + likes: Math.round( + (previousSnapshot.likes || 0) + ((nextSnapshot.likes || 0) - (previousSnapshot.likes || 0)) * ratio, + ), + favorites: Math.round( + (previousSnapshot.favorites || 0) + + ((nextSnapshot.favorites || 0) - (previousSnapshot.favorites || 0)) * ratio, + ), + coins: Math.round( + (previousSnapshot.coins || 0) + ((nextSnapshot.coins || 0) - (previousSnapshot.coins || 0)) * ratio, + ), + danmakus: Math.round( + (previousSnapshot.danmakus || 0) + + ((nextSnapshot.danmakus || 0) - (previousSnapshot.danmakus || 0)) * ratio, + ), + }; + }) + .filter((d) => d !== null); + + return processedData; +}; \ No newline at end of file diff --git a/packages/temp_frontend/app/routes/song/[id]/info/views-chart.tsx b/packages/temp_frontend/app/routes/song/[id]/info/views-chart.tsx new file mode 100644 index 0000000..4826fc4 --- /dev/null +++ b/packages/temp_frontend/app/routes/song/[id]/info/views-chart.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; +import { useDarkMode } from "usehooks-ts"; +import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"; + +const chartConfigLight = { + views: { + label: "播放", + color: "#111417", + }, + likes: { + label: "点赞", + }, +} satisfies ChartConfig; + +const chartConfigDark = { + views: { + label: "播放", + color: "#EEEEF0", + }, + likes: { + label: "点赞", + }, +} satisfies ChartConfig; + +interface ChartData { + createdAt: string; + views: number; +} + +function formatDate(dateStr: string, showYear = false): string { + const date = new Date(dateStr); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const yearStr = showYear ? ` ${year}-` : ""; + return `${yearStr}${month}-${day} ${hours}:${minutes}`; +} + +const formatYAxisLabel = (value: number, minMax: number) => { + if (minMax >= 40000) { + return (value / 10000).toFixed() + " 万"; + } + return value.toLocaleString(); +} + +export function ViewsChart({ chartData }: { chartData: ChartData[] }) { + const { isDarkMode } = useDarkMode(); + const minMax = chartData[chartData.length - 1].views - chartData[0].views; + if (!chartData || chartData.length === 0) return <></>; + return ( + <ChartContainer config={isDarkMode ? chartConfigDark : chartConfigLight} className="min-h-[200px] w-full"> + <LineChart accessibilityLayer data={chartData}> + <CartesianGrid vertical={false} /> + <XAxis + dataKey="createdAt" + tickLine={false} + tickMargin={10} + axisLine={true} + tickFormatter={(e) => formatDate(e)} + minTickGap={30} + className="stat-num" + /> + <YAxis + dataKey="views" + tickLine={false} + tickMargin={5} + domain={["auto", "auto"]} + className="stat-num" + tickFormatter={(value) => formatYAxisLabel(value, minMax)} + allowDecimals={false} + /> + <ChartTooltip + content={<ChartTooltipContent hideIndicator={true} labelFormatter={(e) => formatDate(e, true)} />} + /> + <Line dataKey="views" stroke="var(--color-views)" strokeWidth={2} dot={false} /> + <Line dataKey="likes" stroke="var(--color-likes)" strokeWidth={2} dot={false} /> + </LineChart> + </ChartContainer> + ); +} diff --git a/packages/temp_frontend/package.json b/packages/temp_frontend/package.json index 73ee2ac..fb11a98 100644 --- a/packages/temp_frontend/package.json +++ b/packages/temp_frontend/package.json @@ -13,8 +13,9 @@ "@elysiajs/eden": "^1.4.1", "@nivo/core": "^0.99.0", "@nivo/line": "^0.99.0", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@react-router/node": "^7.7.1", "@react-router/serve": "^7.7.1", @@ -25,6 +26,7 @@ "next-themes": "^0.4.6", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-if": "^4.1.6", "react-router": "^7.7.1", "recharts": "^3.2.1", "sonner": "^2.0.7",