This commit is contained in:
2026-06-21 03:53:06 +03:00
commit f1dd557c5d
147 changed files with 34363 additions and 0 deletions
+793
View File
@@ -0,0 +1,793 @@
import config from "./config.ts";
import fs from "node:fs";
import express, { type Response } from "express";
import bodyParser from "body-parser";
import compression from "compression";
import cors from "cors";
import https from "node:https";
import http from "node:http";
import { Server } from "socket.io";
import { searchYoutube, youtubePlaylist } from "./utils/youtube.ts";
import { Room } from "./room.ts";
import { redis, redisCount } from "./utils/redis.ts";
import {
getCustomerByEmail,
createSelfServicePortal,
getIsSubscriberByEmail,
} from "./utils/stripe.ts";
import { deleteUser, validateUserToken } from "./utils/firebase.ts";
import path from "node:path";
import { getStartOfDay } from "./utils/time.ts";
import { getSessionLimitSeconds } from "./vm/utils.ts";
import { postgres, insertObject, upsertObject } from "./utils/postgres.ts";
import axios, { isAxiosError } from "axios";
import crypto from "node:crypto";
import { gzipSync } from "node:zlib";
import { resolveShard } from "./utils/resolveShard.ts";
import { makeRoomName, makeUserName } from "./utils/moniker.ts";
import { getStats } from "./utils/getStats.ts";
if (process.env.NODE_ENV === "development") {
axios.interceptors.request.use(
(config) => {
// console.log(config);
return config;
},
(error) => {
console.error(error);
},
);
}
const releaseInterval = 5 * 60 * 1000;
const app = express();
let server = null as https.Server | http.Server | null;
if (config.SSL_KEY_FILE && config.SSL_CRT_FILE) {
const key = fs.readFileSync(config.SSL_KEY_FILE);
const cert = fs.readFileSync(config.SSL_CRT_FILE);
server = https.createServer({ key: key, cert: cert }, app);
} else {
server = new http.Server(app);
}
server?.listen(config.PORT, config.HOST);
const io = new Server(server, { cors: {}, transports: ["websocket"] });
io.engine.use(async (req: any, res: Response, next: () => void) => {
const roomId = req._query.roomId;
if (!roomId) {
return next();
}
// Attempt to ensure the room being connected to is loaded in memory
// If it doesn't exist, we may fail later with "invalid namespace"
const shard = resolveShard(roomId);
const key = "/" + roomId;
// Check to make sure this shard should load this room
const isCorrectShard = !config.SHARD || shard === Number(config.SHARD);
// Get the room data from postgres
const persistedRoom = (
await postgres?.query<PersistentRoom>(
`SELECT * from room where "roomId" = $1`,
[key],
)
)?.rows?.[0];
// Don't await after this because we may have a race condition where 2 rquests both try to load the room
if (isCorrectShard && !rooms.has(key)) {
const data = persistedRoom?.data
? JSON.stringify(persistedRoom.data)
: undefined;
if (data) {
const room = new Room(io, key, data);
rooms.set(key, room);
console.log(
"loading room %s into memory on shard %s",
roomId,
config.SHARD,
);
}
}
next();
});
const rooms = new Map<string, Room>();
// Following functions iterate over in-memory rooms
setInterval(minuteMetrics, 60 * 1000);
setInterval(release, releaseInterval);
setInterval(saveRooms, 1000);
if (process.env.NODE_ENV === "development") {
try {
import("./vmWorker.ts");
// import('./syncSubs.ts');
// import('./timeSeries.ts');
} catch (e) {
console.error(e);
}
}
app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.raw({ type: "text/plain", limit: 1000000 }));
app.get("/ping", (_req, res) => {
res.json("pong");
});
// Data's already compressed so go before the compression middleware
app.get("/subtitle/:hash", async (req, res) => {
const key = "subtitle:" + req.params.hash;
const buf = await redis?.getBuffer(key);
if (!buf) {
res.status(404).end("not found");
return;
}
await redis?.expire(key, 24 * 60 * 60);
res.setHeader("Content-Encoding", "gzip");
res.end(buf);
});
app.use(compression());
app.post("/subtitle", async (req, res) => {
const data = req.body;
if (!redis) {
return;
}
// calculate hash, gzip and save to redis
const hash = crypto
.createHash("sha256")
.update(data, "utf8")
.digest()
.toString("hex");
let gzipData = gzipSync(data);
await redis.setex("subtitle:" + hash, 24 * 60 * 60, gzipData);
redisCount("subUploads");
res.json({ hash });
});
app.get("/downloadSubtitles", async (req, res) => {
// Request the URL from OS
try {
const urlResp = await axios<{ link: string }>({
url: "https://api.opensubtitles.com/api/v1/download",
method: "POST",
headers: {
"User-Agent": "watchparty v1",
"Api-Key": config.OPENSUBTITLES_KEY,
Accept: "application/json",
"Content-Type": "application/json",
// 'Authorization': 'Bearer ' + config.OPENSUBTITLES_KEY,
},
data: {
file_id: req.query.file_id,
// sub_format: 'srt',
},
});
redisCount("subDownloadsOS");
if (!redis) {
// Return the direct link to the user, will work for about 3 hours
res.json(urlResp.data);
return;
}
// Cache the contents in Redis (longer retention)
const subResp = await axios.get(urlResp.data.link, {
responseType: "arraybuffer",
});
const data = subResp.data;
const hash = crypto
.createHash("sha256")
.update(data, "utf8")
.digest()
.toString("hex");
let gzipData = gzipSync(data);
await redis.setex("subtitle:" + hash, 24 * 60 * 60, gzipData);
res.json({ link: "/subtitle/" + hash });
} catch (e) {
if (isAxiosError(e)) {
console.log(e.response);
}
throw e;
}
});
app.get("/searchSubtitles", async (req, res) => {
try {
const title = req.query.title ? String(req.query.title) : "";
const url = req.query.url ? String(req.query.url) : "";
let subUrl = "";
if (url) {
const startResp = await axios({
method: "get",
url: url,
headers: {
Range: "bytes=0-65535",
},
responseType: "arraybuffer",
});
const start = startResp.data;
const size = Number(startResp.headers["content-range"].split("/")[1]);
const endResp = await axios({
method: "get",
url: url,
headers: {
Range: `bytes=${size - 65536}-`,
},
responseType: "arraybuffer",
});
const end = endResp.data;
// console.log(start, end, size);
let hash = computeOpenSubtitlesHash(start, end, size);
// hash = 'f65334e75574f00f';
// Search API for subtitles by hash
subUrl = `https://api.opensubtitles.com/api/v1/subtitles?moviehash=${hash}&languages=en`;
} else if (title) {
subUrl = `https://api.opensubtitles.com/api/v1/subtitles?query=${title}&languages=en`;
}
// Alternative, web client calls this to get back some JS with the download URL embedded
// https://www.opensubtitles.com/nocache/download/7585196/subreq.js?file_name=Borgen.S04E01.en&locale=en&np=true&sub_frmt=srt&subtitle_id=6615808&ext_installed=false
// Up to 10 downloads per IP per day, but proxyable and doesn't require key
const response = await axios.get(subUrl, {
headers: {
"User-Agent": "watchparty v1",
"Api-Key": config.OPENSUBTITLES_KEY,
},
});
// console.log(subUrl, response.data);
const subtitles = response.data;
res.json(subtitles.data);
} catch (e: any) {
console.error(e.message);
res.json([]);
}
redisCount("subSearchesOS");
});
app.get("/stats", async (req, res) => {
if (req.query.key && req.query.key === config.STATS_KEY) {
const stats = await getStats();
res.json(stats);
} else {
res.status(403).json({ error: "Access Denied" });
}
});
app.get("/health/:metric", async (req, res) => {
const vmManagerStats = (
await axios.get("http://localhost:" + config.VMWORKER_PORT + "/stats")
).data;
const result = vmManagerStats[req.params.metric]?.availableVBrowsers?.length;
res.status(result ? 200 : 500).json(result);
});
app.get("/timeSeries", async (req, res) => {
if (req.query.key && req.query.key === config.STATS_KEY && redis) {
const timeSeriesData = await redis.lrange("timeSeries", 0, -1);
const timeSeries = timeSeriesData.map((entry) => JSON.parse(entry));
res.json(timeSeries);
} else {
res.status(403).json({ error: "Access Denied" });
}
});
app.get("/youtube", async (req, res) => {
if (typeof req.query.q === "string") {
try {
redisCount("youtubeSearch");
const items = await searchYoutube(req.query.q);
res.json(items);
} catch {
res.status(500).json({ error: "youtube error" });
}
} else {
res.status(500).json({ error: "query must be a string" });
}
});
app.get("/youtubePlaylist/:playlistId", async (req, res) => {
try {
const items = await youtubePlaylist(req.params.playlistId);
res.json(items);
} catch {
res.status(500).json({ error: "youtube error" });
}
});
app.post("/createRoom", async (req, res) => {
const genName = () => "/" + makeRoomName(config.SHARD);
let name = genName();
console.log("createRoom: ", name);
const newRoom = new Room(io, name);
if (postgres) {
const now = new Date();
const roomObj = {
roomId: newRoom.roomId,
lastUpdateTime: now,
creationTime: now,
};
try {
await insertObject(postgres, "room", roomObj);
} catch (e) {
redisCount("createRoomError");
throw e;
}
}
const decoded = await validateUserToken(req.body?.uid, req.body?.token);
newRoom.creator = decoded?.email;
const preload = (req.body?.video || "").slice(0, 20000);
if (preload) {
redisCount("createRoomPreload");
newRoom.video = preload;
newRoom.paused = true;
await newRoom.saveRoom();
}
const prePlaylist = Array.isArray(req.body?.playlist) && req.body?.playlist;
if (prePlaylist) {
for (let item of req.body.playlist) {
newRoom.playlistAdd(null, item);
}
}
rooms.set(name, newRoom);
res.json({ name });
});
app.post("/manageSub", async (req, res) => {
const decoded = await validateUserToken(
String(req.body?.uid),
String(req.body?.token),
);
if (!decoded) {
res.status(400).json({ error: "invalid user token" });
return;
}
if (!decoded.email) {
res.status(400).json({ error: "no email found" });
return;
}
const customer = await getCustomerByEmail(decoded.email);
if (!customer) {
res.status(400).json({ error: "customer not found" });
return;
}
const session = await createSelfServicePortal(
customer.id,
req.body?.return_url,
);
res.json(session);
});
app.delete("/deleteAccount", async (req, res) => {
// TODO pass this in req.query instead
const decoded = await validateUserToken(req.body?.uid, req.body?.token);
if (!decoded) {
res.status(400).json({ error: "invalid user token" });
return;
}
if (postgres) {
// Delete rooms
await postgres.query("DELETE FROM room WHERE owner = $1", [decoded.uid]);
// Delete linked accounts
await postgres.query("DELETE FROM link_account WHERE uid = $1", [
decoded.uid,
]);
}
await deleteUser(decoded.uid);
redisCount("deleteAccount");
res.json({});
});
app.get("/metadata", async (req, res) => {
const decoded = await validateUserToken(
String(req.query?.uid),
String(req.query?.token),
);
let isSubscriber = await getIsSubscriberByEmail(decoded?.email);
// Has the user ever been a subscriber?
// const customer = await getCustomerByEmail(decoded.email);
let isFreePoolFull = false;
try {
isFreePoolFull = (
await axios.get(
"http://localhost:" + config.VMWORKER_PORT + "/isFreePoolFull",
)
).data.isFull;
} catch (e: any) {
console.warn("[WARNING]: free pool check failed: %s", e.code);
}
const beta =
decoded?.email != null &&
Boolean(config.BETA_USER_EMAILS.split(",").includes(decoded?.email));
const streamPath = beta ? config.STREAM_PATH : undefined;
const convertPath = isSubscriber ? config.CONVERT_PATH : undefined;
// log metrics but don't wait for it
if (postgres && decoded?.uid) {
upsertObject(
postgres,
"active_user",
{ uid: decoded?.uid, lastActiveTime: new Date() },
{ uid: true },
);
}
res.json({
isSubscriber,
isFreePoolFull,
beta,
streamPath,
convertPath,
});
});
app.get("/resolveRoom/:vanity", async (req, res) => {
const vanity = req.params.vanity;
const result = await postgres?.query(
`SELECT "roomId", vanity from room WHERE LOWER(vanity) = $1`,
[vanity?.toLowerCase() ?? ""],
);
// console.log(vanity, result.rows);
// We also use this for checking name availability, so just return null if it doesn't exist (http 200)
res.json(result?.rows[0] ?? null);
});
app.get("/roomData/:roomId", async (req, res) => {
// Returns the room data given a room ID
// Only return data if the room doesn't have a password
// If it does, we could accept it as a URL parameter but for now just don't support
const result = await postgres?.query(
`SELECT data from room WHERE "roomId" = $1 and password IS NULL`,
["/" + req.params.roomId],
);
res.json(result?.rows[0]?.data);
});
app.get("/resolveShard/:roomId", async (req, res) => {
const shardNum = resolveShard(req.params.roomId);
res.send(String(config.SHARD ? shardNum : ""));
});
app.get("/listRooms", async (req, res) => {
const decoded = await validateUserToken(
String(req.query?.uid),
String(req.query?.token),
);
if (!decoded) {
res.status(400).json({ error: "invalid user token" });
return;
}
const result = await postgres?.query<PersistentRoom>(
`SELECT "roomId", vanity, password from room WHERE owner = $1`,
[decoded.uid],
);
res.json(result?.rows ?? []);
});
app.delete("/deleteRoom", async (req, res) => {
const decoded = await validateUserToken(
String(req.query?.uid),
String(req.query?.token),
);
if (!decoded) {
res.status(400).json({ error: "invalid user token" });
return;
}
const result = await postgres?.query(
`DELETE from room WHERE owner = $1 and "roomId" = $2`,
[decoded.uid, req.query.roomId],
);
res.json(result?.rows);
});
app.get("/linkAccount", async (req, res) => {
const decoded = await validateUserToken(
String(req.query?.uid),
String(req.query?.token),
);
if (!decoded) {
res.status(400).json({ error: "invalid user token" });
return;
}
if (!postgres) {
res.status(400).json({ error: "invalid database client" });
return;
}
// Get the linked accounts for the user
let linkAccounts: LinkAccount[] = [];
if (decoded?.uid && postgres) {
const { rows } = await postgres.query(
"SELECT kind, accountid, accountname, discriminator FROM link_account WHERE uid = $1",
[decoded?.uid],
);
linkAccounts = rows;
}
res.json(linkAccounts);
});
app.post("/linkAccount", async (req, res) => {
const decoded = await validateUserToken(
String(req.body?.uid),
String(req.body?.token),
);
if (!decoded) {
res.status(400).json({ error: "invalid user token" });
return;
}
if (!postgres) {
res.status(400).json({ error: "invalid database client" });
return;
}
const kind = req.body?.kind;
if (kind === "discord") {
const tokenType = req.body?.tokenType;
const accessToken = req.body.accessToken;
// Get the token and verify the user
const response = await axios.get("https://discord.com/api/users/@me", {
headers: {
authorization: `${tokenType} ${accessToken}`,
},
});
const accountid = response.data.id;
const accountname = response.data.username;
const discriminator = response.data.discriminator;
// Store the user id, username, discriminator
await upsertObject(
postgres,
"link_account",
{
accountid: accountid,
accountname: accountname,
discriminator: discriminator,
uid: decoded.uid,
kind: kind,
},
{ uid: true, kind: true },
);
res.json({});
} else {
res.status(400).json({ error: "unsupported kind" });
}
});
app.delete("/linkAccount", async (req, res) => {
// TODO read from req.query instead
const decoded = await validateUserToken(
String(req.body?.uid),
String(req.body?.token),
);
if (!decoded) {
res.status(400).json({ error: "invalid user token" });
return;
}
if (!postgres) {
res.status(400).json({ error: "invalid database client" });
return;
}
await postgres.query(
"DELETE FROM link_account WHERE uid = $1 AND kind = $2",
[decoded.uid, req.body.kind],
);
res.json({});
});
app.get("/generateName", async (req, res) => {
res.send(makeUserName());
});
// Proxy video segments
app.get("/proxy/*splat", async (req, res) => {
redisCount("proxyReqs");
try {
const parsed = new URL("http://localhost" + req.url);
const pathname = parsed.pathname.slice("/proxy".length);
const host = parsed.searchParams.get("host");
if (pathname.endsWith("index-dvr.m3u8")) {
// VOD
// https://d2vjef5jvl6bfs.cloudfront.net/3012391a6c3e84c79ef6_gamesdonequick_41198403369_1681059003/chunked/index-dvr.m3u8
const resp = await axios.get("https://" + host + pathname);
const re2 = /(.*.ts)/g;
let repl = resp.data.replaceAll(re2, `$1?host=${host}`);
// Mark this as a VOD
repl += "#EXT-X-ENDLIST";
res.send(repl);
} else if (pathname.endsWith(".m3u8")) {
// Stream
// https://video-weaver.sea02.hls.ttvnw.net/v1/playlist/CrQEgv7Mz6nnsfJH3XtVQxeYXk8mViy1zNGWglcybvxZsI1rv3iLnjAnnqwCiVXCJ-DdD27J6RuFrLy7YUYwHUCKazIKICIupUCn9UXtaBYhBM5JIYqg9dz6NWYrCWU9HZJj2TGROv9mAOKuTR51YS82hdYL4PFZa3xxWXhgDsxXQHNDB03kY6S0aG0-EVva1xYrn5Ge6IAXRwug9QDGlb-ydtF3BtYppoTklVI7CVLySPPwbbt5Ow1JXdnKhLSwQEs4bh3BLwMnRBwUFI5nmE18BLYbkMOUivgYP5SSMgnGGlSkJO-iJNPWvepunEgyBUzB_7L-b1keTcV-Qak9IcWIITIWbRvmg6qB3ZSuWdcJgWKmdXdIn4qoRM4o16G1_0N_WRqPtMQFo0hmTlAVmHrzRArJQmaSgqAxZxRbFMd9RFeX6qjP9NtwguPbSeStdVbQxMNC34iavYUIxo8Ug812BHsG7J_kIlof2zkIqkEbP3oV3UkSByIo7xh9EEVargjaGDuQRt8zPQ6-fNBWJJe9F6IFu7lXBPIJ016lopyfcvTWjbLbBHsVkg6vG-3UISh0nud7KB5g5ipQePhtcFSI5hvjlfX1DAVHEpTWXkvlnL4wNqEqpBYL2btSXYeE1Cb-RAvrAT0s61usERcL2eI-S5aTcSO8_hxQ2afC7c9vlypOWgP6p6XNpViZHXmdXv4t-d68Z-MpLtSU7VbB3pRWnSswFFyA3W39ITic4lb97Djp3wHhGgz0Sy8aDb9r0tnphIYgASoJdXMtZWFzdC0yMKQG.m3u8
// Extract the edge URL host and add it to URL so proxy can fetch
const resp = await axios.get("https://" + host + pathname);
// const re = /https:\/\/(.*)\/v1\/segment\/(.*)/g;
// const match = re.exec(resp.data);
// const edgehost = match?.[1];
// const repl = resp.data.replaceAll(
// re,
// `/proxy/v1/segment/$2?host=${edgehost}`,
// );
const repl = resp.data;
res.send(repl);
} else if (pathname.endsWith(".ts")) {
// Segment
const resp = await axios.get("https://" + host + pathname, {
responseType: "arraybuffer",
});
res.writeHead(200, {
"Content-Type": "application/octet-stream",
"Accept-Ranges": "bytes",
"Content-Length": resp.data.length,
"Transfer-Encoding": "chunked",
});
res.write(resp.data);
res.end();
} else {
res.status(404);
res.end();
}
} catch (e) {
// console.log(e);
console.log("proxy failed: %s", req.url);
}
});
app.use(express.static(config.BUILD_DIRECTORY));
// Send index.html for all other requests (SPA)
app.use("/*splat", (_req, res) => {
res.sendFile(
path.resolve(
import.meta.dirname + `/../${config.BUILD_DIRECTORY}/index.html`,
),
);
});
async function saveRooms() {
// Unload rooms that are empty and idle
// Frees up some JS memory space when process is long-running
// On reconnect, we'll attempt to reload the room
let saveCount = 0;
let skipCount = 0;
const start = Date.now();
await Promise.all(
Array.from(rooms.entries()).map(async ([key, room]) => {
if (
room.roster.length === 0 &&
!room.vBrowser &&
Number(room.lastUpdateTime) < Date.now() - 8 * 60 * 60 * 1000
) {
console.log(
"freeing room %s from memory on shard %s",
key,
config.SHARD,
);
await room.saveRoom();
room.destroy();
rooms.delete(key);
saveCount += 1;
// Unregister the namespace to avoid dupes on reload
io._nsps.delete(key);
} else if (room.roster.length) {
room.lastUpdateTime = new Date();
await room.saveRoom();
saveCount += 1;
} else {
skipCount += 1;
}
}),
);
const end = Date.now();
console.log(
"[SAVEROOMS] %s saved in %sms, %s skipped",
saveCount,
end - start,
skipCount,
);
}
async function release() {
// Reset VMs in rooms that are:
// older than the session limit
// assigned to a room with no users
const roomArr = Array.from(rooms.values());
console.log("[RELEASE] %s rooms in batch", roomArr.length);
for (let room of roomArr) {
if (room.vBrowser && room.vBrowser.assignTime) {
const maxTime = getSessionLimitSeconds(room.vBrowser.large) * 1000;
const elapsed = Date.now() - room.vBrowser.assignTime;
const ttl = maxTime - elapsed;
const isTimedOut = ttl && ttl < releaseInterval;
const isAlmostTimedOut = ttl && ttl < releaseInterval * 2;
const isRoomEmpty = room.roster.length === 0;
const isRoomIdle =
Date.now() - Number(room.lastUpdateTime) > 5 * 60 * 1000;
if (isTimedOut || (isRoomEmpty && isRoomIdle)) {
console.log("[RELEASE] VM in room:", room.roomId);
room.stopVBrowserInternal();
if (isTimedOut) {
room.addChatMessage(null, {
id: "",
system: true,
cmd: "vBrowserTimeout",
msg: "",
});
redisCount("vBrowserTerminateTimeout");
} else if (isRoomEmpty) {
redisCount("vBrowserTerminateEmpty");
}
} else if (isAlmostTimedOut) {
room.addChatMessage(null, {
id: "",
system: true,
cmd: "vBrowserAlmostTimeout",
msg: "",
});
}
}
// We want to spread out the jobs over about half the release interval
// This gives other jobs some CPU time
const waitTime = releaseInterval / 2 / roomArr.length;
await new Promise((resolve) => setTimeout(resolve, waitTime));
}
}
async function minuteMetrics() {
const roomArr = Array.from(rooms.values());
let vbWaiting = 0;
for (let room of roomArr) {
if (room.vBrowser && room.vBrowser.id) {
// Update the heartbeat
await postgres?.query(
`UPDATE vbrowser SET "heartbeatTime" = NOW() WHERE "roomId" = $1 and vmid = $2`,
[room.roomId, room.vBrowser.id],
);
const expireTime = getStartOfDay() / 1000 + 86400;
if (room.vBrowser?.creatorClientID) {
await redis?.zincrby(
"vBrowserClientIDMinutes",
1,
room.vBrowser.creatorClientID,
);
await redis?.expireat("vBrowserClientIDMinutes", expireTime);
}
if (room.vBrowser?.creatorUID) {
await redis?.zincrby(
"vBrowserUIDMinutes",
1,
room.vBrowser?.creatorUID,
);
await redis?.expireat("vBrowserUIDMinutes", expireTime);
}
}
const users = room.roster.length;
if (users) {
await redis?.setex(`roomCounts:${room.roomId}`, 120, users);
await redis?.setex(
`roomRosters:${room.roomId}`,
120,
JSON.stringify(room.getRosterForStats()),
);
}
vbWaiting += room.vBrowserQueue ? 1 : 0;
}
// Report shard metrics
const obj: ShardMetric = {
uptime: process.uptime(),
mem: process.memoryUsage().rss,
roomCount: rooms.size,
users: io.engine.clientsCount,
vbWaiting,
};
await redis?.setex(
`shardMetrics:${config.SHARD ?? 0}`,
120,
JSON.stringify(obj),
);
}
function computeOpenSubtitlesHash(first: Buffer, last: Buffer, size: number) {
// console.log(first.length, last.length, size);
let temp = BigInt(size);
process(first);
process(last);
temp = temp & BigInt("0xffffffffffffffff");
return temp.toString(16).padStart(16, "0");
function process(chunk: Buffer) {
for (let i = 0; i < chunk.length; i += 8) {
const long = chunk.readBigUInt64LE(i);
temp += long;
}
}
}