Kyoo/api/src/controllers/video-metadata.ts
2026-04-16 23:56:08 +02:00

275 lines
7.2 KiB
TypeScript

import { getLogger } from "@logtape/logtape";
import { and, eq } from "drizzle-orm";
import { Elysia, t } from "elysia";
import slugify from "slugify";
import { auth } from "~/auth";
import { db } from "~/db";
import { entries, entryVideoJoin, videos } from "~/db/schema";
import { KError } from "~/models/error";
import { isUuid } from "~/models/utils";
import { Video } from "~/models/video";
const logger = getLogger();
export const videosMetadata = new Elysia({
prefix: "/videos",
tags: ["videos"],
})
.model({
video: Video,
error: t.Object({}),
})
.use(auth)
.get(
":id/info",
async ({ params: { id }, status, redirect }) => {
const [video] = await db
.select({
path: videos.path,
})
.from(videos)
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
.limit(1);
if (!video) {
return status(404, {
status: 404,
message: `No video found with id or slug '${id}'`,
});
}
const path = Buffer.from(video.path, "utf8").toString("base64url");
return redirect(`/video/${path}/info`);
},
{
detail: { description: "Get a video's metadata informations" },
params: t.Object({
id: t.String({
description: "The id or slug of the video to retrieve.",
example: "made-in-abyss-s1e13",
}),
}),
response: {
302: t.Void({
description:
"Redirected to the [/video/{path}/info](?api=transcoder#tag/metadata/get/:path/info) route (of the transcoder)",
}),
404: {
...KError,
description: "No video found with the given id or slug.",
},
},
},
)
.get(
":id/thumbnails.vtt",
async ({ params: { id }, status, redirect }) => {
const [video] = await db
.select({
path: videos.path,
})
.from(videos)
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
.limit(1);
if (!video) {
return status(404, {
status: 404,
message: `No video found with id or slug '${id}'`,
});
}
const path = Buffer.from(video.path, "utf8").toString("base64url");
return redirect(`/video/${path}/thumbnails.vtt`);
},
{
detail: {
description: "Get redirected to the direct stream of the video",
},
params: t.Object({
id: t.String({
description: "The id or slug of the video to watch.",
example: "made-in-abyss-s1e13",
}),
}),
response: {
302: t.Void({
description:
"Redirected to the [/video/{path}/direct](?api=transcoder#tag/metadata/get/:path/direct) route (of the transcoder)",
}),
404: {
...KError,
description: "No video found with the given id or slug.",
},
},
},
)
.get(
":id/direct",
async ({ params: { id }, status, redirect }) => {
const [video] = await db
.select({
path: videos.path,
})
.from(videos)
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
.limit(1);
if (!video) {
return status(404, {
status: 404,
message: `No video found with id or slug '${id}'`,
});
}
const path = Buffer.from(video.path, "utf8").toString("base64url");
const filename = video.path.substring(video.path.lastIndexOf("/") + 1);
return redirect(
`/video/${path}/direct/${slugify(filename, { lower: true })}`,
);
},
{
detail: {
description: "Get redirected to the direct stream of the video",
},
params: t.Object({
id: t.String({
description: "The id or slug of the video to watch.",
example: "made-in-abyss-s1e13",
}),
}),
response: {
302: t.Void({
description:
"Redirected to the [/video/{path}/direct](?api=transcoder#tag/metadata/get/:path/direct) route (of the transcoder)",
}),
404: {
...KError,
description: "No video found with the given id or slug.",
},
},
},
)
.get(
":id/master.m3u8",
async ({ params: { id }, request, status, redirect }) => {
const [video] = await db
.select({
path: videos.path,
})
.from(videos)
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
.limit(1);
if (!video) {
return status(404, {
status: 404,
message: `No video found with id or slug '${id}'`,
});
}
const path = Buffer.from(video.path, "utf8").toString("base64url");
const query = request.url.substring(request.url.indexOf("?"));
return redirect(`/video/${path}/master.m3u8${query}`);
},
{
detail: { description: "Get redirected to the master.m3u8 of the video" },
params: t.Object({
id: t.String({
description: "The id or slug of the video to watch.",
example: "made-in-abyss-s1e13",
}),
}),
response: {
302: t.Void({
description:
"Redirected to the [/video/{path}/master.m3u8](?api=transcoder#tag/metadata/get/:path/master.m3u8) route (of the transcoder)",
}),
404: {
...KError,
description: "No video found with the given id or slug.",
},
},
},
)
.get(
":id/prepare",
async ({ params: { id }, headers: { authorization }, status }) => {
const ret = await prepareVideo(id, authorization!);
if (ret) return status(ret.status, ret);
},
{
detail: { description: "Prepare a video for playback" },
params: t.Object({
id: t.String({
description: "The id or slug of the video to watch.",
example: "made-in-abyss-s1e13",
}),
}),
response: {
302: t.Void({
description:
"Prepare said video for playback (compute everything possible and cache it)",
}),
404: {
...KError,
description: "No video found with the given id or slug.",
},
},
},
);
export const prepareVideo = async (slug: string, auth: string) => {
const [vid] = await db
.select({ path: videos.path, show: entries.showPk, order: entries.order })
.from(videos)
.innerJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
.leftJoin(entries, eq(entries.pk, entryVideoJoin.entryPk))
.where(eq(entryVideoJoin.slug, slug))
.limit(1);
if (!vid) {
return {
status: 404,
message: `No video found with slug ${slug}`,
} as const;
}
const related = vid.show
? await db
.select({ order: entries.order, path: videos.path })
.from(entries)
.innerJoin(entryVideoJoin, eq(entries.pk, entryVideoJoin.entryPk))
.innerJoin(videos, eq(videos.pk, entryVideoJoin.videoPk))
.where(and(eq(entries.showPk, vid.show), eq(entries.kind, "episode")))
.orderBy(entries.order)
: [];
const idx = related.findIndex((x) => x.order === vid.order);
const near = [related[idx - 1], related[idx + 1]]
.filter((x) => x)
.map((x) => x.path);
logger.info("Preparing next video {slug} (near episodes: {near})", {
slug,
near,
});
const path = Buffer.from(vid.path, "utf8").toString("base64url");
await fetch(
new URL(
`/video/${path}/prepare`,
process.env.TRANSCODER_SERVER ?? "http://transcoder:7666",
),
{
headers: {
authorization: auth,
"content-type": "application/json",
},
method: "POST",
body: JSON.stringify({
nearEpisodes: near,
}),
},
);
};