add: song inclusion
This commit is contained in:
parent
845c2eb5e9
commit
485d455ea7
16
bun.lock
16
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=="],
|
||||
|
||||
@ -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)
|
||||
|
||||
17
packages/crawler/mq/exec/directSnapshot.ts
Normal file
17
packages/crawler/mq/exec/directSnapshot.ts
Normal 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);
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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
|
||||
});
|
||||
@ -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()
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
32
packages/elysia/routes/song/delete.ts
Normal file
32
packages/elysia/routes/song/delete.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
);
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
},
|
||||
{
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
|
||||
155
packages/temp_frontend/app/components/ui/alert-dialog.tsx
Normal file
155
packages/temp_frontend/app/components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
124
packages/temp_frontend/app/routes/song/[id]/info/data-table.tsx
Normal file
124
packages/temp_frontend/app/routes/song/[id]/info/data-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
336
packages/temp_frontend/app/routes/song/[id]/info/index.tsx
Normal file
336
packages/temp_frontend/app/routes/song/[id]/info/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
packages/temp_frontend/app/routes/song/[id]/info/lib.ts
Normal file
120
packages/temp_frontend/app/routes/song/[id]/info/lib.ts
Normal 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;
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user