1
0

add: some basic info shown in the song info page

This commit is contained in:
alikia2x (寒寒) 2025-10-14 01:49:56 +08:00
parent c7b0174742
commit d22fdd4390
7 changed files with 209 additions and 38 deletions

View File

@ -197,7 +197,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"isbot": "^5.1.27",
"lucide-react": "^0.544.0",
"lucide-react": "^0.545.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
@ -1893,7 +1893,7 @@
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="],
"lucide-react": ["lucide-react@0.545.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw=="],
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
@ -3071,6 +3071,8 @@
"plaette/@types/node": ["@types/node@24.6.2", "", { "dependencies": { "undici-types": "~7.13.0" } }, "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang=="],
"plaette/lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="],
"plaette/vite": ["vite@7.1.9", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg=="],
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],

View File

@ -13,7 +13,11 @@ async function getSongIDFromBiliID(id: string) {
} else {
return null;
}
const songID = await dbMain.select({ id: songs.id }).from(songs).where(eq(songs.aid, aid)).limit(1);
const songID = await dbMain
.select({ id: songs.id })
.from(songs)
.where(eq(songs.aid, aid))
.limit(1);
if (songID.length > 0) {
return songID[0].id;
}
@ -44,23 +48,29 @@ async function getSingers(id: number) {
})
.from(relations)
.innerJoin(singer, eq(relations.targetId, singer.id))
.where(and(eq(relations.sourceId, id), eq(relations.sourceType, "song"), eq(relations.relation, "sing")));
.where(
and(
eq(relations.sourceId, id),
eq(relations.sourceType, "song"),
eq(relations.relation, "sing")
)
);
return singers.map((singer) => singer.singers);
}
export const getSongInfoHandler = new Elysia({ prefix: "/song" }).get(
"/:id/info",
async (c) => {
const id = c.params.id;
async ({ params, status }) => {
const id = params.id;
const songID = await getSongID(id);
if (!songID) {
return c.status(404, {
return status(404, {
message: "song not found"
});
}
const info = await getSongInfo(songID);
if (!info) {
return c.status(404, {
return status(404, {
message: "song not found"
});
}
@ -70,7 +80,8 @@ export const getSongInfoHandler = new Elysia({ prefix: "/song" }).get(
aid: info.aid,
producer: info.producer,
duration: info.duration,
singers: singers
singers: singers,
cover: info.image || undefined
};
},
{
@ -80,7 +91,8 @@ export const getSongInfoHandler = new Elysia({ prefix: "/song" }).get(
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())
singers: t.Array(t.String()),
cover: t.Optional(t.String())
}),
404: t.Object({
message: t.String()
@ -97,3 +109,51 @@ export const getSongInfoHandler = new Elysia({ prefix: "/song" }).get(
}
}
);
export const patchInfoHandler = new Elysia({ prefix: "/song" }).patch(
"/:id/info",
async ({ params, status, body }) => {
const id = params.id;
const songID = await getSongID(id);
if (!songID) {
return status(404, {
message: "song not found"
});
}
const info = await getSongInfo(songID);
if (!info) {
return status(404, {
message: "song not 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));
}
return {
message: "success"
};
},
{
response: {
200: t.Object({
message: t.String()
}),
404: t.Object({
message: t.String()
})
},
body: t.Object({
name: t.Optional(t.String()),
producer: t.Optional(t.String())
})
}
);

View File

@ -3,7 +3,7 @@ import { getBindingInfo, logStartup } from "./startMessage";
import { pingHandler } from "@elysia/routes/ping";
import openapi from "@elysiajs/openapi";
import { cors } from "@elysiajs/cors";
import { getSongInfoHandler } from "@elysia/routes/song/info";
import { getSongInfoHandler, patchInfoHandler } from "@elysia/routes/song/info";
import { rootHandler } from "@elysia/routes/root";
import { getVideoMetadataHandler } from "@elysia/routes/video/metadata";
import { closeMileStoneHandler } from "@elysia/routes/song/milestone";
@ -19,20 +19,41 @@ const app = new Elysia({
hostname: host
}
})
.onAfterHandle({ as: "global" }, ({ responseValue, set, request }) => {
.onAfterHandle({ as: "global" }, ({ responseValue, request }) => {
const contentType = request.headers.get("Content-Type") || "";
const accept = request.headers.get("Accept") || "";
const secFetchMode = request.headers.get("Sec-Fetch-Mode");
const requestJson = contentType.includes("application/json");
const isBrowser = !requestJson && (accept.includes("text/html") || secFetchMode === "navigate");
const isBrowser =
!requestJson && (accept.includes("text/html") || secFetchMode === "navigate");
const responseValueType = typeof responseValue;
const isObject = responseValueType === "object";
const response = isObject
? responseValue
: {
message: responseValue
};
const text = isBrowser ? JSON.stringify(response, null, 2) : JSON.stringify(response);
if (!isObject) {
const response = {
message: responseValue
};
const text = isBrowser ? JSON.stringify(response, null, 2) : JSON.stringify(response);
return new Response(encoder.encode(text), {
headers: {
"Content-Type": "application/json; charset=utf-8"
}
});
}
const realResponse = responseValue as Record<string, unknown>;
if (realResponse.code) {
const text = isBrowser
? JSON.stringify(realResponse.response, null, 2)
: JSON.stringify(realResponse.response);
return new Response(encoder.encode(text), {
status: realResponse.code as any,
headers: {
"Content-Type": "application/json; charset=utf-8"
}
});
}
const text = isBrowser
? JSON.stringify(realResponse, null, 2)
: JSON.stringify(realResponse);
return new Response(encoder.encode(text), {
headers: {
"Content-Type": "application/json; charset=utf-8"
@ -46,6 +67,7 @@ const app = new Elysia({
.use(getVideoMetadataHandler)
.use(getSongInfoHandler)
.use(closeMileStoneHandler)
.use(patchInfoHandler)
.listen(15412);
export const VERSION = "0.7.0";

View File

@ -0,0 +1,13 @@
import { cn } from "@/lib//utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -1,11 +1,9 @@
import type { Route } from "./+types/home";
import { treaty } from "@elysiajs/eden";
import type { App } from "@elysia/src";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SearchIcon } from "@/components/icons/search";
const app = treaty<App>("localhost:15412");
export function meta({}: Route.MetaArgs) {
return [{ title: "中V档案馆" }];

View File

@ -1,29 +1,105 @@
import useSWR from "swr";
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";
const API_URL = "https://api.projectcvsa.com";
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"];
export async function clientLoader({ params }: Route.LoaderArgs) {
return { id: params.id };
}
export default function SongInfo({ loaderData }: Route.ComponentProps) {
const { data, error, isLoading } = useSWR(`${API_URL}/video/${loaderData.id}/info`, async (url) => {
const response = await fetch(url);
if (!response.ok) {
throw new Error("Failed to fetch song info");
}
return response.json();
});
const [data, setData] = useState<SongInfo | null>(null);
const [error, setError] = useState<SongInfoError | null>(null);
if (isLoading) return <div>...</div>;
if (error) return <div>: {error.message}</div>;
if (!data) return <div></div>;
useEffect(() => {
(async () => {
const { data, error } = await app.song({ id: loaderData.id }).info.get();
if (error) {
console.log(error);
setError(error);
return;
}
setData(data);
})();
}, []);
if (!data && !error) {
return (
<div className="w-screen min-h-screen relative left-0 top-0 flex justify-center">
<main className="w-full max-sm:mx-6 pt-14 sm:w-xl xl:w-2xl">
<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>
</main>
</div>
);
}
if (error) {
return (
<div className="w-screen min-h-screen flex items-center justify-center">
<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>
<p className="text-neutral-700 dark:text-neutral-300">{error.status}</p>
{error.value.message && (
<p className="text-neutral-600 dark:text-neutral-400 break-words">
<span className="font-medium text-neutral-700 dark:text-neutral-300"></span>
<br />
{error.value.message}
</p>
)}
</div>
</div>
);
}
const formatDuration = (duration: number) => {
const minutes = Math.floor(duration / 60);
const seconds = duration % 60;
return `${minutes}:${seconds}`;
};
const songNameOnChange = async (e: React.FocusEvent<HTMLHeadingElement, Element>) => {
const name = e.target.textContent;
await app.song({ id: loaderData.id }).info.patch({ name: name || undefined });
};
return (
<div>
<h1></h1>
<pre>{JSON.stringify(data, null, 2)}</pre>
<div className="w-screen min-h-screen relative left-0 top-0 flex justify-center">
<main className="w-full max-sm:mx-6 pt-14 sm:w-xl xl:w-2xl">
{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">
<h1 className="text-4xl font-medium" contentEditable={true} onBlur={songNameOnChange}>
{data!.name ? data!.name : "未知歌曲名"}
</h1>
<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>
</main>
</div>
);
}

View File

@ -20,7 +20,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"isbot": "^5.1.27",
"lucide-react": "^0.544.0",
"lucide-react": "^0.545.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",