545 lines
18 KiB
TypeScript
545 lines
18 KiB
TypeScript
import { Elysia, status } from "elysia";
|
|
import {
|
|
getAllVideos,
|
|
getVideoById,
|
|
deleteVideoById,
|
|
insertVideo,
|
|
updateThumbnailKey,
|
|
updateTier,
|
|
getVideoByMapAndPlayer,
|
|
updateVideoPB,
|
|
updateTranscodedKey,
|
|
updateTranscodeStatus,
|
|
} from "../services/db";
|
|
import {
|
|
uploadObject,
|
|
deleteObject,
|
|
getObjectUrl,
|
|
getStreamUrl,
|
|
createMultipartUpload,
|
|
getPresignedUploadPartUrl,
|
|
completeMultipartUpload,
|
|
abortMultipartUpload,
|
|
getVideoStream,
|
|
transcodeVideo,
|
|
} from "../services/rustfs";
|
|
import { parseMtvFile } from "../services/mtv-parser";
|
|
import { getMapInfo } from "../services/momentum-api";
|
|
import type { VideoEntry, PreviousPB } from "../types/video";
|
|
|
|
const ADMIN_TOKEN = process.env.ADMIN_TOKEN;
|
|
|
|
if (!ADMIN_TOKEN) {
|
|
console.error(
|
|
"ADMIN_TOKEN environment variable is required. Set it to secure upload/delete endpoints.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
function sanitizeFileName(name: string): string {
|
|
return name
|
|
.replace(/\s+/g, "_")
|
|
.replace(/[^a-zA-Z0-9_.\-]/g, "")
|
|
.toLowerCase();
|
|
}
|
|
|
|
export const videoRoutes = new Elysia({ prefix: "/api/videos" })
|
|
.guard({
|
|
beforeHandle: ({ request }) => {
|
|
const isWrite =
|
|
request.method !== "GET" &&
|
|
request.method !== "HEAD" &&
|
|
request.method !== "OPTIONS";
|
|
if (isWrite) {
|
|
const auth = request.headers.get("authorization");
|
|
const token = auth?.startsWith("Bearer ")
|
|
? auth.slice(7)
|
|
: null;
|
|
if (!token || token !== ADMIN_TOKEN)
|
|
return status(401, "Unauthorized");
|
|
}
|
|
},
|
|
})
|
|
|
|
.get("/", () => {
|
|
const videos = getAllVideos();
|
|
return videos.map((v) => ({
|
|
...v,
|
|
thumbnailUrl: v.thumbnailUrl
|
|
? getObjectUrl(v.thumbnailUrl)
|
|
: undefined,
|
|
}));
|
|
})
|
|
|
|
.post("/upload-url", async ({ request }) => {
|
|
let formData: FormData;
|
|
try {
|
|
formData = await request.formData();
|
|
} catch {
|
|
return status(400, { error: "Failed to parse form data" });
|
|
}
|
|
|
|
const mtv = formData.get("mtv") as File | null;
|
|
const videoFileName = formData.get("videoFileName") as string | null;
|
|
const videoContentType =
|
|
(formData.get("videoContentType") as string | null) || "video/webm";
|
|
const videoFileSizeStr = formData.get("videoFileSize") as string | null;
|
|
const runDate = formData.get("runDate") as string | null;
|
|
|
|
if (!mtv || !videoFileName || !videoFileSizeStr) {
|
|
return status(400, {
|
|
error: "mtv file, videoFileName, and videoFileSize are required",
|
|
});
|
|
}
|
|
|
|
const videoFileSize = parseInt(videoFileSizeStr, 10);
|
|
if (isNaN(videoFileSize) || videoFileSize <= 0) {
|
|
return status(400, { error: "Invalid videoFileSize" });
|
|
}
|
|
|
|
const CHUNK_SIZE = 80 * 1024 * 1024;
|
|
const numParts = Math.ceil(videoFileSize / CHUNK_SIZE);
|
|
|
|
const mtvBuffer = await mtv.arrayBuffer();
|
|
const metadata = parseMtvFile(mtvBuffer);
|
|
if (!metadata) {
|
|
return status(400, {
|
|
error: "Invalid .mtv file: could not parse header",
|
|
});
|
|
}
|
|
|
|
const existing = getVideoByMapAndPlayer(
|
|
metadata.mapName,
|
|
metadata.steamId,
|
|
);
|
|
if (existing) {
|
|
if (existing.runTime <= metadata.runTime) {
|
|
return status(409, {
|
|
error: `PB not improved. Your current PB on ${metadata.mapName} is ${existing.runTime}, this run is ${metadata.runTime}`,
|
|
});
|
|
}
|
|
|
|
const deletes = [
|
|
deleteObject(existing.videoKey),
|
|
deleteObject(existing.mtvKey),
|
|
];
|
|
if (existing.thumbnailKey)
|
|
deletes.push(deleteObject(existing.thumbnailKey));
|
|
await Promise.all(deletes);
|
|
|
|
const previousPbs: PreviousPB[] = [
|
|
{ runTime: existing.runTime, createdAt: existing.createdAt },
|
|
...(existing.previousPbs ?? []),
|
|
];
|
|
|
|
const id = existing.id;
|
|
const createdAt =
|
|
runDate && !isNaN(Date.parse(runDate))
|
|
? new Date(runDate).toISOString()
|
|
: new Date().toISOString();
|
|
|
|
const sanitizedVideoName = sanitizeFileName(videoFileName);
|
|
const videoKey = `videos/${id}/${sanitizedVideoName}`;
|
|
const mtvKey = `videos/${id}/${sanitizeFileName(mtv.name)}`;
|
|
|
|
await uploadObject(
|
|
mtvKey,
|
|
Buffer.from(mtvBuffer),
|
|
"application/octet-stream",
|
|
);
|
|
|
|
let tier: number | null | undefined;
|
|
let mapId: number | null | undefined;
|
|
let thumbnailKey: string | undefined;
|
|
const mapInfo = await getMapInfo(metadata.mapName);
|
|
mapId = mapInfo.mapId ?? undefined;
|
|
tier = mapInfo.tier;
|
|
|
|
if (mapInfo.thumbnailUrl) {
|
|
thumbnailKey = `videos/${id}/thumbnail.jpg`;
|
|
try {
|
|
const imgRes = await fetch(mapInfo.thumbnailUrl);
|
|
if (imgRes.ok) {
|
|
const imgBuf = Buffer.from(await imgRes.arrayBuffer());
|
|
await uploadObject(thumbnailKey, imgBuf, "image/jpeg");
|
|
} else {
|
|
thumbnailKey = undefined;
|
|
}
|
|
} catch {
|
|
thumbnailKey = undefined;
|
|
}
|
|
}
|
|
|
|
const title = `${metadata.mapName} - ${metadata.playerName}`;
|
|
const entry: VideoEntry = {
|
|
id,
|
|
title,
|
|
description: "",
|
|
mapName: metadata.mapName,
|
|
playerName: metadata.playerName,
|
|
steamId: metadata.steamId,
|
|
runTime: metadata.runTime,
|
|
totalTicks: metadata.totalTicks,
|
|
tickInterval: metadata.tickInterval,
|
|
videoKey,
|
|
mtvKey,
|
|
thumbnailKey,
|
|
tier,
|
|
mapId,
|
|
jsonStats: metadata.jsonStats,
|
|
createdAt,
|
|
previousPbs,
|
|
};
|
|
|
|
updateVideoPB(id, entry, previousPbs);
|
|
|
|
const { uploadId } = await createMultipartUpload(
|
|
videoKey,
|
|
videoContentType,
|
|
);
|
|
|
|
const parts = await Promise.all(
|
|
Array.from({ length: numParts }, (_, i) => i + 1).map(
|
|
async (partNumber) => ({
|
|
partNumber,
|
|
url: await getPresignedUploadPartUrl(
|
|
videoKey,
|
|
uploadId,
|
|
partNumber,
|
|
),
|
|
}),
|
|
),
|
|
);
|
|
|
|
return {
|
|
...entry,
|
|
presignedUrls: {
|
|
parts,
|
|
uploadId,
|
|
},
|
|
videoUrl: getStreamUrl(id),
|
|
mtvUrl: getObjectUrl(mtvKey),
|
|
thumbnailUrl: thumbnailKey
|
|
? getObjectUrl(thumbnailKey)
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
const id = crypto.randomUUID();
|
|
const createdAt =
|
|
runDate && !isNaN(Date.parse(runDate))
|
|
? new Date(runDate).toISOString()
|
|
: new Date().toISOString();
|
|
|
|
const sanitizedVideoName = sanitizeFileName(videoFileName);
|
|
const videoKey = `videos/${id}/${sanitizedVideoName}`;
|
|
const mtvKey = `videos/${id}/${sanitizeFileName(mtv.name)}`;
|
|
|
|
await uploadObject(
|
|
mtvKey,
|
|
Buffer.from(mtvBuffer),
|
|
"application/octet-stream",
|
|
);
|
|
|
|
let tier: number | null | undefined;
|
|
let mapId: number | null | undefined;
|
|
let thumbnailKey: string | undefined;
|
|
const mapInfo = await getMapInfo(metadata.mapName);
|
|
mapId = mapInfo.mapId ?? undefined;
|
|
tier = mapInfo.tier;
|
|
|
|
if (mapInfo.thumbnailUrl) {
|
|
thumbnailKey = `videos/${id}/thumbnail.jpg`;
|
|
try {
|
|
const imgRes = await fetch(mapInfo.thumbnailUrl);
|
|
if (imgRes.ok) {
|
|
const imgBuf = Buffer.from(await imgRes.arrayBuffer());
|
|
await uploadObject(thumbnailKey, imgBuf, "image/jpeg");
|
|
} else {
|
|
thumbnailKey = undefined;
|
|
}
|
|
} catch {
|
|
thumbnailKey = undefined;
|
|
}
|
|
}
|
|
|
|
const title = `${metadata.mapName} - ${metadata.playerName}`;
|
|
const entry: VideoEntry = {
|
|
id,
|
|
title,
|
|
description: "",
|
|
mapName: metadata.mapName,
|
|
playerName: metadata.playerName,
|
|
steamId: metadata.steamId,
|
|
runTime: metadata.runTime,
|
|
totalTicks: metadata.totalTicks,
|
|
tickInterval: metadata.tickInterval,
|
|
videoKey,
|
|
mtvKey,
|
|
thumbnailKey,
|
|
tier,
|
|
mapId,
|
|
jsonStats: metadata.jsonStats,
|
|
createdAt,
|
|
};
|
|
|
|
insertVideo(entry);
|
|
|
|
const { uploadId } = await createMultipartUpload(
|
|
videoKey,
|
|
videoContentType,
|
|
);
|
|
|
|
const parts = await Promise.all(
|
|
Array.from({ length: numParts }, (_, i) => i + 1).map(
|
|
async (partNumber) => ({
|
|
partNumber,
|
|
url: await getPresignedUploadPartUrl(
|
|
videoKey,
|
|
uploadId,
|
|
partNumber,
|
|
),
|
|
}),
|
|
),
|
|
);
|
|
|
|
return {
|
|
...entry,
|
|
presignedUrls: {
|
|
parts,
|
|
uploadId,
|
|
},
|
|
videoUrl: getStreamUrl(id),
|
|
mtvUrl: getObjectUrl(mtvKey),
|
|
thumbnailUrl: thumbnailKey ? getObjectUrl(thumbnailKey) : undefined,
|
|
};
|
|
})
|
|
|
|
.post("/:id/complete", async ({ params: { id }, body }) => {
|
|
const video = getVideoById(id);
|
|
if (!video) return status(404, { error: "Not found" });
|
|
|
|
const { parts, uploadId } = body as {
|
|
parts: { partNumber: number; eTag: string }[];
|
|
uploadId: string;
|
|
};
|
|
if (!parts || !Array.isArray(parts) || !uploadId) {
|
|
return status(400, {
|
|
error: "parts array and uploadId are required",
|
|
});
|
|
}
|
|
|
|
const s3Parts = parts.map((p) => ({
|
|
PartNumber: p.partNumber,
|
|
ETag: p.eTag,
|
|
}));
|
|
|
|
await completeMultipartUpload(video.videoKey, uploadId, s3Parts);
|
|
|
|
// Auto-transcode in background
|
|
if (
|
|
!video.transcodedKey &&
|
|
video.transcodeStatus !== "pending" &&
|
|
video.transcodeStatus !== "processing"
|
|
) {
|
|
updateTranscodeStatus(id, "pending");
|
|
const videoKey = video.videoKey;
|
|
transcodeVideo(videoKey)
|
|
.then((transcodedKey) => {
|
|
updateTranscodedKey(id, transcodedKey);
|
|
updateTranscodeStatus(id, "done");
|
|
console.log(`[transcode] Done: ${id} -> ${transcodedKey}`);
|
|
})
|
|
.catch((err) => {
|
|
console.error(`[transcode] Failed: ${id}`, err);
|
|
updateTranscodeStatus(id, "failed");
|
|
});
|
|
}
|
|
|
|
return {
|
|
...video,
|
|
videoUrl: getStreamUrl(video.id),
|
|
mtvUrl: getObjectUrl(video.mtvKey),
|
|
thumbnailUrl: video.thumbnailKey
|
|
? getObjectUrl(video.thumbnailKey)
|
|
: undefined,
|
|
};
|
|
})
|
|
|
|
.post("/:id/transcode", async ({ params: { id } }) => {
|
|
const video = getVideoById(id);
|
|
if (!video) return status(404, { error: "Not found" });
|
|
|
|
if (
|
|
video.transcodeStatus === "processing" ||
|
|
video.transcodeStatus === "pending"
|
|
) {
|
|
return status(409, {
|
|
error: "Transcode already in progress",
|
|
transcodeStatus: video.transcodeStatus,
|
|
});
|
|
}
|
|
|
|
if (video.transcodedKey) {
|
|
return {
|
|
...video,
|
|
videoUrl: getStreamUrl(video.id),
|
|
mtvUrl: getObjectUrl(video.mtvKey),
|
|
thumbnailUrl: video.thumbnailKey
|
|
? getObjectUrl(video.thumbnailKey)
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
// Mark as pending and return immediately
|
|
updateTranscodeStatus(id, "pending");
|
|
|
|
// Run transcode in background
|
|
const videoKey = video.videoKey;
|
|
transcodeVideo(videoKey)
|
|
.then((transcodedKey) => {
|
|
updateTranscodedKey(id, transcodedKey);
|
|
updateTranscodeStatus(id, "done");
|
|
console.log(`[transcode] Done: ${id} -> ${transcodedKey}`);
|
|
})
|
|
.catch((err) => {
|
|
console.error(`[transcode] Failed: ${id}`, err);
|
|
updateTranscodeStatus(id, "failed");
|
|
});
|
|
|
|
return { id, transcodeStatus: "pending" };
|
|
})
|
|
|
|
.get("/:id/stream", async ({ params: { id }, request, set }) => {
|
|
const video = getVideoById(id);
|
|
if (!video) return status(404, { error: "Not found" });
|
|
|
|
const rangeHeader = request.headers.get("Range");
|
|
|
|
try {
|
|
const result = await getVideoStream(
|
|
video.transcodedKey ?? video.videoKey,
|
|
rangeHeader,
|
|
);
|
|
|
|
const headers = new Headers();
|
|
headers.set("Content-Type", result.contentType);
|
|
headers.set("Accept-Ranges", "bytes");
|
|
headers.set("Cache-Control", "public, max-age=86400");
|
|
|
|
if (result.range) {
|
|
headers.set(
|
|
"Content-Range",
|
|
`bytes ${result.range.start}-${result.range.end}/${result.contentLength}`,
|
|
);
|
|
headers.set(
|
|
"Content-Length",
|
|
String(result.range.end - result.range.start + 1),
|
|
);
|
|
return new Response(result.stream, {
|
|
status: 206,
|
|
headers,
|
|
});
|
|
} else {
|
|
headers.set("Content-Length", String(result.contentLength));
|
|
return new Response(result.stream, {
|
|
status: 200,
|
|
headers,
|
|
});
|
|
}
|
|
} catch (err: any) {
|
|
console.error("Stream error:", err);
|
|
return status(404, { error: "Video not found" });
|
|
}
|
|
})
|
|
|
|
.get("/:id", ({ params: { id } }) => {
|
|
const video = getVideoById(id);
|
|
if (!video) return status(404, { error: "Not found" });
|
|
return {
|
|
...video,
|
|
videoUrl: getStreamUrl(video.id),
|
|
mtvUrl: getObjectUrl(video.mtvKey),
|
|
thumbnailUrl: video.thumbnailKey
|
|
? getObjectUrl(video.thumbnailKey)
|
|
: undefined,
|
|
};
|
|
})
|
|
|
|
.post("/:id/refresh-info", async ({ params: { id } }) => {
|
|
const video = getVideoById(id);
|
|
if (!video) return status(404, { error: "Not found" });
|
|
|
|
const mapInfo = await getMapInfo(video.mapName);
|
|
|
|
let thumbnailKey = video.thumbnailKey;
|
|
if (!thumbnailKey && mapInfo.thumbnailUrl) {
|
|
thumbnailKey = `videos/${video.id}/thumbnail.jpg`;
|
|
try {
|
|
const imgRes = await fetch(mapInfo.thumbnailUrl);
|
|
if (imgRes.ok) {
|
|
const imgBuf = Buffer.from(await imgRes.arrayBuffer());
|
|
await uploadObject(thumbnailKey, imgBuf, "image/jpeg");
|
|
updateThumbnailKey(id, thumbnailKey);
|
|
} else {
|
|
thumbnailKey = undefined;
|
|
}
|
|
} catch {
|
|
thumbnailKey = undefined;
|
|
}
|
|
}
|
|
|
|
updateTier(id, mapInfo.tier);
|
|
|
|
return {
|
|
success: true,
|
|
tier: mapInfo.tier,
|
|
thumbnailUrl: thumbnailKey ? getObjectUrl(thumbnailKey) : undefined,
|
|
};
|
|
})
|
|
|
|
.post("/:id/refresh-thumbnail", async ({ params: { id } }) => {
|
|
const video = getVideoById(id);
|
|
if (!video) return status(404, { error: "Not found" });
|
|
|
|
const mapInfo = await getMapInfo(video.mapName);
|
|
if (!mapInfo.thumbnailUrl) {
|
|
return status(404, {
|
|
error: `No thumbnail found on Momentum Mod for ${video.mapName}`,
|
|
});
|
|
}
|
|
|
|
const thumbnailKey = `videos/${video.id}/thumbnail.jpg`;
|
|
try {
|
|
const imgRes = await fetch(mapInfo.thumbnailUrl);
|
|
if (!imgRes.ok)
|
|
return status(502, { error: "Failed to download thumbnail" });
|
|
const imgBuf = Buffer.from(await imgRes.arrayBuffer());
|
|
await uploadObject(thumbnailKey, imgBuf, "image/jpeg");
|
|
} catch {
|
|
return status(502, { error: "Failed to download thumbnail" });
|
|
}
|
|
|
|
updateThumbnailKey(id, thumbnailKey);
|
|
updateTier(id, mapInfo.tier);
|
|
|
|
return { success: true, thumbnailUrl: getObjectUrl(thumbnailKey) };
|
|
})
|
|
|
|
.delete("/:id", async ({ params: { id } }) => {
|
|
const video = getVideoById(id);
|
|
if (!video) return status(404, { error: "Not found" });
|
|
|
|
const deletes = [
|
|
deleteObject(video.videoKey),
|
|
deleteObject(video.mtvKey),
|
|
];
|
|
if (video.thumbnailKey) deletes.push(deleteObject(video.thumbnailKey));
|
|
if (video.transcodedKey)
|
|
deletes.push(deleteObject(video.transcodedKey));
|
|
await Promise.all(deletes);
|
|
|
|
deleteVideoById(id);
|
|
return { success: true };
|
|
});
|