Initial commit
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { mkdirSync } from "fs";
|
||||
import { dirname } from "path";
|
||||
import type { VideoEntry, VideoListItem, PreviousPB } from "../types/video";
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || "./data/surf.db";
|
||||
|
||||
let db: Database | null = null;
|
||||
|
||||
export function initDb(): void {
|
||||
if (db) return;
|
||||
|
||||
mkdirSync(dirname(DB_PATH), { recursive: true });
|
||||
|
||||
db = new Database(DB_PATH, { create: true });
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
db.exec("PRAGMA strict_mode = ON");
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS videos (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
map_name TEXT NOT NULL,
|
||||
player_name TEXT NOT NULL,
|
||||
steam_id TEXT NOT NULL,
|
||||
run_time REAL NOT NULL,
|
||||
total_ticks INTEGER NOT NULL,
|
||||
tick_interval REAL NOT NULL,
|
||||
video_key TEXT NOT NULL,
|
||||
mtv_key TEXT NOT NULL,
|
||||
thumbnail_key TEXT,
|
||||
tier INTEGER,
|
||||
json_stats TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_videos_map ON videos(map_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_videos_created ON videos(created_at DESC);
|
||||
`);
|
||||
|
||||
const columns = db.prepare("PRAGMA table_info(videos)").all() as {
|
||||
name: string;
|
||||
}[];
|
||||
if (!columns.some((col) => col.name === "tier")) {
|
||||
db.exec("ALTER TABLE videos ADD COLUMN tier INTEGER");
|
||||
}
|
||||
if (!columns.some((col) => col.name === "map_id")) {
|
||||
db.exec("ALTER TABLE videos ADD COLUMN map_id INTEGER");
|
||||
}
|
||||
if (!columns.some((col) => col.name === "rank")) {
|
||||
db.exec("ALTER TABLE videos ADD COLUMN rank INTEGER");
|
||||
}
|
||||
if (!columns.some((col) => col.name === "rank_updated_at")) {
|
||||
db.exec("ALTER TABLE videos ADD COLUMN rank_updated_at TEXT");
|
||||
}
|
||||
if (!columns.some((col) => col.name === "previous_pbs")) {
|
||||
db.exec("ALTER TABLE videos ADD COLUMN previous_pbs TEXT");
|
||||
}
|
||||
}
|
||||
|
||||
function getDb(): Database {
|
||||
if (!db) initDb();
|
||||
return db!;
|
||||
}
|
||||
|
||||
export function insertVideo(entry: VideoEntry): void {
|
||||
const d = getDb();
|
||||
d.prepare(
|
||||
`INSERT INTO videos (id, title, description, map_name, player_name, steam_id, run_time, total_ticks, tick_interval, video_key, mtv_key, thumbnail_key, tier, map_id, json_stats, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
entry.id,
|
||||
entry.title,
|
||||
entry.description,
|
||||
entry.mapName,
|
||||
entry.playerName,
|
||||
entry.steamId,
|
||||
entry.runTime,
|
||||
entry.totalTicks,
|
||||
entry.tickInterval,
|
||||
entry.videoKey,
|
||||
entry.mtvKey,
|
||||
entry.thumbnailKey ?? null,
|
||||
entry.tier ?? null,
|
||||
entry.mapId ?? null,
|
||||
entry.jsonStats ?? null,
|
||||
entry.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
export function getAllVideos(): VideoListItem[] {
|
||||
const d = getDb();
|
||||
return d
|
||||
.prepare(
|
||||
`SELECT id, title, map_name, player_name, run_time, tier, thumbnail_key, created_at
|
||||
FROM videos ORDER BY created_at DESC`,
|
||||
)
|
||||
.all()
|
||||
.map(rowToListItem);
|
||||
}
|
||||
|
||||
export function getVideoById(id: string): VideoEntry | null {
|
||||
const d = getDb();
|
||||
const row = d.prepare(`SELECT * FROM videos WHERE id = ?`).get(id);
|
||||
return row ? rowToEntry(row as any) : null;
|
||||
}
|
||||
|
||||
export function deleteVideoById(id: string): boolean {
|
||||
const d = getDb();
|
||||
const result = d.prepare(`DELETE FROM videos WHERE id = ?`).run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
function rowToListItem(row: any): VideoListItem {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
mapName: row.map_name,
|
||||
playerName: row.player_name,
|
||||
runTime: row.run_time,
|
||||
tier: row.tier ?? undefined,
|
||||
thumbnailUrl: row.thumbnail_key || undefined,
|
||||
createdAt: row.created_at,
|
||||
previousPbs: row.previous_pbs
|
||||
? JSON.parse(row.previous_pbs)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function rowToEntry(row: any): VideoEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
mapName: row.map_name,
|
||||
playerName: row.player_name,
|
||||
steamId: row.steam_id,
|
||||
runTime: row.run_time,
|
||||
totalTicks: row.total_ticks,
|
||||
tickInterval: row.tick_interval,
|
||||
videoKey: row.video_key,
|
||||
mtvKey: row.mtv_key,
|
||||
thumbnailKey: row.thumbnail_key ?? undefined,
|
||||
tier: row.tier ?? undefined,
|
||||
mapId: row.map_id ?? undefined,
|
||||
jsonStats: row.json_stats ?? undefined,
|
||||
createdAt: row.created_at,
|
||||
previousPbs: row.previous_pbs
|
||||
? JSON.parse(row.previous_pbs)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateThumbnailKey(
|
||||
id: string,
|
||||
thumbnailKey: string | null,
|
||||
): boolean {
|
||||
const d = getDb();
|
||||
const result = d
|
||||
.prepare("UPDATE videos SET thumbnail_key = ? WHERE id = ?")
|
||||
.run(thumbnailKey, id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function getVideoByMapAndPlayer(
|
||||
mapName: string,
|
||||
steamId: string,
|
||||
): VideoEntry | null {
|
||||
const d = getDb();
|
||||
const row = d
|
||||
.prepare(`SELECT * FROM videos WHERE map_name = ? AND steam_id = ?`)
|
||||
.get(mapName, steamId);
|
||||
return row ? rowToEntry(row as any) : null;
|
||||
}
|
||||
|
||||
export function updateVideoPB(
|
||||
id: string,
|
||||
entry: VideoEntry,
|
||||
previousPbs: PreviousPB[],
|
||||
): void {
|
||||
const d = getDb();
|
||||
d.prepare(
|
||||
`UPDATE videos SET
|
||||
title = ?,
|
||||
description = ?,
|
||||
map_name = ?,
|
||||
player_name = ?,
|
||||
steam_id = ?,
|
||||
run_time = ?,
|
||||
total_ticks = ?,
|
||||
tick_interval = ?,
|
||||
video_key = ?,
|
||||
mtv_key = ?,
|
||||
thumbnail_key = ?,
|
||||
tier = ?,
|
||||
map_id = ?,
|
||||
json_stats = ?,
|
||||
created_at = ?,
|
||||
previous_pbs = ?
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
entry.title,
|
||||
entry.description,
|
||||
entry.mapName,
|
||||
entry.playerName,
|
||||
entry.steamId,
|
||||
entry.runTime,
|
||||
entry.totalTicks,
|
||||
entry.tickInterval,
|
||||
entry.videoKey,
|
||||
entry.mtvKey,
|
||||
entry.thumbnailKey ?? null,
|
||||
entry.tier ?? null,
|
||||
entry.mapId ?? null,
|
||||
entry.jsonStats ?? null,
|
||||
entry.createdAt,
|
||||
JSON.stringify(previousPbs),
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateTier(id: string, tier: number | null): boolean {
|
||||
const d = getDb();
|
||||
const result = d
|
||||
.prepare("UPDATE videos SET tier = ? WHERE id = ?")
|
||||
.run(tier, id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
const MOMENTUM_API = "https://api.momentum-mod.org/v1";
|
||||
|
||||
interface MomentumMapThumbnail {
|
||||
id: string;
|
||||
small: string;
|
||||
medium: string;
|
||||
large: string;
|
||||
xl: string;
|
||||
}
|
||||
|
||||
interface MomentumMapLeaderboard {
|
||||
gamemode: number;
|
||||
trackType: number;
|
||||
trackNum: number;
|
||||
style: number;
|
||||
tier: number | null;
|
||||
tags: number[];
|
||||
type: number;
|
||||
linear: boolean | null;
|
||||
}
|
||||
|
||||
interface MomentumMapInfo {
|
||||
description: string;
|
||||
youtubeID: string;
|
||||
creationDate: string;
|
||||
approvedDate: string;
|
||||
requiredGames: number[];
|
||||
}
|
||||
|
||||
interface MomentumMap {
|
||||
id: number;
|
||||
name: string;
|
||||
thumbnail: MomentumMapThumbnail | null;
|
||||
images: MomentumMapThumbnail[];
|
||||
info: MomentumMapInfo | null;
|
||||
leaderboards: MomentumMapLeaderboard[];
|
||||
}
|
||||
|
||||
interface MomentumMapsResponse {
|
||||
totalCount: number;
|
||||
returnCount: number;
|
||||
data: MomentumMap[];
|
||||
}
|
||||
|
||||
export interface MapInfo {
|
||||
mapId: number | null;
|
||||
thumbnailUrl: string | null;
|
||||
tier: number | null;
|
||||
mapDate: string | null;
|
||||
}
|
||||
|
||||
const mapInfoCache = new Map<string, MapInfo>();
|
||||
|
||||
function extractSurfTier(
|
||||
leaderboards: MomentumMapLeaderboard[],
|
||||
): number | null {
|
||||
const mainLeaderboard = leaderboards.find(
|
||||
(lb) => lb.gamemode === 1 && lb.trackType === 0 && lb.style === 0,
|
||||
);
|
||||
return mainLeaderboard?.tier ?? null;
|
||||
}
|
||||
|
||||
export async function getMapInfo(mapName: string): Promise<MapInfo> {
|
||||
const cached = mapInfoCache.get(mapName);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const url = `${MOMENTUM_API}/maps?search=${encodeURIComponent(mapName)}&take=1&expand=info,leaderboards`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const info: MapInfo = {
|
||||
mapId: null,
|
||||
thumbnailUrl: null,
|
||||
tier: null,
|
||||
mapDate: null,
|
||||
};
|
||||
mapInfoCache.set(mapName, info);
|
||||
return info;
|
||||
}
|
||||
const json: MomentumMapsResponse = await res.json();
|
||||
const map = json.data?.[0];
|
||||
if (!map || map.name !== mapName) {
|
||||
const info: MapInfo = {
|
||||
mapId: null,
|
||||
thumbnailUrl: null,
|
||||
tier: null,
|
||||
mapDate: null,
|
||||
};
|
||||
mapInfoCache.set(mapName, info);
|
||||
return info;
|
||||
}
|
||||
const thumbnailUrl =
|
||||
map.thumbnail?.small ?? map.images?.[0]?.small ?? null;
|
||||
const tier = extractSurfTier(map.leaderboards ?? []);
|
||||
const mapDate = map.info?.creationDate ?? null;
|
||||
const info: MapInfo = { mapId: map.id, thumbnailUrl, tier, mapDate };
|
||||
mapInfoCache.set(mapName, info);
|
||||
return info;
|
||||
} catch {
|
||||
const info: MapInfo = {
|
||||
mapId: null,
|
||||
thumbnailUrl: null,
|
||||
tier: null,
|
||||
mapDate: null,
|
||||
};
|
||||
mapInfoCache.set(mapName, info);
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMapThumbnail(mapName: string): Promise<string | null> {
|
||||
const info = await getMapInfo(mapName);
|
||||
return info.thumbnailUrl;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { MtvHeader, MtvMetadata } from "../types/video";
|
||||
|
||||
const MTV_MAGIC = 0x56544d4d;
|
||||
const HEADER_SIZE = 0xc3;
|
||||
|
||||
function readNullTerminatedString(buffer: ArrayBuffer, offset: number, maxLen: number): string {
|
||||
const view = new Uint8Array(buffer, offset, maxLen);
|
||||
let end = 0;
|
||||
while (end < maxLen && view[end] !== 0) end++;
|
||||
return new TextDecoder().decode(view.slice(0, end));
|
||||
}
|
||||
|
||||
export function parseMtvHeader(buffer: ArrayBuffer): MtvHeader | null {
|
||||
if (buffer.byteLength < HEADER_SIZE) return null;
|
||||
|
||||
const view = new DataView(buffer);
|
||||
|
||||
const magic = view.getUint32(0, true);
|
||||
if (magic !== MTV_MAGIC) return null;
|
||||
|
||||
const version = view.getUint32(4, true);
|
||||
const unknown08 = view.getFloat32(8, true);
|
||||
const unknown0c = view.getUint32(0x0c, true);
|
||||
const mapName = readNullTerminatedString(buffer, 0x10, 64);
|
||||
const mapHash = readNullTerminatedString(buffer, 0x50, 41);
|
||||
const unknown79 = view.getUint8(0x79);
|
||||
const compression = view.getUint8(0x7a);
|
||||
const tickInterval = view.getFloat32(0x7b, true);
|
||||
const steamId = view.getBigUint64(0x7f, true);
|
||||
const playerName = readNullTerminatedString(buffer, 0x87, 32);
|
||||
const unknownA7 = view.getUint8(0xa7);
|
||||
const unknownA8 = view.getUint8(0xa8);
|
||||
const runTime = view.getFloat64(0xa9, true);
|
||||
const totalTicks = view.getUint32(0xb1, true);
|
||||
const seekEntryCount = view.getUint32(0xb5, true);
|
||||
const baselineCount = view.getUint32(0xb9, true);
|
||||
const entityTypeCount = view.getUint8(0xbd);
|
||||
const tempEntityCount = view.getUint8(0xbe);
|
||||
const stringTableCount = view.getUint8(0xbf);
|
||||
|
||||
return {
|
||||
magic,
|
||||
version,
|
||||
unknown08,
|
||||
unknown0c,
|
||||
mapName,
|
||||
mapHash,
|
||||
unknown79,
|
||||
compression,
|
||||
tickInterval,
|
||||
steamId,
|
||||
playerName,
|
||||
unknownA7,
|
||||
unknownA8,
|
||||
runTime,
|
||||
totalTicks,
|
||||
seekEntryCount,
|
||||
baselineCount,
|
||||
entityTypeCount,
|
||||
tempEntityCount,
|
||||
stringTableCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMtvFile(buffer: ArrayBuffer): MtvMetadata | null {
|
||||
const header = parseMtvHeader(buffer);
|
||||
if (!header) return null;
|
||||
|
||||
let jsonStats: string | undefined;
|
||||
|
||||
if (buffer.byteLength > HEADER_SIZE + 4) {
|
||||
const view = new DataView(buffer);
|
||||
const jsonLen = view.getUint32(HEADER_SIZE, true);
|
||||
if (jsonLen > 0 && buffer.byteLength >= HEADER_SIZE + 4 + jsonLen) {
|
||||
const jsonBytes = new Uint8Array(buffer, HEADER_SIZE + 4, jsonLen - 1);
|
||||
jsonStats = new TextDecoder().decode(jsonBytes);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mapName: header.mapName,
|
||||
mapHash: header.mapHash,
|
||||
playerName: header.playerName,
|
||||
steamId: header.steamId.toString(),
|
||||
runTime: header.runTime,
|
||||
totalTicks: header.totalTicks,
|
||||
tickInterval: header.tickInterval,
|
||||
version: header.version,
|
||||
jsonStats,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRunTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toFixed(3).padStart(6, "0")}`;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
const RUSTFS_ENDPOINT = process.env.RUSTFS_ENDPOINT || "http://localhost:9000";
|
||||
const RUSTFS_ACCESS_KEY = process.env.RUSTFS_ACCESS_KEY || "minioadmin";
|
||||
const RUSTFS_SECRET_KEY = process.env.RUSTFS_SECRET_KEY || "minioadmin";
|
||||
const RUSTFS_BUCKET = process.env.RUSTFS_BUCKET || "surf";
|
||||
const RUSTFS_PUBLIC = (process.env.RUSTFS_PUBLIC || "true") === "true";
|
||||
|
||||
let client: S3Client | null = null;
|
||||
|
||||
function getRustFsClient(): S3Client {
|
||||
if (!client) {
|
||||
client = new S3Client({
|
||||
endpoint: RUSTFS_ENDPOINT,
|
||||
region: "us-east-1",
|
||||
credentials: {
|
||||
accessKeyId: RUSTFS_ACCESS_KEY,
|
||||
secretAccessKey: RUSTFS_SECRET_KEY,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
function getPublicUrl(key: string): string {
|
||||
if (RUSTFS_PUBLIC) {
|
||||
return `${RUSTFS_ENDPOINT}/${RUSTFS_BUCKET}/${key}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function getObjectUrl(key: string): string {
|
||||
const publicUrl = getPublicUrl(key);
|
||||
if (publicUrl) return publicUrl;
|
||||
return `/api/videos/file/${encodeURIComponent(key)}`;
|
||||
}
|
||||
|
||||
export async function uploadObject(
|
||||
key: string,
|
||||
body: Buffer,
|
||||
contentType: string,
|
||||
): Promise<void> {
|
||||
const s3 = getRustFsClient();
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: RUSTFS_BUCKET,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteObject(key: string): Promise<void> {
|
||||
const s3 = getRustFsClient();
|
||||
await s3.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: RUSTFS_BUCKET,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPresignedUploadUrl(
|
||||
key: string,
|
||||
contentType: string,
|
||||
expiresInSeconds = 3600,
|
||||
): Promise<string> {
|
||||
const s3 = getRustFsClient();
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: RUSTFS_BUCKET,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
});
|
||||
return getSignedUrl(s3, command, { expiresIn: expiresInSeconds });
|
||||
}
|
||||
Reference in New Issue
Block a user