Change /video/ route to /run/, add embed support
This commit is contained in:
@@ -0,0 +1,156 @@
|
|||||||
|
import { getVideoById } from "./services/db";
|
||||||
|
import { getStreamUrl, getObjectUrl } from "./services/rustfs";
|
||||||
|
import type { VideoEntry } from "./types/video";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
|
||||||
|
const BASE_URL = process.env.BASE_URL || "https://surf.nathan.rip";
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRunTime(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins}:${secs.toFixed(3).padStart(6, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDisplayName(mapName: string): string {
|
||||||
|
return mapName.replace(/_/g, " ").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageTitle(video: VideoEntry): string {
|
||||||
|
return `${mapDisplayName(video.mapName)} — ${video.playerName} · surf.nathan.rip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ogTitle(video: VideoEntry): string {
|
||||||
|
return mapDisplayName(video.mapName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ogDescription(video: VideoEntry): string {
|
||||||
|
const parts: string[] = [formatRunTime(video.runTime)];
|
||||||
|
if (video.tier != null) parts.push(`Tier ${video.tier}`);
|
||||||
|
return `${video.playerName} on ${mapDisplayName(video.mapName)} · ${parts.join(" · ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOgsHtml(video: VideoEntry): string {
|
||||||
|
const runUrl = `${BASE_URL}/run/${video.id}`;
|
||||||
|
const title = ogTitle(video);
|
||||||
|
const description = ogDescription(video);
|
||||||
|
const oembedUrl = `${BASE_URL}/api/oembed/${video.id}`;
|
||||||
|
|
||||||
|
const thumbnailUrl = video.thumbnailKey
|
||||||
|
? getObjectUrl(video.thumbnailKey)
|
||||||
|
: undefined;
|
||||||
|
const videoStreamUrl = `${BASE_URL}${getStreamUrl(video.id)}`;
|
||||||
|
|
||||||
|
let tags =
|
||||||
|
// oEmbed discovery — lets Discord (and others) find the JSON endpoint
|
||||||
|
` <link rel="alternate" type="application/json+oembed" href="${escapeHtml(oembedUrl)}" title="${escapeHtml(title)}" />\n` +
|
||||||
|
` <meta property="og:title" content="${escapeHtml(title)}" />\n` +
|
||||||
|
` <meta property="og:description" content="${escapeHtml(description)}" />\n` +
|
||||||
|
` <meta property="og:url" content="${runUrl}" />\n` +
|
||||||
|
` <meta property="og:type" content="video.other" />\n` +
|
||||||
|
` <meta property="og:site_name" content="surf.nathan.rip" />`;
|
||||||
|
|
||||||
|
if (thumbnailUrl) {
|
||||||
|
tags +=
|
||||||
|
`\n <meta property="og:image" content="${thumbnailUrl}" />\n` +
|
||||||
|
` <meta property="og:image:width" content="1280" />\n` +
|
||||||
|
` <meta property="og:image:height" content="720" />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video embed — Discord & Facebook
|
||||||
|
tags +=
|
||||||
|
`\n <meta property="og:video" content="${videoStreamUrl}" />\n` +
|
||||||
|
` <meta property="og:video:type" content="video/mp4" />\n` +
|
||||||
|
` <meta property="og:video:width" content="1280" />\n` +
|
||||||
|
` <meta property="og:video:height" content="720" />`;
|
||||||
|
|
||||||
|
// Twitter / X
|
||||||
|
tags +=
|
||||||
|
`\n <meta name="twitter:card" content="summary_large_image" />\n` +
|
||||||
|
` <meta name="twitter:title" content="${escapeHtml(title)}" />\n` +
|
||||||
|
` <meta name="twitter:description" content="${escapeHtml(description)}" />`;
|
||||||
|
|
||||||
|
if (thumbnailUrl) {
|
||||||
|
tags += `\n <meta name="twitter:image" content="${thumbnailUrl}" />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steam uses the same og: tags, nothing extra needed
|
||||||
|
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OembedResponse {
|
||||||
|
type: string;
|
||||||
|
version: string;
|
||||||
|
title: string;
|
||||||
|
author_name: string;
|
||||||
|
author_url: string;
|
||||||
|
provider_name: string;
|
||||||
|
provider_url: string;
|
||||||
|
thumbnail_url?: string;
|
||||||
|
html?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOembed(id: string): OembedResponse | null {
|
||||||
|
const video = getVideoById(id);
|
||||||
|
if (!video) return null;
|
||||||
|
|
||||||
|
const thumbnailUrl = video.thumbnailKey
|
||||||
|
? getObjectUrl(video.thumbnailKey)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "video",
|
||||||
|
version: "1.0",
|
||||||
|
title: ogTitle(video),
|
||||||
|
author_name: video.playerName,
|
||||||
|
author_url: `https://dashboard.momentum-mod.org/maps/${video.mapName}`,
|
||||||
|
provider_name: "surf.nathan.rip",
|
||||||
|
provider_url: BASE_URL,
|
||||||
|
thumbnail_url: thumbnailUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a given run ID, return the SPA's index.html with OG meta tags
|
||||||
|
* injected into <head> and the <title> updated. If the run doesn't exist,
|
||||||
|
* returns the plain SPA html (React Router will show a 404). If the
|
||||||
|
* static index.html isn't available, returns null.
|
||||||
|
*/
|
||||||
|
export function renderRunPage(
|
||||||
|
id: string,
|
||||||
|
staticDir: string,
|
||||||
|
): { html: string; contentType: string } | null {
|
||||||
|
const indexPath = `${staticDir}/index.html`;
|
||||||
|
|
||||||
|
let indexHtml: string;
|
||||||
|
try {
|
||||||
|
indexHtml = readFileSync(indexPath, "utf-8");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const video = getVideoById(id);
|
||||||
|
if (!video) {
|
||||||
|
// Serve the plain SPA — React Router handles the 404
|
||||||
|
return { html: indexHtml, contentType: "text/html" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ogTags = buildOgsHtml(video);
|
||||||
|
const title = pageTitle(video);
|
||||||
|
const enhancedHtml = indexHtml
|
||||||
|
.replace("</head>", `${ogTags}\n </head>`)
|
||||||
|
.replace(
|
||||||
|
/<title>[^<]*<\/title>/,
|
||||||
|
`<title>${escapeHtml(title)}</title>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { html: enhancedHtml, contentType: "text/html" };
|
||||||
|
}
|
||||||
+12
-21
@@ -2,8 +2,7 @@ import { Elysia, status } from "elysia";
|
|||||||
import { cors } from "@elysiajs/cors";
|
import { cors } from "@elysiajs/cors";
|
||||||
import { videoRoutes } from "./routes/videos";
|
import { videoRoutes } from "./routes/videos";
|
||||||
import { initDb } from "./services/db";
|
import { initDb } from "./services/db";
|
||||||
import { join } from "path";
|
import { renderRunPage, getOembed } from "./embeds";
|
||||||
import { existsSync, statSync } from "fs";
|
|
||||||
|
|
||||||
initDb();
|
initDb();
|
||||||
|
|
||||||
@@ -16,28 +15,20 @@ const app = new Elysia()
|
|||||||
exposeHeaders: ["Content-Range", "Accept-Ranges", "Content-Length"],
|
exposeHeaders: ["Content-Range", "Accept-Ranges", "Content-Length"],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.onError(({ error }) => {
|
|
||||||
console.error("Unhandled error:", error);
|
|
||||||
return status(500, { error: "Internal server error" });
|
|
||||||
})
|
|
||||||
.use(videoRoutes)
|
.use(videoRoutes)
|
||||||
.get("/api/health", () => ({ status: "ok" }))
|
.get("/api/health", () => ({ status: "ok" }))
|
||||||
.get("*", ({ request }) => {
|
.get("/api/oembed/:id", ({ params: { id } }) => {
|
||||||
const url = new URL(request.url);
|
const oembed = getOembed(id);
|
||||||
const pathname = url.pathname;
|
if (!oembed) return status(404, { error: "Not found" });
|
||||||
|
return oembed;
|
||||||
if (pathname.startsWith("/api")) return status(404, "Not found");
|
})
|
||||||
|
.get("/run/:id", ({ params: { id } }) => {
|
||||||
const filePath = join(STATIC_DIR, pathname);
|
const result = renderRunPage(id, STATIC_DIR);
|
||||||
if (existsSync(filePath) && statSync(filePath).isFile())
|
if (!result) return status(404, "Not found");
|
||||||
return Bun.file(filePath);
|
return new Response(result.html, {
|
||||||
|
headers: { "Content-Type": result.contentType },
|
||||||
const indexPath = join(STATIC_DIR, "index.html");
|
});
|
||||||
if (existsSync(indexPath)) return Bun.file(indexPath);
|
|
||||||
|
|
||||||
return status(404, "Not found");
|
|
||||||
})
|
})
|
||||||
.listen(PORT);
|
.listen(PORT);
|
||||||
|
|
||||||
console.log(`surf.nathan.rip running on port ${PORT}`);
|
console.log(`surf.nathan.rip running on port ${PORT}`);
|
||||||
console.log(`Serving static files from ${STATIC_DIR}`);
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route index element={<Home />} />
|
<Route index element={<Home />} />
|
||||||
<Route path="video/:id" element={<VideoDetail />} />
|
<Route path="run/:id" element={<VideoDetail />} />
|
||||||
<Route path="upload" element={<Upload />} />
|
<Route path="upload" element={<Upload />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function VideoCard({ video }: { video: VideoListItem }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/video/${video.id}`}
|
to={`/run/${video.id}`}
|
||||||
className="group flex h-full flex-col shadow no-underline"
|
className="group flex h-full flex-col shadow no-underline"
|
||||||
>
|
>
|
||||||
<div className="relative aspect-video overflow-hidden rounded-t">
|
<div className="relative aspect-video overflow-hidden rounded-t">
|
||||||
|
|||||||
@@ -66,8 +66,14 @@ export default function VideoDetail() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
fetchVideo(id)
|
fetchVideo(id)
|
||||||
.then(setVideo)
|
.then((v) => {
|
||||||
|
setVideo(v);
|
||||||
|
document.title = `${getMapDisplayName(v.mapName)} — ${v.playerName} · surf.nathan.rip`;
|
||||||
|
})
|
||||||
.catch((err) => setError(err.message));
|
.catch((err) => setError(err.message));
|
||||||
|
return () => {
|
||||||
|
document.title = "surf.nathan.rip";
|
||||||
|
};
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user