1
0

add: song inclusion

This commit is contained in:
alikia2x (寒寒) 2025-11-10 04:27:51 +08:00
parent 845c2eb5e9
commit 485d455ea7
WARNING! Although there is a key with this ID in the database it does not verify this commit! This commit is SUSPICIOUS.
GPG Key ID: 56209E0CCD8420C6
24 changed files with 1312 additions and 362 deletions

View File

@ -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=="],

View File

@ -99,13 +99,43 @@ interface Snapshot {
views: number;
}
export async function findClosestSnapshot(sql: Psql, aid: number, targetTime: Date): Promise<Snapshot | null> {
export async function findClosestSnapshot(
sql: Psql,
aid: number,
targetTime: Date
): Promise<Snapshot | null> {
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<Snapshot | null> {
export async function findSnapshotBefore(
sql: Psql,
aid: number,
targetTime: Date
): Promise<Snapshot | null> {
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<Snapshot | null> {
@ -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)

View File

@ -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<void> => {
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);
};

View File

@ -5,9 +5,10 @@ import { sql } from "@core/db/dbNew";
export const getVideoInfoWorker = async (job: Job): Promise<void> => {
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);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
},
{

View File

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

View File

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

View File

@ -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<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

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

View File

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

View File

@ -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<App>(import.meta.env.VITE_API_URL!);
type SongInfo = Awaited<ReturnType<ReturnType<typeof app.song>["info"]["get"]>>["data"];
type SongInfoError = Awaited<ReturnType<ReturnType<typeof app.song>["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<SongInfo | null>(null);
const [error, setError] = useState<SongInfoError | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [importStatus, setImportStatus] = useState<ImportStatus | null>(null);
const [importInterval, setImportInterval] = useState<NodeJS.Timeout | null>(null);
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>
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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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