This commit is contained in:
2026-06-21 03:53:06 +03:00
commit f1dd557c5d
147 changed files with 34363 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
# See config.ts for list of server config variables
# See src/config.ts for list of client config variables
# Leave blank to allow access to all features without login
VITE_FIREBASE_CONFIG=
# VITE_FIREBASE_CONFIG={"apiKey":"AIzaSyA2fkXeFokJ-Ei_jnzDso5AmjbIaMdzuEc","authDomain":"watchparty-273604.firebaseapp.com","databaseURL":"https://watchparty-273604.firebaseio.com","projectId":"watchparty-273604","storageBucket":"watchparty-273604.appspot.com","messagingSenderId":"769614672795","appId":"1:769614672795:web:54bbda86288ab1a034273e"}
# All other config is optional (see README)
# YOUTUBE_API_KEY=REPLACE_ME
# FIREBASE_ADMIN_SDK_CONFIG=REPLACE_ME
# DATABASE_URL=postgresql://postgres@localhost:5432/postgres?sslmode=disable
# REDIS_URL=localhost:6379
+7
View File
@@ -0,0 +1,7 @@
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 10
+36
View File
@@ -0,0 +1,36 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Node.js CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: 24.x
- run: npm install
- run: npm run build --if-present
env:
CI: true
NODE_ENV: production
- name: Push to remote
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "watchparty+github-actions[bot]@users.noreply.github.com"
rm .gitignore
git checkout -b release
git add --all
git commit --quiet -m "Built from ${{ github.sha }}"
git push origin release -f
+27
View File
@@ -0,0 +1,27 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.theia
.eslintcache
+3
View File
@@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}
+9
View File
@@ -0,0 +1,9 @@
## April 2023
- Added support for looping the same video
- Player now shows the buffered/downloaded ranges of video available
- Added a 3x speed playback option
- HLS/m3u8 live vs. non-live streams are now handled properly
- Improved HLS playback experience on Safari and Android Chrome
- Pasting Reddit links with video media is now supported
- Twitch links (streams and VODs) are now supported
+11
View File
@@ -0,0 +1,11 @@
FROM node:24-alpine
COPY . /usr/src
WORKDIR /usr/src
RUN npm install
RUN npm run build
ENTRYPOINT ["/bin/sh", "-c" , "npm start"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Howard Chung
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+84
View File
@@ -0,0 +1,84 @@
# WatchParty
![screenshot](https://github.com/howardchung/watchparty/raw/master/public/screenshot_full.png)
A website for watching videos together.
## Description
- Synchronizes the video being watched with the current room
- Plays, pauses, and seeks are synced to all watchers
- Supports:
- Screen sharing (full screen, browser tab or application)
- Launch a shared virtual browser in the cloud (similar to rabb.it)
- Stream-your-own-file
- Video files on the Internet (anything accessible via HTTP)
- YouTube videos
- Magnet links (via WebTorrent)
- .m3u8 streams (HLS)
- Create separate rooms for users on demand
- Text chat
- Video chat
## Quick Start
- Clone this repo via `git clone git@github.com:howardchung/watchparty.git`
- Install npm dependencies for the project via `npm install`
- Start the server via `npm run dev`
- Defaults to port 8080, customize with `PORT` env var
- Set `SSL_KEY_FILE` and `SSL_CRT_FILE` for HTTPS.
- Start the React application in a separate shell and port via `npm run ui`
- Point to server using `VITE_SERVER_HOST` env var if you customized it above
- Set `SSL_KEY_FILE` and `SSL_CRT_FILE` for HTTPS.
- HTTPS is required by the browser for some WebRTC features (camera, etc.)
- Duplicate the `.env.example` file
- Rename it to `.env`
- Add config for the features you want as described in the advanced setup
## Advanced Setup (optional)
All of these are optional and the application should work without them. Some functionality may be missing.
### YouTube API (video search)
This project uses the YouTube API for video search, which requires an API key. You can get one from Google [here](https://console.developers.google.com).
Without an API key you won't be able to search for videos via the searchbox.
After creating a **YouTube Data API V3** access, you can create an API key which you can add to your environment variables by copying the `.env.example`, renaming it to `.env` and adding the key to the YOUTUBE_API_KEY variable.
After that restart your server to enable the YouTube API access on your server.
### Firebase Config (user authentication)
This project uses Firebase for authentication. This is used for user login, account management, subscriptions, and handling some features like room locking/permanence.
To set up, create a new Firebase app (or reuse an old one) from [here](https://console.firebase.google.com/). After creating an application, click on the settings cog icon in the left menu next to "Project overview" and click on project settings. From there, scroll down, create a web application and copy the Firebase SDK configuration snippet JSON data.
Next, you have to stringify it: `JSON.stringify(PASTE_CONFIG_HERE)` in your browser console, then add it to `VITE_FIREBASE_CONFIG` in your .env file.
For server verification of accounts you'll also need `FIREBASE_ADMIN_SDK_CONFIG`, which you should do the same steps for.
### Virtual Browser Setup
This project supports creating virtual browsers (using https://github.com/m1k1o/neko) either on a cloud provider or with Docker containers. For development, Docker is easiest.
- Install Docker: `curl -fsSL https://get.docker.com | sh`
- Make sure you have an SSH key pair set up on the server (`id_rsa` in `~/.ssh` directory), if not, use `ssh-keygen`.
- Configure `DOCKER_VM_HOST_SSH_USER` if `root` is not the correct user
- Note: If your web client is not running on the same physical machine as the server, you will also need to configure `DOCKER_VM_HOST` to a publically-resolvable value (i.e. not localhost)
- If you want to run managed instance pools (whether on cloud or with Docker), configure `VM_MANAGER_CONFIG` and run the vmWorker service.
### Room Persistence
- Configure Postgres by adding DATABASE_URL to your .env file and then setting up the database schema
- This allows rooms to persist between server restarts
## Tech
- React
- TypeScript
- Node.js
- Redis
- PostgreSQL
- Docker
+87
View File
@@ -0,0 +1,87 @@
<body>
<audio controls autoplay>
<source src="http://212.47.251.184:5902" type="audio/ogg" />
</audio>
</body>
<script>
/*
const videoRec = document.createElement('audio');
videoRec.autoplay = true;
videoRec.controls = true;
videoRec.id = 'audio';
videoRec.type = 'audio/ogg; codec=opus';
videoRec.src = 'http://212.47.251.184:5902';
document.body.appendChild(videoRec);
*/
/*
const ms = new MediaSource();
let queue = [];
let buffSize = 0;
let first = true;
ms.onsourceopen = () => {
console.log('onsourceopen');
ms.addSourceBuffer('audio/mp4; codecs=opus');
const sourceBuffer = ms.sourceBuffers[0];
const updateNext = () => {
if (queue.length) {
console.log('premerge', queue.length, buffSize);
const merged = mergeBuffers();
console.log('merged', merged.map(num => num.toString(16).padStart(2, '0')));
// debugger;
try {
sourceBuffer.appendBuffer(merged);
} catch (e) {
console.log(document.getElementById('audio').error);
}
}
};
sourceBuffer.addEventListener('updateend', updateNext);
const socket = new WebSocket('ws://212.47.251.184:5001');
socket.binaryType = 'arraybuffer';
socket.onmessage = (event) => {
const buffer = new Uint8Array(event.data);
queue.push(buffer);
buffSize += buffer.length;
if (!sourceBuffer.updating) {
updateNext();
}
}
};
ms.onsourceclose = () => {
console.log('onsourceclose');
}
videoRec.src = window.URL.createObjectURL(ms);
function mergeBuffers() {
var merged;
if (queue.length == 1) {
merged = queue[0];
} else {
merged = new Uint8Array(buffSize);
var length = queue.length;
var offset = 0;
for (var i = 0; i < length; i++) {
var curr = queue[i];
if (curr.length <= 0) {
continue;
}
merged.set(curr, offset);
offset += curr.length;
}
}
queue = [];
buffSize = 0;
return merged;
}
*/
//options
// mp4mux with mediasource and websockify
// ogg with TCP->HTTP forwarder and audio src
</script>
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
sudo docker exec -it --user postgres postgres sh -c "pg_dump postgres" > postgres.bak
sudo docker exec -it redis sh -c "cat dump.rdb" > dump.rdb
# on local: scp root@ip:dump.rdb dump.rdb
sudo docker exec -it --user postgres postgres sh -c "psql postgres" < postgres.bak
sudo docker exec -it redis sh -c "cat > dump.rdb" < dump.rdb
+7
View File
@@ -0,0 +1,7 @@
FROM siomiz/chrome
RUN apt-get update
RUN apt-get install -y libasound2 alsa-utils alsa-oss
RUN apt-get install -y pulseaudio
+35
View File
@@ -0,0 +1,35 @@
FROM ubuntu:xenial
RUN apt-get update; apt-get clean
# Add a user for running applications.
RUN useradd apps
RUN mkdir -p /home/apps && chown apps:apps /home/apps
# Install x11vnc.
RUN apt-get install -y x11vnc
# Install xvfb.
RUN apt-get install -y xvfb
# Install fluxbox.
RUN apt-get install -y fluxbox
# Install wget.
RUN apt-get install -y wget
# Install wmctrl.
RUN apt-get install -y wmctrl
RUN apt-get install -y gnupg2
# Set the Chrome repo.
RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
&& echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list
# Install Chrome.
RUN apt-get update && apt-get -y install google-chrome-stable
COPY bootstrap.sh /
CMD '/bootstrap.sh'
+88
View File
@@ -0,0 +1,88 @@
#!/bin/bash
# Based on: http://www.richud.com/wiki/Ubuntu_Fluxbox_GUI_with_x11vnc_and_Xvfb
readonly G_LOG_I='[INFO]'
readonly G_LOG_W='[WARN]'
readonly G_LOG_E='[ERROR]'
main() {
launch_xvfb
launch_window_manager
run_vnc_server
}
launch_xvfb() {
# Set defaults if the user did not specify envs.
export DISPLAY=${XVFB_DISPLAY:-:1}
local screen=${XVFB_SCREEN:-0}
local resolution=${XVFB_RESOLUTION:-1280x1024x24}
local timeout=${XVFB_TIMEOUT:-5}
# Start and wait for either Xvfb to be fully up or we hit the timeout.
Xvfb ${DISPLAY} -screen ${screen} ${resolution} &
local loopCount=0
until xdpyinfo -display ${DISPLAY} > /dev/null 2>&1
do
loopCount=$((loopCount+1))
sleep 1
if [ ${loopCount} -gt ${timeout} ]
then
echo "${G_LOG_E} xvfb failed to start."
exit 1
fi
done
}
launch_window_manager() {
local timeout=${XVFB_TIMEOUT:-5}
# Start and wait for either fluxbox to be fully up or we hit the timeout.
fluxbox &
local loopCount=0
until wmctrl -m > /dev/null 2>&1
do
loopCount=$((loopCount+1))
sleep 1
if [ ${loopCount} -gt ${timeout} ]
then
echo "${G_LOG_E} fluxbox failed to start."
exit 1
fi
done
}
run_vnc_server() {
local passwordArgument='-nopw'
if [ -n "${VNC_SERVER_PASSWORD}" ]
then
local passwordFilePath="${HOME}/x11vnc.pass"
if ! x11vnc -storepasswd "${VNC_SERVER_PASSWORD}" "${passwordFilePath}"
then
echo "${G_LOG_E} Failed to store x11vnc password."
exit 1
fi
passwordArgument=-"-rfbauth ${passwordFilePath}"
echo "${G_LOG_I} The VNC server will ask for a password."
else
echo "${G_LOG_W} The VNC server will NOT ask for a password."
fi
x11vnc -display ${DISPLAY} -forever ${passwordArgument} &
wait $!
}
control_c() {
echo ""
exit
}
#trap control_c SIGINT SIGTERM SIGHUP
main
sleep 1
google-chrome --start-maximized --no-first-run --display ${DISPLAY}
exit
+16
View File
@@ -0,0 +1,16 @@
import http from "node:http";
import net from "node:net";
const port = 5902;
const client = new net.Socket();
const server = http.createServer((req, res) => {
console.log(req.url);
res.setHeader("Content-Type", "audio/ogg");
client.connect(5901, "localhost");
client.pipe(res);
});
server.listen(port, () => {
console.log(`server is listening on ${port}`);
});
+29
View File
@@ -0,0 +1,29 @@
# Enable kernel modules on the host
modprobe snd-aloop
# Install gstreamer
apt-get install -y libgstreamer1.0-0 gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav gstreamer1.0-doc gstreamer1.0-tools gstreamer1.0-x gstreamer1.0-alsa gstreamer1.0-gl gstreamer1.0-gtk3 gstreamer1.0-qt5 gstreamer1.0-pulseaudio
# Inside container: ALSA to view sound devices with aplay -l
apt-get install -y libasound2 alsa-utils alsa-oss
apt-get install -y pulseaudio
# Rebuild our container
docker build . -t browseparty/chrome
# Run our services
# Start chrome + VNC, sharing audio device
docker run -d -p 5900:5900 --rm --name chrome --device /dev/snd -e VNC_PASSWORD="password" -e VNC_SCREEN_SIZE="1280x720" -e CHROME_OPTS_OVERRIDE=" --user-data-dir --no-sandbox --no-first-run --window-position=0,0 --force-device-scale-factor=1 --disable-dev-shm-usage" browseparty/chrome
# Proxy the VNC from TCP to WebSocket
docker run -d -it --rm --net=host --name websockify efrecon/websockify 5000 localhost:5900
# Audio test
speaker-test -D hw:0,0,0 --channels 2 --rate 48000
# Stream the audio to a TCP sink
gst-launch-1.0 -v alsasrc device=hw:0,1,0 ! audio/x-raw, channels=2, rate=48000 ! opusenc complexity=0 ! oggmux ! tcpserversink port=5901 host=0.0.0.0
gst-launch-1.0 -v alsasrc device=hw:0,1,0 ! audio/x-raw, channels=2, rate=48000 ! audioconvert ! lamemp3enc ! tcpserversink port=5901 host=0.0.0.0
# Proxy the audio from TCP to WebSocket
docker run -d -it --rm --net=host --name websockify-audio efrecon/websockify 5001 localhost:5901
+11
View File
@@ -0,0 +1,11 @@
{
"CORSRules": [
{
"AllowedOrigins": ["*"],
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "HEAD", "POST", "PUT", "DELETE"],
"MaxAgeSeconds": 3000,
"ExposeHeaders": ["Etag"]
}
]
}
+9
View File
@@ -0,0 +1,9 @@
<CORSConfiguration>
<CORSRule>
<AllowedOrigin>*</AllowedOrigin>
<AllowedMethod>GET</AllowedMethod>
<AllowedHeader>*</AllowedHeader>
<MaxAgeSeconds>3000</MaxAgeSeconds>
<ExposeHeader>ETag</ExposeHeader>
</CORSRule>
</CORSConfiguration>
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# install docker
curl -fsSL https://get.docker.com | sh
# coturn
docker run -d --network=host --name=coturn coturn/coturn \
-n --log-file=stdout \
--lt-cred-mech --fingerprint \
--no-multicast-peers --no-cli \
--no-tlsv1 --no-tlsv1_1 \
--realm=watchparty \
# doesn't actually work, manually docker exec bash and run the add
docker exec -it --user nobody coturn "bash -c 'turnadmin --add -u username -p password -r watchparty'"
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
apt-get update
apt-get install -y curl
# install docker
curl -fsSL https://get.docker.com | sh
# pull vbrowser image
docker pull howardc93/vbrowser
# install certbot
DEBIAN_FRONTEND=noninteractive apt-get install -y certbot
certbot certonly --standalone -n --email howardzchung@gmail.com --agree-tos -d docker1.watchparty.me
chmod -R 755 /etc/letsencrypt/live/
chmod -R 755 /etc/letsencrypt/archive/
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
apt update
DEBIAN_FRONTEND=noninteractive apt-get install -y certbot python3-certbot-dns-cloudflare
certbot certonly --standalone -n --email howardzchung@gmail.com --agree-tos -d $(hostname).watchparty.me
#certbot certonly --dns-cloudflare --dns-cloudflare-credentials ~/cloudflare.ini -d *.watchparty.me --preferred-challenges dns-01
apt install -y nginx
apt install -y bind9
echo 'events {}
http {
server {
listen 443 ssl;
server_name HOSTNAME_PLACEHOLDER.watchparty.me;
ssl_certificate /etc/letsencrypt/live/HOSTNAME_PLACEHOLDER.watchparty.me/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/HOSTNAME_PLACEHOLDER.watchparty.me/privkey.pem;
location / {
resolver 127.0.0.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_pass http://$arg_ip;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
}' | sed "s/HOSTNAME_PLACEHOLDER/$(hostname)/g" > /etc/nginx/nginx.conf
/etc/init.d/nginx reload
# Update /etc/letsencrypt/cli.ini with renew hook
echo '
deploy-hook = systemctl reload nginx' >> /etc/letsencrypt/cli.ini
+8
View File
@@ -0,0 +1,8 @@
import { Redis } from "ioredis";
const redis = new Redis("localhost:6379");
const redis2 = new Redis("localhost:6379");
redis.blpop("ioRedisTest", 0);
redis.disconnect();
await redis2.lpush("ioRedisTest", "val");
console.log(await redis2.lrange("ioRedisTest", 0, -1));
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
docker run --name watchparty-redis --rm --net=host -d redis
+21
View File
@@ -0,0 +1,21 @@
import fs from "node:fs";
import xml2js from "xml2js";
const xml = ``;
const subPath = "/var/www/html/subtitles/";
const subs = fs.readdirSync(subPath);
let vids = fs.readdirSync("/var/www/html/");
const data = await xml2js.parseStringPromise(xml);
// vids = [...data.ListBucketResult.Contents.filter(file => file.Key[0].startsWith('')).map(file => file.Key[0])];
console.log(subs);
console.log(vids);
vids.forEach((vid) => {
const match = subs.find((sub) =>
vid.toLowerCase().startsWith(sub.slice(0, -4).toLowerCase()),
);
console.log(vid, match);
if (vid && match) {
fs.copyFileSync(subPath + match, subPath + vid + ".srt");
}
});
+29
View File
@@ -0,0 +1,29 @@
import requests
url = "https://discord.com/api/v10/applications/1071394728513380372/commands"
# This is an example CHAT_INPUT or Slash Command, with a type of 1
json = {
"name": "watch",
"type": 1,
"description": "Start a new WatchParty",
"options": [
{
"name": "video",
"description": "The URL to start watching",
"type": 3
}
]
}
# For authorization, you can use either your bot token
headers = {
"Authorization": "Bot <TOKEN>"
}
# or a client credentials token for your app with the applications.commands.update scope
# headers = {
# "Authorization": "Bearer <my_credentials_token>"
# }
r = requests.post(url, headers=headers, json=json)
+96
View File
@@ -0,0 +1,96 @@
<video id="video" autoplay="true"></video>
<canvas id="canvas"></canvas>
<script>
var video = document.getElementById("video");
if (navigator.mediaDevices.getUserMedia) {
var successCallback = function (stream) {
video.srcObject = stream;
var canvas = document.querySelector("canvas");
// Optional frames per second argument.
var stream2 = canvas.captureStream(25);
var recordedChunks = [];
console.log(stream);
var options = { mimeType: "video/webm; codecs=vp9" };
mediaRecorder = new MediaRecorder(stream, options);
mediaRecorder.ondataavailable = handleDataAvailable;
mediaRecorder.start(1000);
async function handleDataAvailable(event) {
console.log(event.data, await event.data.arrayBuffer());
if (event.data.size > 0) {
// recordedChunks.push(event.data);
addVideoChunk({ data: event.data });
}
}
};
var errorCallback = function (error) {
console.log(error);
};
navigator.mediaDevices
.getUserMedia({
audio: false,
video: { facingMode: { ideal: "environment" } }, // prefer rear-facing camera
})
.then(successCallback, errorCallback);
}
var canvas = document.getElementById("canvas");
var context = canvas.getContext("2d");
requestAnimationFrame(renderFrame);
function renderFrame() {
// re-register callback
requestAnimationFrame(renderFrame);
// set internal canvas size to match HTML element size
canvas.width = canvas.scrollWidth;
canvas.height = canvas.scrollHeight;
if (video.readyState === video.HAVE_ENOUGH_DATA) {
// scale and horizontally center the camera image
var videoSize = { width: video.videoWidth, height: video.videoHeight };
var canvasSize = { width: canvas.width, height: canvas.height };
var renderSize = calculateSize(videoSize, canvasSize);
var xOffset = (canvasSize.width - renderSize.width) / 2;
context.drawImage(video, xOffset, 0, renderSize.width, renderSize.height);
}
}
function calculateSize(srcSize, dstSize) {
var srcRatio = srcSize.width / srcSize.height;
var dstRatio = dstSize.width / dstSize.height;
if (dstRatio > srcRatio) {
return {
width: dstSize.height * srcRatio,
height: dstSize.height,
};
} else {
return {
width: dstSize.width,
height: dstSize.width / srcRatio,
};
}
}
const videoRec = document.createElement("video");
const ms = new MediaSource();
videoRec.autoplay = true;
videoRec.width = 320;
videoRec.height = 240;
// console.log(MediaSource.isTypeSupported('video/webm; codecs=vp9'));
ms.onsourceopen = () => {
console.log("onsourceopen");
ms.addSourceBuffer("video/webm; codecs=vp9");
};
ms.onsourceclose = () => {
console.log("onsourceclose");
};
videoRec.src = window.URL.createObjectURL(ms);
document.body.appendChild(videoRec);
async function addVideoChunk(data) {
ms.sourceBuffers[0].appendBuffer(await data.data.arrayBuffer());
}
</script>
+12
View File
@@ -0,0 +1,12 @@
import { resolveShard } from "../server/utils/resolveShard";
import { makeName } from "../server/utils/moniker";
for (let i = 0; i < 100; i++) {
const shard = (i % 2) + 1;
const name = makeName(shard);
const nameShard = resolveShard(name);
console.log(name, shard, nameShard);
if (shard !== nameShard) {
throw new Error("shard mismatch");
}
}
+51
View File
@@ -0,0 +1,51 @@
import axios from "axios";
import fs from "node:fs";
import s3 from "s3-client";
var client = s3.createClient({
maxAsyncS3: 20, // this is the default
s3RetryCount: 3, // this is the default
s3RetryDelay: 1000, // this is the default
multipartUploadThreshold: 2097152000,
multipartUploadSize: 15728640, // this is the default (15 MB)
s3Options: {
accessKeyId: process.env.SCW_ACCESS_KEY,
secretAccessKey: process.env.SCW_SECRET_KEY,
endpoint: "s3.fr-par.scw.cloud",
// endpoint: 's3.yourdomain.com',
// sslEnabled: false
// any other options are passed to new AWS.S3()
// See: http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/Config.html#constructor-property
},
});
var params = {
localDir: "/var/www/html",
deleteRemoved: false, // default false, whether to remove s3 objects that have no corresponding local file.
s3Params: {
Bucket: "hcmedia",
Prefix: "",
},
getS3Params: (localFile, stat, callback) => {
// call callback like this:
var s3Params = {
// if there is no error
Key: localFile.replace(/ /g, "."),
ACL: "public-read",
// other options supported by putObject, except Body and ContentLength.
// See: http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#putObject-property
};
// pass `null` for `s3Params` if you want to skip uploading this file.
callback(null, s3Params);
},
};
var uploader = client.uploadDir(params);
uploader.on("error", function (err) {
console.error("unable to sync:", err.stack);
});
uploader.on("progress", function () {
console.log("progress", uploader.progressAmount, uploader.progressTotal);
});
uploader.on("end", function () {
console.log("done uploading");
});
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# apt-get update
# apt-get install -y curl
# install docker (if not already installed)
# curl -fsSL https://get.docker.com | sh
# install dnsutils (iptables?)
apt-get install -y dnsutils
# disable unattended-upgrades
# apt-get remove -y unattended-upgrades
# determine if this is a large or not via nproc
# This systemd config starts the vbrowser on reboot (or instances created from a snapshot of this init vm)
echo '
#!/bin/bash
iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 5000
PASSWORD=$(uuidgen)
#RESOLUTION=$(if [ "$(nproc)" -le "2" ]; then echo "1280x720@30"; else echo "1920x1080@30"; fi)
RESOLUTION="1280x720@30"
ADMIN_KEY={VBROWSER_ADMIN_KEY}
# docker pull howardc93/vbrowser
docker run -d --rm --name=vbrowser --log-opt max-size=1g --net=host --shm-size=1g --cap-add="SYS_ADMIN" -e DISPLAY=":99.0" -e NEKO_SCREEN=$RESOLUTION -e NEKO_PASSWORD=$PASSWORD -e NEKO_PASSWORD_ADMIN=$PASSWORD -e NEKO_ADMIN_KEY=$ADMIN_KEY -e NEKO_BIND=":5000" -e NEKO_EPR=":59000-59100" -e NEKO_H264="1" howardc93/vbrowser
' > /etc/systemd/system/vbrowser.sh
chmod u+x /etc/systemd/system/vbrowser.sh
echo '[Unit]
Description=Start VBrowser
[Service]
Type=oneshot
ExecStart=bash /etc/systemd/system/vbrowser.sh
[Install]
WantedBy=multi-user.target' > /etc/systemd/system/vbrowser.service
systemctl enable vbrowser.service
# This ensures the image is pre-pulled and also creates an endpoint for updateSnapshot to check
docker run -d --rm --name=test --net=host --shm-size=1g --cap-add="SYS_ADMIN" -e DISPLAY=":99.0" -e NEKO_PASSWORD=neko -e NEKO_PASSWORD_ADMIN=admin -e NEKO_BIND=":5000" -e NEKO_EPR=":59000-59100" howardc93/vbrowser
+98
View File
@@ -0,0 +1,98 @@
# Allow ssh, http, https
ufw allow http
ufw allow https
ufw allow ssh
ufw allow 3000
# Enable ufw
ufw enable
# Install nginx/bind9
apt install -y nginx
apt install -y bind9
echo 'worker_rlimit_nofile 4096;
events {
worker_connections 4096;
}
http {
upstream rr {
server 127.0.0.1:3001;
server 127.0.0.1:3002;
# server 127.0.0.1:3003;
}
upstream 1 {
server 127.0.0.1:3001;
}
upstream 2 {
server 127.0.0.1:3002;
}
upstream 3 {
server 127.0.0.1:3003;
}
map $arg_shard $pool {
default "rr";
1 "1";
2 "2";
# 3 "3";
}
server {
listen 80;
location / {
proxy_pass http://$pool;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
# enable WebSockets
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
}' > /etc/nginx/nginx.conf
/etc/init.d/nginx reload
# Install git
apt update
apt install -y git
# Clone application code
git clone https://github.com/howardchung/watchparty
# Install docker
curl -sSL https://get.docker.com/ | sh
# Start Redis
sudo docker run --log-opt max-size=1g -d --name redis --restart=always --net=host redis
# Start Postgres
sudo docker run --log-opt max-size=1g -d --name postgres --restart=always -e POSTGRES_PASSWORD=password --net=host -v $PWD/sql/:/docker-entrypoint-initdb.d/ postgres
# Install NodeJS
curl -sL https://deb.nodesource.com/setup_14.x | sudo -E bash -
sudo apt-get install -y nodejs
# Set up certbot or Cloudflare HTTPS
# Build watchparty
npm run build
# Set .env config
echo '
DATABASE_URL=postgresql://postgres@localhost:5432/postgres?sslmode=disable
REDIS_URL=localhost:6379
' > .env
# Install PM2 globally
npm install -g pm2
# PM2 start
npm run pm2
# Run on startup
./node_modules/pm2/bin/pm2 startup
./node_modules/pm2/bin/pm2 save
Vendored
+106
View File
@@ -0,0 +1,106 @@
declare module "srt-webvtt";
type StringDict = Record<string, string>;
type NumberDict = Record<string, number>;
type BooleanDict = Record<string, boolean>;
type AnyDict = Record<string, any>;
type PCDict = Record<string, RTCPeerConnection>;
type HTMLVideoElementDict = Record<string, HTMLVideoElement>;
type MediaType = "vbrowser" | "screenshare" | "video" | "youtube";
interface User {
id: string;
isVideoChat?: boolean;
isMuted?: boolean;
isScreenShare?: boolean;
}
interface Reaction {
user: string;
value: string;
msgId: string;
msgTimestamp: string;
}
type ChatPayload = {
msg: string;
replyToId?: string;
replyToTimestamp?: string;
};
interface ChatMessageBase {
id: string;
cmd?: string;
msg?: string;
system?: boolean;
isSub?: boolean;
replyToId?: string;
replyToTimestamp?: string;
replyToUserId?: string;
replyToMsg?: string;
}
interface ChatMessage extends ChatMessageBase {
timestamp: string;
videoTS?: number;
reactions?: { [value: string]: string[] };
}
interface Settings {
disableChatSound?: boolean;
}
interface PlaylistVideo {
url: string;
name: string;
img?: string;
channel?: string;
duration: number;
type: string;
}
interface SearchResult extends PlaylistVideo {
size?: string | number;
seeders?: string;
magnet?: string;
type: "youtube" | "file" | "magnet";
url: string;
name: string;
duration: number;
}
interface HostState {
video: string;
videoTS: number;
subtitle: string;
paused: boolean;
isVBrowserLarge: boolean;
controller?: string;
playbackRate: number;
loop: boolean;
}
interface PersistentRoom {
roomId: string;
password: string;
owner: string;
vanity: string;
isChatDisabled: boolean;
isSubRoom: boolean;
data: any;
}
interface LinkAccount {
accountname: string;
accountid: string;
discriminator: string;
kind: string;
}
interface ShardMetric {
uptime: number;
mem: number;
roomCount: number;
users: number;
vbWaiting: number;
}
+64
View File
@@ -0,0 +1,64 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Watch together with friends. Chat and react in real time to the same stream, whether it's YouTube, your own video, or a virtual browser."
/>
<meta name="company" content="Gravitech LLC" />
<meta property="og:image" content="/screenshot4.png" />
<title>WatchParty</title>
<script type="module" src="./src/index.tsx"></script>
</head>
<body>
<script>
// Remove fbclid parameter
// (function () {
// var param = 'fbclid';
// if (location.search.indexOf(param + '=') !== -1) {
// var replace = '';
// try {
// var url = new URL(location);
// url.searchParams.delete(param);
// replace = url.href;
// } catch (ex) {
// var regExp = new RegExp('[?&]' + param + '=.*$');
// replace = location.search.replace(regExp, '');
// replace = location.pathname + replace + location.hash;
// }
// history.replaceState(null, '', replace);
// }
// })();
</script>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script
async
src="https://www.googletagmanager.com/gtag/js?id=UA-45337794-6"
></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "UA-45337794-6");
</script>
</html>
+11820
View File
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
{
"name": "watchparty",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server/server.ts",
"ui": "vite --host",
"dev": "node --watch-path . server/server.ts",
"build": "npm run buildReact && npm run typecheckServer",
"buildReact": "vite build && npm run typecheck",
"pm2": "pm2 start server/server.ts",
"deploy": "git fetch && git reset --hard origin/release && pm2 flush && pm2 delete all && pm2 start server/ecosystem.config.js",
"prettier": "npx prettier --write .",
"typecheckServer": "tsc --project server/tsconfig.json",
"typecheck": "tsc --project src/tsconfig.json --noEmit",
"ncu": "ncu --semverLevel major -u",
"updateSnapshot": "curl -X POST localhost:3100/updateSnapshot -H 'Content-Type: application/json' -d '{\"provider\":\"Hetzner\",\"region\":\"US\"}'",
"testvBrowser": "docker run -d --rm --name=vbrowser --log-opt max-size=1g --net=host --shm-size=1g --cap-add=\"SYS_ADMIN\" -e DISPLAY=\":99.0\" -e NEKO_PASSWORD=user -e NEKO_PASSWORD_ADMIN=admin -e NEKO_BIND=\":5100\" -e NEKO_EPR=\":59000-59100\" -e NEKO_H264=\"1\" howardc93/vbrowser",
"testvlc": "docker run -d --rm --name=vlc --log-opt max-size=1g --net=host -e VLC_MEDIA=\"https://github.com/ietf-wg-cellar/matroska-test-files/raw/master/test_files/test1.mkv\" -e NEKO_PASSWORD=user -e NEKO_PASSWORD_ADMIN=admin -e NEKO_BIND=\":5100\" -e NEKO_EPR=\":59000-59100\" -e NEKO_H264=\"1\" m1k1o/neko:vlc",
"analyze": "npx source-map-explorer 'build/assets/*.js' --html result.html --no-border-checks"
},
"dependencies": {
"@googleapis/youtube": "^8.0.2",
"axios": "^1.13.2",
"body-parser": "^1.20.4",
"compression": "^1.8.1",
"cors": "^2.8.5",
"discord.js": "^14.25.1",
"express": "^5.2.1",
"firebase-admin": "^11.11.1",
"ioredis": "^5.8.2",
"node-ssh": "^13.2.1",
"pg": "^8.16.3",
"pm2": "^6.0.14",
"socket.io": "^4.8.1",
"stripe": "^8.222.0"
},
"devDependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@mantine/core": "^8.3.10",
"@stripe/stripe-js": "^1.54.2",
"@tabler/icons-react": "^3.35.0",
"@types/compression": "^1.8.1",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^25.0.1",
"@types/pg": "^8.16.0",
"@types/react": "^18.3.27",
"@types/react-dom": "^18.3.7",
"@types/react-onclickoutside": "^6.7.10",
"@types/react-router-dom": "^5.3.3",
"@types/react-transition-group": "^4.4.12",
"@types/webtorrent": "^0.110.1",
"@types/youtube": "^0.1.2",
"can-autoplay": "^3.0.2",
"dashjs": "^5.1.0",
"eventemitter3": "^4.0.7",
"firebase": "^9.23.0",
"hls.js": "^1.6.15",
"mediasoup-client": "^3.18.1",
"react": "^18.3.1",
"react-colorful": "^5.6.1",
"react-countup": "^6.5.3",
"react-dom": "^18.3.1",
"react-linkify": "^1.0.0-alpha",
"react-onclickoutside": "^6.13.2",
"react-router-dom": "^5.3.4",
"react-secure-link": "^3.2.0",
"react-transition-group": "^4.4.5",
"recharts": "^2.15.4",
"socket.io-client": "^4.8.1",
"srt-webvtt": "^2.0.0",
"twitch-m3u8": "^1.1.5",
"typescript": "^5.9.3",
"vite": "^7.2.7",
"webtorrent": "^2.8.5"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+3
View File
@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
Binary file not shown.

After

Width:  |  Height:  |  Size: 971 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

+82
View File
@@ -0,0 +1,82 @@
(() => {
"use strict";
let e = !1;
(self.addEventListener("install", () => {
self.skipWaiting();
}),
self.addEventListener("fetch", (s) => {
const t = ((s) => {
const { url: t } = s.request;
return t.includes(self.registration.scope + "webtorrent/")
? t.includes(self.registration.scope + "webtorrent/keepalive/")
? new Response()
: t.includes(self.registration.scope + "webtorrent/cancel/")
? new Response(
new ReadableStream({
cancel() {
e = !0;
},
}),
)
: (async function ({ request: s }) {
const { url: t, method: n, headers: o, destination: a } = s,
l = await clients.matchAll({
type: "window",
includeUncontrolled: !0,
}),
[r, i] = await new Promise((e) => {
for (const s of l) {
const l = new MessageChannel(),
{ port1: r, port2: i } = l;
((r.onmessage = ({ data: s }) => {
e([s, r]);
}),
s.postMessage(
{
url: t,
method: n,
headers: Object.fromEntries(o.entries()),
scope: self.registration.scope,
destination: a,
type: "webtorrent",
},
[i],
));
}
});
let c = null;
const d = () => {
(i.postMessage(!1), clearTimeout(c), (i.onmessage = null));
};
return "STREAM" !== r.body
? (d(), new Response(r.body, r))
: new Response(
new ReadableStream({
pull: (s) =>
new Promise((t) => {
((i.onmessage = ({ data: e }) => {
(e ? s.enqueue(e) : (d(), s.close()), t());
}),
e ||
(clearTimeout(c),
"document" !== a &&
(c = setTimeout(() => {
(d(), t());
}, 5e3))),
i.postMessage(!0));
}),
cancel() {
d();
},
}),
r,
);
})(s)
: null;
})(s);
t && s.respondWith(t);
}),
self.addEventListener("activate", () => {
self.clients.claim();
}));
})();
+16
View File
@@ -0,0 +1,16 @@
import { postgres } from "./utils/postgres.ts";
cleanupPostgres();
setInterval(cleanupPostgres, 5 * 60 * 1000);
async function cleanupPostgres() {
if (!postgres) {
return;
}
console.time("[CLEANUP]");
const result = await postgres?.query(
`DELETE FROM room WHERE owner IS NULL AND ("lastUpdateTime" < NOW() - INTERVAL '1 day' OR "lastUpdateTime" IS NULL)`,
);
console.log(result.command, result.rowCount);
console.timeEnd("[CLEANUP]");
}
+69
View File
@@ -0,0 +1,69 @@
import { loadEnvFile } from "node:process";
try {
loadEnvFile();
} catch (e) {
console.log(e);
}
const defaults = {
REDIS_URL: "", // Optional, for metrics
DATABASE_URL: "", // Optional, for permanent rooms and VBrowser management
YOUTUBE_API_KEY: "", // Optional, provide one to enable searching YouTube
NODE_ENV: "", // Usually, you should let process.env.NODE_ENV override this
FIREBASE_ADMIN_SDK_CONFIG: "", // Optional, for features requiring sign-in/authentication
FIREBASE_DATABASE_URL: "", // Optional (unused)
STRIPE_SECRET_KEY: "", // Optional, for subscriptions
VBROWSER_SESSION_SECONDS: 10800, // Number of seconds to allow vbrowsers to run for
VBROWSER_SESSION_SECONDS_LARGE: 86400, // Number of seconds to allow large vbrowsers to run for
VM_POOL_RAMP_DOWN_HOURS: "", // Comma separated start/end UTC hours of the ramp down period
VM_POOL_RAMP_UP_HOURS: "", // Comma separated start/end UTC hours of the ramp up period
VBROWSER_TAG: "", // Optional, tag to put on VBrowser VM instances
DO_TOKEN: "", // Optional, for DigitalOcean VMs
DO_GATEWAY: "", // Gateway handling SSL termination
DO_IMAGE: "", // ID of DigitalOcean snapshot image to use for vbrowser
DO_SSH_KEYS: "", // IDs of DigitalOcean SSH keys to access vbrowsers
HETZNER_TOKEN: "", // Optional, for Hetzner VMs
HETZNER_GATEWAY: "", // Gateway handling SSL termination
HETZNER_SSH_KEYS: "", // IDs of Hetzner SSH keys to access vbrowsers
HETZNER_IMAGE: "", // ID of Hetzner snapshot image to use for vbrowser
VM_MANAGER_CONFIG: "", // Comma-separated list of the pools of VMs to run (provider:size:region:minSize:limitSize:hostname), e.g. Docker:large:US:0:1:localhost,Docker:standard:US:0:1:localhost
SCW_SECRET_KEY: "", // Optional, for Scaleway VMs
SCW_ORGANIZATION_ID: "", // Optional, for Scaleway VMs
SCW_GATEWAY: "", // Gateway handling SSL termination
SCW_IMAGE: "", // ID of Scaleway snapshot image to use for vbrowser
DOCKER_VM_HOST: "localhost", // Optional, for Docker VMs
DOCKER_VM_HOST_SSH_USER: "root", // Optional, username for Docker host
DOCKER_VM_HOST_SSH_KEY_BASE64: "", // Optional, private SSH key for Docker host, or default to ~/.ssh/id_rsa content
SSL_KEY_FILE: "", // Optional, Filename of SSL key (to use https)
SSL_CRT_FILE: "", // Optional, Filename of SSL cert (to use https)
PORT: 8080, // Port to use for server
HOST: "0.0.0.0", // Host interface to bind server to
STATS_KEY: "", // Secret string to validate viewing stats
BETA_USER_EMAILS: "", // Comma-delimited list of user emails to include in the beta
CUSTOM_SETTINGS_HOSTNAME: "", // Hostname to send different config settings to client
STREAM_PATH: "", // Path of server that supports additional video streams
CONVERT_PATH: "", // Path of server that supports video conversion
ROOM_CAPACITY: 0, // Maximum capacity of a standard room. Set to 0 for unlimited.
ROOM_CAPACITY_SUB: 0, // Maximum capacity of a sub room. Set to 0 for unlimited.
BUILD_DIRECTORY: "build", // Name of the directory where the built React UI is served from
VM_MIN_UPTIME_MINUTES: 0, // Number of minutes of the hour VMs must exist for before being eligible for termination
SHARD: undefined, // Shard ID of the web server (configure in ecosystem.config.js)
FREE_ROOM_LIMIT: 1, // The maximum number of rooms a free user can have
SUBSCRIBER_ROOM_LIMIT: 20, // The maximum number of rooms a subscriber can have
VMWORKER_PORT: 3100, // Port to use for the vmWorker HTTP server
VM_ASSIGNMENT_TIMEOUT: 75, // Number of seconds to wait for a VM before failing
DISCORD_BOT_TOKEN: "", // Token for the Discord bot that generates WatchParty links
DISCORD_ADMIN_BOT_TOKEN: "", // Optional, for Discord bot to set subscriber roles
DISCORD_ADMIN_BOT_SERVER_ID: "708181150220156929", // Optional, ID of the Discord server
DISCORD_ADMIN_BOT_SUB_ROLE_ID: "722202622345609246", // Optional, ID of subscriber role
MEDIASOUP_SERVER: "", // Optional, URL of the MediaSoup server to broadcast to for larger screen/file shares
TWITCH_PROXY_PATH: "", // Optional, URL of the server that can proxy twitch HLS stream playlists and segments
VBROWSER_ADMIN_KEY: "", // Optional, the key to hit admin endpoints on the vbrowser
OPENSUBTITLES_KEY: "", // Optional, key to OpenSubtitles API
};
export default {
...defaults,
...process.env,
};
+41
View File
@@ -0,0 +1,41 @@
import { Client, IntentsBitField, Events } from "discord.js";
import config from "./config.ts";
import axios from "axios";
import { redisCount } from "./utils/redis.ts";
// URL to invite bot: https://discord.com/api/oauth2/authorize?client_id=1071394728513380372&permissions=2147485696&scope=bot
const HOST_NAME = "https://www.watchparty.me";
const API_NAME = "https://backend.watchparty.me";
const client = new Client({
intents: [IntentsBitField.Flags.Guilds, IntentsBitField.Flags.GuildMessages],
});
client.on("ready", () => {
console.log("I am ready!");
console.log("bot is in %s guilds", client.guilds.cache.size);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
if (interaction.commandName === "watch") {
const preload = interaction.options.get("video")?.value;
// Call the watchparty API to make a room
const response = await axios.post(API_NAME + "/createRoom", {
video: preload,
});
redisCount("discordBotWatch");
// Return the generated room URL
await interaction.reply({
content: `Created a new WatchParty${
preload ? ` with video ${preload}` : ""
}!
${HOST_NAME + "/watch" + response.data.name}
`,
});
}
});
client.login(config.DISCORD_BOT_TOKEN);
+81
View File
@@ -0,0 +1,81 @@
export const apps = [
// {
// name: 'server',
// script: './server/server.ts',
// log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
// interpreter: 'node',
// env: {
// PORT: 80,
// },
// },
{
name: "shard1",
script: "./server/server.ts",
log_date_format: "YYYY-MM-DD HH:mm:ss Z",
interpreter: "node",
env: {
SHARD: 1,
PORT: 3001,
},
},
{
name: "shard2",
script: "./server/server.ts",
log_date_format: "YYYY-MM-DD HH:mm:ss Z",
interpreter: "node",
env: {
SHARD: 2,
PORT: 3002,
},
},
// {
// name: 'shard3',
// script: './server/server.ts',
// log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
// interpreter: 'node',
// env: {
// SHARD: 3,
// PORT: 3003,
// },
// },
{
name: "vmWorker",
script: "./server/vmWorker.ts",
log_date_format: "YYYY-MM-DD HH:mm:ss Z",
interpreter: "node",
env: {
HETZNER_GATEWAY: "gateway2.watchparty.me",
HETZNER_SSH_KEYS: "1570536",
HETZNER_IMAGE: "398857306",
SCW_GATEWAY: "gateway2.watchparty.me",
SCW_IMAGE: "172bd9df-eba5-44e7-add0-f6edbb0f9c64",
DO_GATEWAY: "gateway2.watchparty.me",
DO_IMAGE: "150334605",
DO_SSH_KEYS: "cc:3d:a7:d3:99:17:fe:b7:dd:59:c4:78:14:d4:02:d1",
},
},
{
name: "syncSubs",
script: "./server/syncSubs.ts",
log_date_format: "YYYY-MM-DD HH:mm:ss Z",
interpreter: "node",
},
{
name: "timeSeries",
script: "./server/timeSeries.ts",
log_date_format: "YYYY-MM-DD HH:mm:ss Z",
interpreter: "node",
},
{
name: "cleanup",
script: "./server/cleanup.ts",
log_date_format: "YYYY-MM-DD HH:mm:ss Z",
interpreter: "node",
},
{
name: "discordBot",
script: "./server/discordBot.ts",
log_date_format: "YYYY-MM-DD HH:mm:ss Z",
interpreter: "node",
},
];
+41
View File
@@ -0,0 +1,41 @@
interface YoutubeResult {
kind: string;
etag: string;
snippet: {
publishedAt: string;
channelId: string;
title: string;
description: string;
thumbnails: {
default: {
url: string;
width: number;
height: number;
};
medium: {
url: string;
width: number;
height: number;
};
high: {
url: string;
width: number;
height: number;
};
standard: {
url: string;
width: number;
height: number;
};
};
channelTitle: string;
tags: string[];
categoryId: string;
liveBroadcastContent: string;
localized: {
title: string;
description: string;
};
defaultAudioLanguage: string;
};
}
+1457
View File
File diff suppressed because it is too large Load Diff
+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;
}
}
}
+156
View File
@@ -0,0 +1,156 @@
import config from "./config.ts";
import { getUserByEmail } from "./utils/firebase.ts";
import { insertObject, newPostgres, updateObject } from "./utils/postgres.ts";
import { getAllActiveSubscriptions, getAllCustomers } from "./utils/stripe.ts";
import { Client as DiscordClient, IntentsBitField } from "discord.js";
let lastSubs = "";
let currentSubs = "";
const postgres2 = newPostgres();
// set up the Discord admin bot
const discordBot = new DiscordClient({
intents: [IntentsBitField.Flags.Guilds, IntentsBitField.Flags.GuildMembers],
});
if (config.DISCORD_ADMIN_BOT_TOKEN) {
discordBot.login(config.DISCORD_ADMIN_BOT_TOKEN);
// discordBot.once('ready', () => {
// console.log(`Discord Bot "${discordBot?.user?.username}" ready`);
// });
}
if (process.env.NODE_ENV === "development") {
setTimeout(syncSubscribers, 1000);
}
setInterval(syncSubscribers, 60 * 1000);
async function syncSubscribers() {
if (!config.STRIPE_SECRET_KEY || !config.FIREBASE_ADMIN_SDK_CONFIG) {
return;
}
console.time("syncSubscribers");
// Fetch subs, customers from stripe
const [subs, customers] = await Promise.all([
getAllActiveSubscriptions(),
getAllCustomers(),
]);
const emailMap = new Map();
customers.forEach((cust) => {
emailMap.set(cust.id, cust.email);
});
console.log("%s subs in Stripe", subs.length);
const uidMap = new Map();
for (let i = 0; i < subs.length; i += 50) {
// Batch customers and fetch firebase data
const batch = subs.slice(i, i + 50);
const fbUsers = await Promise.all(
batch
.map((sub) =>
emailMap.get(sub.customer)
? getUserByEmail(emailMap.get(sub.customer))
: null,
)
.filter(Boolean),
);
fbUsers.forEach((user) => {
uidMap.set(user?.email, user?.uid);
});
}
let noUID = 0;
// Create sub objects
let result = subs
.map((sub) => {
let uid = uidMap.get(emailMap.get(sub.customer));
if (!uid) {
uid = emailMap.get(sub.customer);
noUID += 1;
}
return {
customerId: sub.customer,
email: emailMap.get(sub.customer),
status: sub.status,
uid,
};
})
.filter((sub) => sub.uid);
console.log("%s subs to insert", result.length);
console.log("%s subs do not have UID, using email", noUID);
const newResult = result.filter(
(sub, index) =>
index === result.findIndex((other) => sub.uid === other.uid),
);
console.log("%s deduped subs to insert", newResult.length);
if (result.length !== newResult.length) {
// Log the difference
console.log(result.filter((x) => !newResult.includes(x)));
}
result = newResult;
currentSubs = result
.map((sub) => sub.uid)
.sort()
.join();
// Upsert to DB
// console.log(result);
if (currentSubs !== lastSubs) {
try {
await postgres2?.query("BEGIN TRANSACTION");
await postgres2?.query("DELETE FROM subscriber");
await postgres2?.query('UPDATE room SET "isSubRoom" = false');
for (let row of result) {
await insertObject(postgres2, "subscriber", row);
await updateObject(
postgres2,
"room",
{ isSubRoom: true },
{ owner: row.uid },
);
}
await postgres2?.query("COMMIT");
lastSubs = currentSubs;
} catch (e) {
console.error(e);
await postgres2?.query("ROLLBACK");
}
}
if (
discordBot.isReady() &&
config.DISCORD_ADMIN_BOT_SERVER_ID &&
config.DISCORD_ADMIN_BOT_SUB_ROLE_ID
) {
console.log("setting discord roles");
// Update the sub status of users in Discord
// Join the current subs with linked accounts
const guild = discordBot.guilds.cache.get(
config.DISCORD_ADMIN_BOT_SERVER_ID,
);
const role = guild?.roles.cache.get(config.DISCORD_ADMIN_BOT_SUB_ROLE_ID);
const toUpdate = (
await postgres2.query(
`SELECT la.accountid from subscriber JOIN link_account la ON subscriber.uid = la.uid WHERE la.kind = 'discord'`,
)
).rows;
console.log("%s users to set sub role", toUpdate.length);
for (let row of toUpdate) {
try {
const user = await guild?.members.fetch(row.accountid);
if (user && role) {
console.log("assigning role %s to user %s", role, user.id);
await user.roles.add(role);
}
} catch (e: any) {
console.log(e.message);
}
}
}
console.timeEnd("syncSubscribers");
}
+53
View File
@@ -0,0 +1,53 @@
import config from "./config.ts";
import axios from "axios";
import { redis } from "./utils/redis.ts";
import { getStats } from "./utils/getStats.ts";
statsTimeSeries();
setInterval(statsTimeSeries, 5 * 60 * 1000);
async function statsTimeSeries() {
if (redis) {
console.time("timeSeries");
try {
const stats = await getStats();
const isFreePoolFull = (
await axios.get(
"http://localhost:" + config.VMWORKER_PORT + "/isFreePoolFull",
)
).data.isFull;
const datapoint: AnyDict = {
time: new Date(),
currentUsers: stats.counts.currentUsers,
currentVBrowser: stats.counts.currentVBrowser,
currentVBrowserLarge: stats.counts.currentVBrowserLarge,
currentHttp: stats.counts.currentHttp,
currentScreenShare: stats.counts.currentScreenShare,
currentFileShare: stats.counts.currentFileShare,
currentVideoChat: stats.counts.currentVideoChat,
chatMessages: stats.counts.chatMessages,
redisUsage: stats.counts.redisUsage,
hetznerApiRemaining: stats.counts.hetznerApiRemaining,
avgStartMS:
(stats.vBrowserStartMS || []).map(Number).reduce((a, b) => a + b, 0) /
(stats.vBrowserStartMS?.length ?? 0),
vBrowserStarts: stats.counts.vBrowserStarts,
vBrowserLaunches: stats.counts.vBrowserLaunches,
vBrowserFails: stats.counts.vBrowserFails,
vBrowserStagingFails: stats.counts.vBrowserStagingFails,
isFreePoolFull: Number(isFreePoolFull),
};
Object.keys(stats.vmManagerStats).forEach((key) => {
if (stats.vmManagerStats[key]) {
datapoint[key] =
stats.vmManagerStats[key]?.availableVBrowsers?.length;
}
});
await redis.lpush("timeSeries", JSON.stringify(datapoint));
await redis.ltrim("timeSeries", 0, 288);
} catch (e: any) {
console.warn(`[TIMESERIES] %s when collecting stats`, e.code);
}
console.timeEnd("timeSeries");
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"strict": true,
"noEmit": true,
"allowJs": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true
},
"include": [".", "../global.d.ts"]
}
+73
View File
@@ -0,0 +1,73 @@
import config from "../config.ts";
import admin from "firebase-admin";
if (config.FIREBASE_ADMIN_SDK_CONFIG) {
admin.initializeApp({
credential: admin.credential.cert(
JSON.parse(config.FIREBASE_ADMIN_SDK_CONFIG),
),
databaseURL: config.FIREBASE_DATABASE_URL,
});
}
export async function validateUserToken(uid: string, token: string) {
if (!config.FIREBASE_ADMIN_SDK_CONFIG) {
return undefined;
}
if (!token) {
return undefined;
}
try {
const decoded = await admin.auth().verifyIdToken(token);
if (uid !== decoded.uid) {
// Valid but for wrong user
return undefined;
}
return decoded;
} catch (e) {
// Promise rejects if verification failed
console.log(e);
return undefined;
}
}
export async function writeData(key: string, value: string) {
if (!config.FIREBASE_ADMIN_SDK_CONFIG) {
return;
}
await admin.database().ref(key).set(value);
}
export async function getUserByEmail(email: string) {
if (!config.FIREBASE_ADMIN_SDK_CONFIG) {
return null;
}
try {
return await admin.auth().getUserByEmail(email);
} catch (e: any) {
console.log(email, e.message);
}
return null;
}
export async function getUser(uid: string) {
if (!config.FIREBASE_ADMIN_SDK_CONFIG) {
return null;
}
return await admin.auth().getUser(uid);
}
export async function getUserEmail(uid: string) {
if (!config.FIREBASE_ADMIN_SDK_CONFIG) {
return null;
}
const user = await admin.auth().getUser(uid);
return user.email;
}
export async function deleteUser(uid: string) {
if (!config.FIREBASE_ADMIN_SDK_CONFIG) {
return null;
}
return admin.auth().deleteUser(uid);
}
+312
View File
@@ -0,0 +1,312 @@
import type { AssignedVM } from "../vm/base.ts";
import { postgres } from "./postgres.ts";
import os from "node:os";
import { getRedisCountDay, getRedisCountDayDistinct, redis } from "./redis.ts";
import config from "../config.ts";
import { apps } from "../ecosystem.config.js";
export async function getStats() {
const now = Date.now();
let currentUsers = 0;
// Render each shard metrics as its own object
const shardMetrics: Record<string, ShardMetric> = {};
const shardKeys = new Set(
apps.map((app) => `shardMetrics:${app.env?.SHARD ?? 0}`),
);
for (let key of shardKeys) {
const resp2 = await redis?.get(key);
if (resp2) {
shardMetrics[key] = JSON.parse(resp2);
currentUsers += shardMetrics[key].users;
}
}
// Count these from postgres data
let currentHttp = 0;
let currentVBrowser = 0;
let currentVBrowserLarge = 0;
let currentScreenShare = 0;
let currentFileShare = 0;
const currentRoomSizes: Record<string, number> = {};
const result = await postgres?.query<{
roomId: string;
creationTime: Date;
lastUpdateTime: Date;
vanity: string;
isSubRoom: boolean;
roomTitle: string;
roomDescription: string;
mediaPath: string;
owner: string;
password: string;
video: string;
videoTS: number;
vBrowser: AssignedVM;
creator: string;
lock: string;
}>(
`SELECT "roomId", "creationTime", "lastUpdateTime", vanity, "isSubRoom", "roomTitle", "roomDescription", "mediaPath", owner, password,
data->'video' as video, data->'videoTS' as "videoTS", data->'vBrowser' as "vBrowser", data->'creator' as creator, data->'lock' as lock
FROM room
WHERE "lastUpdateTime" > NOW() - INTERVAL '7 day'
AND length(data->>'video') > 0
ORDER BY "creationTime" DESC`,
);
const currentRoomData = await Promise.all(
(result?.rows ?? []).map(async (dbRoom) => {
const vBrowser = dbRoom.vBrowser;
if (vBrowser) {
currentVBrowser += 1;
}
if (vBrowser?.large) {
currentVBrowserLarge += 1;
}
const rosterLength = Number(
await redis?.get(`roomCounts:${dbRoom.roomId}`),
);
let roster = [];
if (rosterLength) {
currentRoomSizes[rosterLength] =
(currentRoomSizes[rosterLength] ?? 0) + 1;
const resp = await redis?.get(`roomRosters:${dbRoom.roomId}`);
if (resp) {
roster = JSON.parse(resp);
}
}
const obj = {
roomId: dbRoom.roomId,
video: dbRoom.video || undefined,
videoTS: dbRoom.videoTS || undefined,
creationTime: dbRoom.creationTime || undefined,
lastUpdateTime: dbRoom.lastUpdateTime || undefined,
vanity: dbRoom.vanity || undefined,
isSubRoom: dbRoom.isSubRoom || undefined,
owner: dbRoom.owner || undefined,
password: dbRoom.password || undefined,
roomTitle: dbRoom.roomTitle || undefined,
roomDescription: dbRoom.roomDescription || undefined,
mediaPath: dbRoom.mediaPath || undefined,
vBrowser,
vBrowserElapsed: vBrowser?.assignTime && now - vBrowser?.assignTime,
lock: dbRoom.lock || undefined,
creator: dbRoom.creator || undefined,
rosterLength,
roster,
};
if (obj.video?.startsWith("http") && rosterLength) {
currentHttp += 1;
}
if (obj.video?.startsWith("screenshare://") && rosterLength) {
currentScreenShare += 1;
}
if (obj.video?.startsWith("fileshare://") && rosterLength) {
currentFileShare += 1;
}
return obj;
}),
);
// Singleton stats below (same for all shards)
const currentVideoChat = 0;
const cpuUsage = os.loadavg()[1] * 100;
const redisUsage = Number(
(await redis?.info())
?.split("\n")
.find((line) => line.startsWith("used_memory:"))
?.split(":")[1]
.trim(),
);
const postgresUsage = Number(
(await postgres?.query(`SELECT pg_database_size('postgres');`))?.rows[0]
.pg_database_size,
);
const numPermaRooms = Number(
(await postgres?.query("SELECT count(1) from room WHERE owner IS NOT NULL"))
?.rows[0].count,
);
const numAllRooms = Number(
(await postgres?.query("SELECT count(1) from room"))?.rows[0].count,
);
const numSubs = Number(
(await postgres?.query("SELECT count(1) from subscriber"))?.rows[0].count,
);
const discordBotWatch = await getRedisCountDay("discordBotWatch");
const createRoomErrors = await getRedisCountDay("createRoomError");
const deleteAccounts = await getRedisCountDay("deleteAccount");
const chatMessages = await getRedisCountDay("chatMessages");
const addReactions = await getRedisCountDay("addReaction");
const hetznerApiRemaining = Number(await redis?.get("hetznerApiRemaining"));
const vBrowserStarts = await getRedisCountDay("vBrowserStarts");
const vBrowserLaunches = await getRedisCountDay("vBrowserLaunches");
const vBrowserFails = await getRedisCountDay("vBrowserFails");
const vBrowserStagingFails = await getRedisCountDay("vBrowserStagingFails");
const vBrowserReimages = await getRedisCountDay("vBrowserReimage");
const vBrowserCleanups = await getRedisCountDay("vBrowserCleanup");
const vBrowserStopTimeout = await getRedisCountDay(
"vBrowserTerminateTimeout",
);
const vBrowserStopEmpty = await getRedisCountDay("vBrowserTerminateEmpty");
const vBrowserStopManual = await getRedisCountDay("vBrowserTerminateManual");
const vBrowserStartMS = await redis?.lrange("vBrowserStartMS", 0, -1);
const vBrowserStageRetries = await redis?.lrange(
"vBrowserStageRetries",
0,
-1,
);
const vBrowserStageFails = await redis?.lrange("vBrowserStageFails", 0, -1);
const vBrowserSessionMS = await redis?.lrange("vBrowserSessionMS", 0, -1);
// const vBrowserVMLifetime = await redis?.lrange('vBrowserVMLifetime', 0, -1);
const proxyReqs = await getRedisCountDay("proxyReqs");
const urlStarts = await getRedisCountDay("urlStarts");
const streamStarts = await getRedisCountDay("streamStarts");
const convertStarts = await getRedisCountDay("convertStarts");
const playlistAdds = await getRedisCountDay("playlistAdds");
const screenShareStarts = await getRedisCountDay("screenShareStarts");
const fileShareStarts = await getRedisCountDay("fileShareStarts");
const mediasoupStarts = await getRedisCountDay("mediasoupStarts");
const videoChatStarts = await getRedisCountDay("videoChatStarts");
const connectStarts = await getRedisCountDay("connectStarts");
const connectStartsDistinct = await getRedisCountDayDistinct(
"connectStartsDistinct",
);
const subUploads = await getRedisCountDay("subUploads");
const subDownloadsOS = await getRedisCountDay("subDownloadsOS");
const subSearchesOS = await getRedisCountDay("subSearchesOS");
const youtubeSearch = await getRedisCountDay("youtubeSearch");
const vBrowserClientIDsCard = await redis?.zcard("vBrowserClientIDs");
const vBrowserUIDsCard = await redis?.zcard("vBrowserUIDs");
const createRoomPreloads = await getRedisCountDay("createRoomPreload");
const vBrowserClientIDs = altArrayToObject(
await redis?.zrevrangebyscore(
"vBrowserClientIDs",
"+inf",
"0",
"WITHSCORES",
"LIMIT",
0,
20,
),
);
const vBrowserUIDs = altArrayToObject(
await redis?.zrevrangebyscore(
"vBrowserUIDs",
"+inf",
"0",
"WITHSCORES",
"LIMIT",
0,
20,
),
);
const vBrowserClientIDMinutes = altArrayToObject(
await redis?.zrevrangebyscore(
"vBrowserClientIDMinutes",
"+inf",
"0",
"WITHSCORES",
"LIMIT",
0,
20,
),
);
const vBrowserUIDMinutes = altArrayToObject(
await redis?.zrevrangebyscore(
"vBrowserUIDMinutes",
"+inf",
"0",
"WITHSCORES",
"LIMIT",
0,
20,
),
);
// Fetch VM stats from vmworker
const resp = await fetch(
"http://localhost:" + config.VMWORKER_PORT + "/stats",
);
const vmManagerStats = await resp.json();
return {
...shardMetrics,
currentRoomSizes,
counts: {
currentUsers,
currentVideoChat,
currentVBrowser,
currentVBrowserLarge,
currentHttp,
currentScreenShare,
currentFileShare,
cpuUsage,
redisUsage,
postgresUsage,
numPermaRooms,
numAllRooms,
numSubs,
discordBotWatch,
createRoomErrors,
createRoomPreloads,
deleteAccounts,
chatMessages,
addReactions,
proxyReqs,
urlStarts,
streamStarts,
convertStarts,
playlistAdds,
screenShareStarts,
fileShareStarts,
mediasoupStarts,
subUploads,
subDownloadsOS,
subSearchesOS,
youtubeSearch,
videoChatStarts,
connectStarts,
connectStartsDistinct,
hetznerApiRemaining,
vBrowserStarts,
vBrowserLaunches,
vBrowserFails,
vBrowserStagingFails,
vBrowserReimages,
vBrowserCleanups,
vBrowserStopManual,
vBrowserStopEmpty,
vBrowserStopTimeout,
vBrowserClientIDsCard,
vBrowserUIDsCard,
},
// Stats object from vmWorker (render as JSON)
vmManagerStats,
// Array of room data (render as JSON)
currentRoomData,
// Arrays of last values (render as one column table)
vBrowserStartMS,
vBrowserStageRetries,
vBrowserStageFails,
vBrowserSessionMS,
// Maps of vbrowser users
vBrowserClientIDs,
vBrowserClientIDMinutes,
vBrowserUIDs,
vBrowserUIDMinutes,
};
}
function altArrayToObject(arr: string[] | undefined) {
const result: Record<string, number> = {};
if (!arr) {
return result;
}
for (let i = 0; i < arr.length; i += 2) {
const k = arr[i];
const v = arr[i + 1];
result[k] = Number(v);
}
return result;
}
+41
View File
@@ -0,0 +1,41 @@
import fs from "node:fs";
import { resolveShard } from "./resolveShard.ts";
let adjectives = fs
.readFileSync(process.cwd() + "/words/adjectives.txt")
.toString()
.split("\n");
const nouns = fs
.readFileSync(process.cwd() + "/words/nouns.txt")
.toString()
.split("\n");
const verbs = fs
.readFileSync(process.cwd() + "/words/verbs.txt")
.toString()
.split("\n");
const randomElement = (array: string[]) =>
array[Math.floor(Math.random() * array.length)];
export function makeRoomName(shard: number | undefined) {
let filteredAdjectives = adjectives;
if (shard) {
// Filter the adjective list by shard
filteredAdjectives = adjectives.filter(
(adj) => resolveShard(adj) === Number(shard),
);
}
const adjective = randomElement(filteredAdjectives);
const noun = randomElement(nouns);
const verb = randomElement(verbs);
return `${adjective}-${noun}-${verb}`;
}
export function makeUserName() {
return `${capFirst(randomElement(adjectives))} ${capFirst(
randomElement(nouns),
)}`;
}
function capFirst(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
+7
View File
@@ -0,0 +1,7 @@
export const findPlaylistVideoByUrl = (
playlist: PlaylistVideo[],
url?: string,
) => {
if (!url) return;
return playlist.find((video) => video.url === url);
};
+87
View File
@@ -0,0 +1,87 @@
import { Client, type QueryResult } from "pg";
import config from "../config.ts";
export let postgres: Client | undefined = undefined;
if (config.DATABASE_URL) {
postgres = new Client({
connectionString: config.DATABASE_URL,
ssl: { rejectUnauthorized: false },
});
postgres.connect();
}
/**
* Use this if we need a new connection instead of sharing.
* Guarantees we'll return a client because we throw if we don't have it configured
* @returns
*/
export function newPostgres() {
if (!config.DATABASE_URL) {
throw new Error("postgres not configured");
}
const postgres = new Client({
connectionString: config.DATABASE_URL,
ssl: { rejectUnauthorized: false },
});
postgres.connect();
return postgres;
}
export async function updateObject(
postgres: Client,
table: string,
object: AnyDict,
condition: AnyDict,
): Promise<QueryResult<any>> {
const columns = Object.keys(object);
const values = Object.values(object);
// TODO support compound conditions, not just one
let query = `UPDATE ${table} SET ${columns
.map((c, i) => `"${c}" = $${i + 1}`)
.join(",")}
WHERE "${Object.keys(condition)[0]}" = $${Object.keys(object).length + 1}
RETURNING *`;
//console.log(query);
const result = await postgres.query(query, [
...values,
condition[Object.keys(condition)[0]],
]);
return result;
}
export async function insertObject(
postgres: Client,
table: string,
object: AnyDict,
): Promise<QueryResult<any>> {
const columns = Object.keys(object);
const values = Object.values(object);
let query = `INSERT INTO ${table} (${columns.map((c) => `"${c}"`).join(",")})
VALUES (${values.map((_, i) => "$" + (i + 1)).join(",")})
RETURNING *`;
// console.log(query);
const result = await postgres.query(query, values);
return result;
}
export async function upsertObject(
postgres: Client,
table: string,
object: AnyDict,
conflict: BooleanDict,
): Promise<QueryResult<any>> {
const columns = Object.keys(object);
const values = Object.values(object);
let query = `INSERT INTO ${table} (${columns.map((c) => `"${c}"`).join(",")})
VALUES (${values.map((_, i) => "$" + (i + 1)).join(",")})
ON CONFLICT (${Object.keys(conflict)
.map((k) => `"${k}"`)
.join(",")})
DO UPDATE SET ${Object.keys(object)
.map((c) => `"${c}" = EXCLUDED."${c}"`)
.join(",")}
RETURNING *`;
// console.log(query);
const result = await postgres.query(query, values);
return result;
}
+72
View File
@@ -0,0 +1,72 @@
import config from "../config.ts";
import { Redis } from "ioredis";
import { getStartOfHour } from "./time.ts";
export let redis: Redis | undefined = undefined;
if (config.REDIS_URL) {
redis = new Redis(config.REDIS_URL);
}
export async function redisCount(prefix: string) {
if (!redis) {
return;
}
try {
const key = `${prefix}:${getStartOfHour()}`;
await redis.incr(key);
await redis.expireat(key, getStartOfHour() + 86400 * 1000);
} catch (e) {
console.log(e);
}
}
export async function getRedisCountDay(prefix: string) {
if (!redis) {
return;
}
// Get counts for last 24 hour keys (including current partial hour)
const keyArr = [];
for (let i = 0; i < 24; i += 1) {
keyArr.push(`${prefix}:${getStartOfHour() - i * 3600 * 1000}`);
}
const values = await redis.mget(...keyArr);
return values.reduce((a, b) => (Number(a) || 0) + (Number(b) || 0), 0);
}
export async function getRedisCountHour(prefix: string) {
if (!redis) {
return;
}
// Get counts for previous full hour
const value = await redis.get(`${prefix}:${getStartOfHour() - 3600 * 1000}`);
return Number(value);
}
export async function redisCountDistinct(prefix: string, item: string) {
if (!redis) {
return;
}
const key = `${prefix}:${getStartOfHour()}`;
await redis.pfadd(key, item);
await redis.expireat(key, getStartOfHour() + 86400 * 1000);
}
export async function getRedisCountDayDistinct(prefix: string) {
if (!redis) {
return;
}
// Get counts for last 24 hour keys (including current partial hour)
const keyArr = [];
for (let i = 0; i < 24; i += 1) {
keyArr.push(`${prefix}:${getStartOfHour() - i * 3600 * 1000}`);
}
return await redis.pfcount(...keyArr);
}
export async function getRedisCountHourDistinct(prefix: string) {
if (!redis) {
return;
}
// Get counts for previous full hour
return await redis.pfcount(`${prefix}:${getStartOfHour() - 3600 * 1000}`);
}
+11
View File
@@ -0,0 +1,11 @@
/** This regex searches for YouTube Video IDs from a YouTube URL */
/** example: */
/** YOUTUBE_VIDEO_ID_REGEX.exec('https://youtube.com/?v=14634524364) */
/** will return the id 14634524364 in the first exec group [1] */
export const YOUTUBE_VIDEO_ID_REGEX =
/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/;
/** These regexes allow us to find hours, minutes and seconds from a ISO 8601 time string */
export const PT_HOURS_REGEX = /(\d{1,2})H/;
export const PT_MINUTES_REGEX = /(\d{1,2})M/;
export const PT_SECONDS_REGEX = /(\d{1,2})S/;
+12
View File
@@ -0,0 +1,12 @@
import config from "../config.ts";
import { apps } from "../ecosystem.config.js";
export function resolveShard(roomId: string): number {
if (!config.SHARD) {
return 0;
}
const numShards = apps.filter((app) => app.env?.SHARD).length;
const letter = roomId[0];
const charCode = letter.charCodeAt(0);
return Number((charCode % numShards) + 1);
}
+61
View File
@@ -0,0 +1,61 @@
import config from "../config.ts";
import Stripe from "stripe";
const stripe = new Stripe(config.STRIPE_SECRET_KEY, {
apiVersion: "2020-08-27",
});
export async function getCustomerByEmail(email: string) {
if (!config.STRIPE_SECRET_KEY) {
return undefined;
}
const customer = await stripe.customers.list({
email,
expand: ["data.subscriptions"],
});
return customer?.data[0];
}
export async function getIsSubscriberByEmail(email: string | undefined) {
if (!config.STRIPE_SECRET_KEY) {
// If Stripe isn't set up assume everyone is a subscriber
return true;
}
if (!email) {
return false;
}
const customer = await getCustomerByEmail(email);
const isSubscriber = Boolean(
customer?.subscriptions?.data?.find((sub) => sub?.status === "active"),
);
return isSubscriber;
}
export async function createSelfServicePortal(
customerId: string,
returnUrl: string,
) {
return await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,
});
}
export async function getAllCustomers() {
const result = [];
for await (const customer of stripe.customers.list({ limit: 100 })) {
result.push(customer);
}
return result;
}
export async function getAllActiveSubscriptions() {
const result = [];
for await (const sub of stripe.subscriptions.list({
limit: 100,
status: "active",
})) {
result.push(sub);
}
return result;
}
+14
View File
@@ -0,0 +1,14 @@
export function getStartOfDay() {
const now = Date.now();
return now - (now % 86400000);
}
export function getStartOfHour() {
const now = Date.now();
return now - (now % 3600000);
}
export function getStartOfMinute() {
const now = Date.now();
return now - (now % 60000);
}
+127
View File
@@ -0,0 +1,127 @@
import config from "../config.ts";
import {
PT_HOURS_REGEX,
PT_MINUTES_REGEX,
PT_SECONDS_REGEX,
YOUTUBE_VIDEO_ID_REGEX,
} from "./regex.ts";
import { youtube, youtube_v3 } from "@googleapis/youtube";
let Youtube = config.YOUTUBE_API_KEY
? youtube({
version: "v3",
auth: config.YOUTUBE_API_KEY,
})
: null;
export const mapYoutubeSearchResult = (
video: youtube_v3.Schema$SearchResult,
): PlaylistVideo => {
return {
channel: video.snippet?.channelTitle ?? "",
url: "https://www.youtube.com/watch?v=" + video?.id?.videoId,
name: video.snippet?.title ?? "",
img: video.snippet?.thumbnails?.default?.url ?? "",
duration: 0,
type: "youtube",
};
};
export const mapYoutubeListResult = (
video: youtube_v3.Schema$Video,
): PlaylistVideo => {
const videoId = video.id;
return {
url: "https://www.youtube.com/watch?v=" + videoId,
name: video.snippet?.title ?? "",
img: video.snippet?.thumbnails?.default?.url ?? "",
channel: video.snippet?.channelTitle ?? "",
duration: getVideoDuration(video.contentDetails?.duration ?? ""),
type: "youtube",
};
};
export const mapYoutubePlaylistResult = (
item: youtube_v3.Schema$PlaylistItem,
): PlaylistVideo => {
return {
url: "https://www.youtube.com/watch?v=" + item.snippet?.resourceId?.videoId,
name: item.snippet?.title ?? "",
img: item.snippet?.thumbnails?.default?.url ?? "",
channel: item.snippet?.channelTitle ?? "",
duration: 0,
// duration: getVideoDuration(video.contentDetails?.duration ?? ''),
type: "youtube",
};
};
export const searchYoutube = async (
query: string,
): Promise<PlaylistVideo[]> => {
const response = await Youtube?.search.list({
part: ["snippet"],
type: ["video"],
maxResults: 25,
q: query,
});
return response?.data?.items?.map(mapYoutubeSearchResult) ?? [];
};
export const youtubePlaylist = async (
playlistId: string,
): Promise<PlaylistVideo[]> => {
const response = await Youtube?.playlistItems.list({
part: ["snippet"],
playlistId,
maxResults: 100,
});
return response?.data?.items?.map(mapYoutubePlaylistResult) ?? [];
};
export const getYoutubeVideoID = (url: string) => {
const idParts = YOUTUBE_VIDEO_ID_REGEX.exec(url);
if (!idParts) {
return;
}
const id = idParts[1];
if (!id) {
return;
}
return id;
};
export const fetchYoutubeVideo = async (
id: string,
): Promise<PlaylistVideo | null> => {
const response = await Youtube?.videos.list({
part: ["snippet", "contentDetails"],
id: [id],
});
const top = response?.data?.items?.[0];
return top ? mapYoutubeListResult(top) : null;
};
export const getVideoDuration = (string: string): number => {
if (!string) {
return 0;
}
const hoursParts = PT_HOURS_REGEX.exec(string);
const minutesParts = PT_MINUTES_REGEX.exec(string);
const secondsParts = PT_SECONDS_REGEX.exec(string);
const hours = hoursParts ? parseInt(hoursParts[1]) : 0;
const minutes = minutesParts ? parseInt(minutesParts[1]) : 0;
const seconds = secondsParts ? parseInt(secondsParts[1]) : 0;
const totalSeconds = seconds + minutes * 60 + hours * 60 * 60;
return totalSeconds;
};
export const isYouTube = (input: string) => {
return (
input.startsWith("https://www.youtube.com/") ||
input.startsWith("https://youtu.be/")
);
};
+625
View File
@@ -0,0 +1,625 @@
import config from "../config.ts";
import axios from "axios";
import { redis, redisCount } from "../utils/redis.ts";
import { postgres as pg } from "../utils/postgres.ts";
import type { PoolConfig, PoolRegion } from "./utils.ts";
import type { Client } from "pg";
const incrInterval = 5 * 1000;
const decrInterval = 15 * 1000;
const cleanupInterval = 5 * 60 * 1000;
// If postgres isn't configured we can still run in stateless mode
// Only start/get/terminate can be used, otherwise exception will be thrown
const postgres = !pg ? (null as unknown as Client) : pg;
export abstract class VMManager {
protected isLarge = false;
protected region: PoolRegion = "US";
private limitSize = 0;
private minSize = 0;
protected hostname: string | undefined;
constructor({ isLarge, region, limitSize, minSize, hostname }: PoolConfig) {
this.isLarge = isLarge;
this.region = region;
this.limitSize = Number(limitSize) || 0;
this.minSize = Number(minSize) || 0;
this.hostname = hostname;
}
public getIsLarge = () => {
return this.isLarge;
};
public getRegion = () => {
return this.region;
};
public getMinSize = () => {
return this.minSize;
};
public getLimitSize = () => {
return this.limitSize;
};
public getTargetBuffer = () => {
let buffer = this.limitSize * 0.02;
// If ramping config, adjust buffer based on the hour
// During ramp down hours, keep a smaller buffer
// During ramp up hours, keep a larger buffer
// const rampDownHours = config.VM_POOL_RAMP_DOWN_HOURS.split(",").map(Number);
// const rampUpHours = config.VM_POOL_RAMP_UP_HOURS.split(",").map(Number);
// const nowHour = new Date().getUTCHours();
// const isRampDown =
// rampDownHours.length &&
// pointInInterval24(nowHour, rampDownHours[0], rampDownHours[1]);
// const isRampUp =
// rampUpHours.length &&
// pointInInterval24(nowHour, rampUpHours[0], rampUpHours[1]);
// if (isRampDown) {
// buffer *= 0.5;
// } else if (isRampUp) {
// buffer *= 1.5;
// }
return Math.ceil(buffer);
};
public getCurrentSize = async () => {
const { rows } = await postgres.query(
`SELECT count(1) FROM vbrowser WHERE pool = $1`,
[this.getPoolName()],
);
return Number(rows[0]?.count);
};
public getPoolName = () => {
return this.id + (this.isLarge ? "Large" : "") + this.region;
};
public getAvailableCount = async (): Promise<number> => {
const { rows } = await postgres.query(
`SELECT count(1) FROM vbrowser WHERE pool = $1 and state = 'available'`,
[this.getPoolName()],
);
return Number(rows[0]?.count);
};
public getStagingCount = async (): Promise<number> => {
const { rows } = await postgres.query(
`SELECT count(1) FROM vbrowser WHERE pool = $1 and state = 'staging'`,
[this.getPoolName()],
);
return Number(rows[0]?.count);
};
public getAvailableVBrowsers = async (): Promise<string[]> => {
const { rows } = await postgres.query(
`SELECT vmid from vbrowser WHERE pool = $1 and state = 'available'`,
[this.getPoolName()],
);
return rows.map((row: any) => row.vmid);
};
public getStagingVBrowsers = async (): Promise<string[]> => {
const { rows } = await postgres.query(
`SELECT vmid from vbrowser WHERE pool = $1 and state = 'staging'`,
[this.getPoolName()],
);
return rows.map((row: any) => row.vmid);
};
public getTag = () => {
return (
(config.VBROWSER_TAG || "vbrowser") +
this.region +
(this.isLarge ? "Large" : "")
);
};
public assignVM = async (
roomId: string,
uid: string,
): Promise<AssignedVM | undefined> => {
if (!roomId || !uid) {
return undefined;
}
// Update and use SKIP LOCKED to ensure each consumer gets a different one
const { rows } = await postgres.query(
`
UPDATE vbrowser
SET "roomId" = $1, uid = $2, "heartbeatTime" = NOW(), "assignTime" = NOW(), state = 'used'
WHERE id = (
SELECT id
FROM vbrowser
WHERE state = 'available'
AND pool = $3
ORDER BY "creationTime" DESC
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING data, pass`,
[roomId, uid, this.getPoolName()],
);
let vm: VM | undefined = rows[0]?.data;
let pass: string = rows[0]?.pass;
if (!vm) {
return;
}
return { ...vm, pass, assignTime: Date.now() };
};
public resetVM = async (vmid: string, roomId?: string): Promise<void> => {
if (roomId !== undefined) {
// verify the roomId matches if user initiated
const { rows } = await postgres.query(
`SELECT "roomId" FROM vbrowser WHERE pool = $1 AND vmid = $2`,
[this.getPoolName(), vmid],
);
if (rows[0]?.roomId && rows[0]?.roomId !== roomId) {
console.log(
"[RESET] %s: roomId mismatch on %s, expected %s, got %s",
this.getPoolName(),
vmid,
rows[0]?.roomId,
roomId,
);
return;
}
}
console.log("[RESET]", this.getPoolName(), vmid, roomId);
// We generally want to reuse if the provider has per-hour billing
// Since most user sessions are less than an hour
// Otherwise if it's per-second or Docker, it's easier to just terminate it on reboot
if (this.reuseVMs) {
// To reset without API calls/reimaging (logic can be reused on any cloud provider so we can avoid individual reboot implementations):
// Update vbrowser script to generate uuid password
// add admin endpoints to vbrowser to get password and reboot
// to reset, hit reboot endpoint with admin key OR call cloud reboot method
// in checkstaging, check password endpoint with admin key, update DB once available
const { rows } = await postgres.query(
`SELECT image FROM vbrowser WHERE pool = $1 AND vmid = $2`,
[this.getPoolName(), vmid],
);
const vmImageId = rows[0]?.image;
// Check if VM needs to be reimaged
if (this.imageId !== vmImageId) {
await this.reimageVM(vmid);
redisCount("vBrowserReimage");
// Update the vmImageId
await postgres.query(
`UPDATE vbrowser SET image = $3 WHERE pool = $1 AND vmid = $2`,
[this.getPoolName(), vmid, this.imageId],
);
} else {
await this.rebootVM(vmid);
}
// we could crash here and then row will remain in used state
// Once the heartbeat becomes stale cleanup will reset it again
const result = await postgres.query(
`
INSERT INTO vbrowser(pool, vmid, "creationTime", state)
VALUES($1, $2, NOW(), 'staging')
ON CONFLICT(pool, vmid) DO
UPDATE SET state = 'staging',
"roomId" = NULL, uid = NULL, retries = 0, "heartbeatTime" = NULL, "assignTime" = NULL, data = NULL
`,
[this.getPoolName(), vmid],
);
console.log("UPSERT", result.rowCount);
// Normally this should be an update, but we could insert if:
// if cleaning up a VM we didn't record in db on create
// if we resized down and deleted db row but didn't complete the termination
} else {
this.terminateVMWrapper(vmid);
}
};
public startVMWrapper = async () => {
// generate credentials and boot a VM
const password = crypto.randomUUID();
const id = await this.startVM(password);
// We might fail to record it if crashing here but cleanup will reset it
await postgres.query(
`
INSERT INTO vbrowser(pool, vmid, "creationTime", state, image)
VALUES($1, $2, NOW(), 'staging', $3)`,
[this.getPoolName(), id, this.imageId],
);
redisCount("vBrowserLaunches");
return id;
};
protected terminateVMWrapper = async (vmid: string) => {
console.log("[TERMINATE]", this.getPoolName(), vmid);
// Update the DB before calling terminate
// If we don't actually complete the termination, cleanup will reset it
const { command, rowCount } = await postgres.query(
`DELETE FROM vbrowser WHERE pool = $1 AND vmid = $2 RETURNING id`,
[this.getPoolName(), vmid],
);
console.log(command, rowCount);
// We can log the VM lifetime by returning the creationTime and diffing
await this.terminateVM(vmid);
};
checkVMReady = async (host: string) => {
// NOTE: This URL doesn't work for docker since it doesn't have /
// But since we don't reuse docker VMs it's ok
const url = "https://" + host.replace("/", "/health");
try {
// const out = execSync(`curl -i -L -v --ipv4 '${host}'`);
// if (!out.toString().startsWith('OK') && !out.toString().startsWith('404 page not found')) {
// throw new Error('mismatched response from health');
// }
const resp = await axios({
method: "GET",
url,
timeout: 1000,
});
// Check to make sure the VM was recently rebooted (we could also check on the password to ensure reset)
const timeSinceBoot = Date.now() / 1000 - Number(resp.data);
// console.log(timeSinceBoot);
return this.reuseVMs ? timeSinceBoot < 150 * 1000 : true;
} catch (e) {
// console.log(url, e.message, e.response?.status);
return false;
}
};
public runBackgroundJobs = async () => {
const resizeVMGroupIncr = async () => {
const availableCount = await this.getAvailableCount();
const stagingCount = await this.getStagingCount();
const currentSize = await this.getCurrentSize();
let launch = false;
launch =
availableCount + stagingCount < this.getTargetBuffer() &&
currentSize < (this.getLimitSize() || Infinity);
if (launch) {
console.log(
"[RESIZE-INCR]",
this.getPoolName(),
"target:",
this.getTargetBuffer(),
"available:",
availableCount,
"staging:",
stagingCount,
"currentSize:",
currentSize,
"limit:",
this.getLimitSize(),
);
try {
await this.startVMWrapper();
} catch (e: any) {
console.log(
e.response?.status,
JSON.stringify(e.response?.data),
e.config?.url,
e.config?.data,
);
}
}
};
const resizeVMGroupDecr = async () => {
const availableCount = await this.getAvailableCount();
const stagingCount = await this.getStagingCount();
let unlaunch = false;
unlaunch = availableCount + stagingCount > this.getTargetBuffer();
if (unlaunch) {
// use SKIP LOCKED to delete to avoid deleting VM that might be assigning
// filter to only VMs eligible for deletion
// they must be up for long enough
// keep the oldest min pool size number of VMs
// Hetzner/DO/Scaleway rounds up to nearest hour
let modulo = 3600;
const { rows } = await postgres.query(
`
DELETE FROM vbrowser
WHERE id = (
SELECT id
FROM vbrowser
WHERE pool = $1
AND state = 'available'
AND id >= (SELECT id from vbrowser WHERE pool = $1 ORDER BY id ASC LIMIT 1 OFFSET $2)
AND CAST(extract(epoch from now() - "creationTime") as INT) % $3 > $4
FOR UPDATE SKIP LOCKED
LIMIT 1
) RETURNING vmid`,
[
this.getPoolName(),
this.getMinSize(),
modulo,
config.VM_MIN_UPTIME_MINUTES * 60, // to seconds
],
);
const first = rows[0];
if (first) {
console.log("[RESIZE-DECR] %s: %s", this.getPoolName(), first.vmid);
await this.terminateVMWrapper(first.vmid);
}
}
};
const cleanupVMGroup = async () => {
// Reset hanging VMs
// It's possible we created a VM but lost track of it
// Take the list of VMs from API
// subtract VMs that have a heartbeat or available or staging
let allVMs = [];
try {
allVMs = await this.listVMs(this.getTag());
} catch (e) {
console.log(
"[CLEANUP] %s: failed to fetch VM list",
this.getPoolName(),
);
return;
}
const { rows } = await postgres.query(
`
SELECT vmid from vbrowser
WHERE pool = $1
AND
("heartbeatTime" > (NOW() - INTERVAL '5 minutes')
OR state = 'staging'
OR state = 'available')
`,
[this.getPoolName()],
);
const inUse = new Set(rows.map((row: any) => row.vmid));
console.log(
"[CLEANUP] %s: found %s VMs, %s to keep",
this.getPoolName(),
allVMs.length,
inUse.size,
);
for (let server of allVMs) {
if (!inUse.has(server.id)) {
redisCount("vBrowserCleanup");
console.log("[CLEANUP]", this.getPoolName(), server.id);
try {
await this.resetVM(server.id);
//this.terminateVMWrapper(server.id);
} catch (e: any) {
console.warn("[CLEANUP]", this.getPoolName(), e.response?.data);
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
};
const checkStaging = async () => {
// Increment retry count and return data
const { rows } = await postgres.query(
`
UPDATE vbrowser
SET retries = retries + 1
WHERE pool = $1 and state = 'staging'
RETURNING id, vmid, data, retries
`,
[this.getPoolName()],
);
const stagingPromises = rows.map(async (row: any): Promise<string> => {
const rowid = row.id;
const vmid: string = row.vmid;
const retryCount: number = row.retries;
let vm: VM | null = row.data;
if (retryCount < this.minRetries) {
if (config.NODE_ENV === "development") {
console.log(
"[CHECKSTAGING] %s: [vmid: %s] [attempt: %s] waiting for minRetries",
this.getPoolName(),
vmid,
retryCount,
);
}
// Do a minimum # of retries to give reboot time
return [vmid, retryCount, false].join(",");
}
// Fetch data on first attempt
// Try again only every once in a while to reduce load on API
const shouldFetchVM =
retryCount === this.minRetries + 1 || retryCount % 30 === 0;
// Refetch the VM every once in a while even if cached to check if it should be deleted
if (shouldFetchVM) {
try {
vm = await this.getVM(vmid);
} catch (e: any) {
console.warn(e.response?.data);
if (e.response?.status === 404) {
// Remove the VM because the provider says it doesn't exist
await postgres.query("DELETE FROM vbrowser WHERE id = $1", [
rowid,
]);
throw new Error("failed to find vm " + vmid);
}
}
if (vm?.host) {
// Save the VM data
await postgres.query(
`UPDATE vbrowser SET data = $1 WHERE id = $2`,
[vm, rowid],
);
}
}
if (!vm?.host) {
console.log(
"[CHECKSTAGING] %s: no host for vm %s",
this.getPoolName(),
vmid,
);
throw new Error("no host for vm " + vmid);
}
if (retryCount % 150 === 0) {
console.log(
"[CHECKSTAGING] %s: %s poweron, attach to network",
this.getPoolName(),
vmid,
);
this.powerOn(vmid);
//this.attachToNetwork(vmid);
}
if (retryCount % 180 === 0) {
console.log("[CHECKSTAGING]", this.getPoolName(), "giving up:", vmid);
redisCount("vBrowserStagingFails");
await redis?.lpush("vBrowserStageFails", vmid);
await redis?.ltrim("vBrowserStageFails", 0, 19);
// VM didn't come up. set image to null so we reimage
await postgres.query(
`UPDATE vbrowser SET image = NULL WHERE pool = $1 AND vmid = $2`,
[this.getPoolName(), vmid],
);
await this.resetVM(vmid);
}
if (retryCount >= 180) {
throw new Error("too many attempts on vm " + vmid);
}
const ready = await this.checkVMReady(vm.host);
if (
ready ||
retryCount % (config.NODE_ENV === "development" ? 1 : 30) === 0
) {
console.log(
"[CHECKSTAGING] %s: [ready: %s] [vmid: %s] [retries: %s] [host: %s]",
this.getPoolName(),
ready,
vmid,
retryCount,
vm?.host,
);
}
if (ready) {
const passUrl =
"https://" +
(vm.host.includes("/")
? vm.host.replace("/", "/password")
: vm.host + "/password") +
(vm.host.includes("?") ? "&" : "?") +
"key=" +
config.VBROWSER_ADMIN_KEY;
// console.log(passUrl);
const resp2 = await axios({
method: "GET",
url: passUrl,
timeout: 1000,
});
const pass = resp2.data;
// password is a uuid so it should never match the previous value, if it does we probably didn't reset properly
const { rows } = await postgres.query(
`UPDATE vbrowser SET state = 'available', pass = $2 WHERE id = $1 AND pass IS DISTINCT FROM $2`,
[rowid, pass],
);
// console.log(rows);
await redis?.lpush("vBrowserStageRetries", retryCount);
await redis?.ltrim("vBrowserStageRetries", 0, 19);
}
return [vmid, retryCount, ready].join(",");
});
// TODO log something if we timeout
const result = await Promise.race([
Promise.allSettled(stagingPromises),
new Promise((resolve) => setTimeout(resolve, 30000)),
]);
return result;
};
console.log("[VMWORKER] %s: starting background jobs", this.getPoolName());
setInterval(resizeVMGroupIncr, incrInterval);
setInterval(resizeVMGroupDecr, decrInterval);
setInterval(async () => {
console.log(
"[STATS] %s: currentSize %s, available %s, staging %s, target %s",
this.getPoolName(),
await this.getCurrentSize(),
await this.getAvailableCount(),
await this.getStagingCount(),
this.getTargetBuffer(),
);
}, 10000);
// The following may take a while per iteration
// Use while loop and delay between iterations rather than setInterval to avoid stacking requests
setImmediate(async () => {
while (true) {
console.time(this.getPoolName() + ":cleanup");
try {
await cleanupVMGroup();
} catch (e: any) {
console.warn(
"[CLEANUPVMGROUP-ERROR]",
this.getPoolName(),
e.response?.data,
);
}
console.timeEnd(this.getPoolName() + ":cleanup");
await new Promise((resolve) => setTimeout(resolve, cleanupInterval));
}
});
setImmediate(async () => {
while (true) {
// console.time(this.getPoolName() + ':checkstaging');
try {
await checkStaging();
} catch (e) {
console.warn("[CHECKSTAGING-ERROR]", this.getPoolName(), e);
}
// console.timeEnd(this.getPoolName() + ':checkstaging');
await new Promise((resolve) => setTimeout(resolve, 1000));
}
});
};
public abstract id: string;
protected abstract size: string;
protected abstract largeSize: string;
protected abstract minRetries: number;
protected abstract reuseVMs: boolean;
protected abstract imageId: string;
protected abstract startVM: (name: string) => Promise<string>;
protected abstract rebootVM: (id: string) => Promise<void>;
protected abstract reimageVM: (id: string) => Promise<void>;
protected abstract terminateVM: (id: string) => Promise<void>;
public abstract getVM: (id: string) => Promise<VM>;
protected abstract listVMs: (filter: string) => Promise<VM[]>;
protected abstract powerOn: (id: string) => Promise<void>;
protected abstract attachToNetwork: (id: string) => Promise<void>;
protected abstract mapServerObject: (server: any) => VM;
public abstract updateSnapshot: () => Promise<string>;
}
function pointInInterval24(x: number, a: number, b: number) {
return nonNegativeMod(x - a, 24) <= nonNegativeMod(b - a, 24);
}
function nonNegativeMod(n: number, m: number) {
return ((n % m) + m) % m;
}
export interface VM {
id: string;
host: string;
provider: string;
large: boolean;
region: string;
}
export interface AssignedVM extends VM {
pass: string;
assignTime: number;
controllerClient?: string;
creatorUID?: string;
creatorClientID?: string;
}
export interface VMManagers {
standard: VMManager | null;
large: VMManager | null;
US: VMManager | null;
}
+139
View File
@@ -0,0 +1,139 @@
import config from "../config.ts";
import axios from "axios";
import { VMManager, type VM } from "./base.ts";
const DO_TOKEN = config.DO_TOKEN;
const region = "sfo3";
const gatewayHost = config.DO_GATEWAY;
const sshKeys = config.DO_SSH_KEYS.split(",");
export class DigitalOcean extends VMManager {
size = "s-2vcpu-2gb"; // s-1vcpu-1gb, s-1vcpu-2gb, s-2vcpu-2gb, s-2vcpu-4gb, c-2, s-4vcpu-8gb
largeSize = "s-4vcpu-8gb";
minRetries = 5;
reuseVMs = true;
id = "DO";
imageId = config.DO_IMAGE;
startVM = async (name: string) => {
const response = await axios({
method: "POST",
url: `https://api.digitalocean.com/v2/droplets`,
headers: {
Authorization: "Bearer " + DO_TOKEN,
"Content-Type": "application/json",
},
data: {
name: name,
region: region,
size: this.isLarge ? this.largeSize : this.size,
image: Number(this.imageId),
ssh_keys: sshKeys,
private_networking: true,
// user_data: cloudInit(),
tags: [this.getTag()],
},
});
const id = response.data.droplet.id;
return id;
};
terminateVM = async (id: string) => {
const response = await axios({
method: "DELETE",
url: `https://api.digitalocean.com/v2/droplets/${id}`,
headers: {
Authorization: "Bearer " + DO_TOKEN,
"Content-Type": "application/json",
},
});
};
rebootVM = async (id: string) => {
// Reboot the VM
const response2 = await axios({
method: "POST",
url: `https://api.digitalocean.com/v2/droplets/${id}/actions`,
headers: {
Authorization: "Bearer " + DO_TOKEN,
"Content-Type": "application/json",
},
data: {
type: "reboot",
},
});
return;
};
reimageVM = async (id: string) => {
// Rebuild the VM
const response2 = await axios({
method: "POST",
url: `https://api.digitalocean.com/v2/droplets/${id}/actions`,
headers: {
Authorization: "Bearer " + DO_TOKEN,
"Content-Type": "application/json",
},
data: {
type: "rebuild",
image: Number(this.imageId),
},
});
return;
};
getVM = async (id: string) => {
const response = await axios({
method: "GET",
url: `https://api.digitalocean.com/v2/droplets/${id}`,
headers: {
Authorization: "Bearer " + DO_TOKEN,
"Content-Type": "application/json",
},
});
let server = this.mapServerObject(response.data.droplet);
return server;
};
listVMs = async (filter: string) => {
// console.log(filter, tags);
const response = await axios({
method: "GET",
url: `https://api.digitalocean.com/v2/droplets`,
headers: {
Authorization: "Bearer " + DO_TOKEN,
"Content-Type": "application/json",
},
params: {
// TODO need to update if over 100 results
per_page: 100,
tag_name: filter,
},
});
return response.data.droplets.map(this.mapServerObject);
};
powerOn = async (_id: string) => {};
attachToNetwork = async (_id: string) => {};
updateSnapshot = async () => {
return "";
};
mapServerObject = (server: any): VM => {
// const ip = server.networks.v4.find(
// (network: any) => network.type === 'private',
// )?.ip_address;
const ip = server.networks.v4.find(
(network: any) => network.type === "public",
)?.ip_address;
return {
id: server.id?.toString(),
// The gateway handles SSL termination and proxies to the private IP
host: ip ? `${gatewayHost}/?ip=${ip}` : "",
provider: this.id,
large: this.isLarge,
region: this.region,
};
};
}
+134
View File
@@ -0,0 +1,134 @@
// This assumes an installation of Docker exists on the given host
// and that host is configured to accept our SSH key
import config from "../config.ts";
import { VMManager, type VM } from "./base.ts";
import { imageName } from "./utils.ts";
import fs from "node:fs";
import { homedir } from "node:os";
import { NodeSSH } from "node-ssh";
export class Docker extends VMManager {
// TODO support multiple Docker providers in the pool config with same region
size = "";
largeSize = "";
minRetries = 0;
reuseVMs = false;
id = "Docker";
ssh: NodeSSH | undefined = undefined;
imageId = imageName;
getSSH = async () => {
if (this.ssh && this.ssh.isConnected()) {
return this.ssh;
}
const sshConfig = {
username: config.DOCKER_VM_HOST_SSH_USER,
host: this.hostname,
// The private key the Docker host is configured to accept
privateKey: config.DOCKER_VM_HOST_SSH_KEY_BASE64
? Buffer.from(config.DOCKER_VM_HOST_SSH_KEY_BASE64, "base64").toString()
: fs.readFileSync(homedir() + "/.ssh/id_rsa").toString(),
};
this.ssh = new NodeSSH();
await this.ssh.connect(sshConfig);
return this.ssh;
};
startVM = async (name: string) => {
const tag = this.getTag();
const conn = await this.getSSH();
// If in development, have neko share the same SSL cert as the other services
// If in production, they are probably on different hosts and neko is behind a reverse proxy for SSL termination
const sslEnv =
config.NODE_ENV === "development" &&
config.SSL_KEY_FILE &&
config.SSL_CRT_FILE
? `-e NEKO_KEY="${config.SSL_KEY_FILE}" -e NEKO_CERT="${config.SSL_CRT_FILE}"`
: "";
const { stdout, stderr } = await conn.execCommand(
`
#!/bin/bash
set -e
PORT=$(comm -23 <(seq 5000 5063 | sort) <(ss -Htan | awk '{print $4}' | cut -d':' -f2 | sort -u) | sort -n | head -n 1)
INDEX=$(($PORT - 5000))
UDP_START=$((59000+$INDEX*100))
UDP_END=$((59099+$INDEX*100))
docker run -d --rm --name=${name} --memory="2g" --cpus="2" -p $PORT:$PORT -p $UDP_START-$UDP_END:$UDP_START-$UDP_END/udp -v /etc/letsencrypt:/etc/letsencrypt -l ${tag} -l index=$INDEX --log-opt max-size=1g --shm-size=1g --cap-add="SYS_ADMIN" ${sslEnv} -e DISPLAY=":99.0" -e NEKO_PASSWORD=${name} -e NEKO_PASSWORD_ADMIN=${name} -e NEKO_ADMIN_KEY=${config.VBROWSER_ADMIN_KEY} -e NEKO_BIND=":$PORT" -e NEKO_EPR=":$UDP_START-$UDP_END" -e NEKO_H264="1" ${imageName}
`,
);
console.log(stdout, stderr);
return stdout.trim();
};
terminateVM = async (id: string) => {
const conn = await this.getSSH();
const { stdout, stderr } = await conn.execCommand(`docker rm -fv ${id}`);
console.log(stdout, stderr);
return;
};
rebootVM = async (id: string) => {
// Docker containers aren't set to reuse, so do nothing (reset will terminate)
};
reimageVM = async (id: string) => {
const conn = await this.getSSH();
const { stdout, stderr } = await conn.execCommand(
`docker pull ${this.imageId}`,
);
console.log(stdout, stderr);
// The container is out of date. Delete it
this.terminateVMWrapper(id);
return;
};
getVM = async (id: string) => {
const conn = await this.getSSH();
const { stdout } = await conn.execCommand(`docker inspect ${id}`);
let data = null;
try {
data = JSON.parse(stdout)[0];
if (!data) {
throw new Error("no container with this ID found");
}
} catch {
console.warn(stdout);
throw new Error("failed to parse json");
}
let server = this.mapServerObject(data);
return server;
};
listVMs = async (filter: string) => {
const conn = await this.getSSH();
const listCmd = `docker inspect $(docker ps --filter label=${filter} --quiet --no-trunc)`;
const { stdout } = await conn.execCommand(listCmd);
if (!stdout) {
return [];
}
let data = [];
try {
data = JSON.parse(stdout);
} catch (e) {
console.warn(stdout);
throw new Error("failed to parse json");
}
return data.map(this.mapServerObject);
};
powerOn = async (id: string) => {};
attachToNetwork = async (id: string) => {};
updateSnapshot = async () => {
return "";
};
mapServerObject = (server: any): VM => ({
id: server.Id,
host: `${this.hostname}:${5000 + Number(server.Config?.Labels?.index)}`,
provider: this.id,
large: this.isLarge,
region: this.region,
});
}
+258
View File
@@ -0,0 +1,258 @@
import config from "../config.ts";
import axios from "axios";
import { VMManager, type VM } from "./base.ts";
import fs from "node:fs";
import { redis } from "../utils/redis.ts";
const HETZNER_TOKEN = config.HETZNER_TOKEN;
const sshKeys = config.HETZNER_SSH_KEYS.split(",").map(Number);
export class Hetzner extends VMManager {
size = "cpx11"; // cpx11, cx22 (not available in US yet)
largeSize = "cpx31"; // cpx21, cpx31, ccx13, ccx23, cx32
minRetries = 5;
reuseVMs = true;
id = "Hetzner";
gateway = config.HETZNER_GATEWAY;
imageId = config.HETZNER_IMAGE;
private getRandomDatacenter() {
// US
let datacenters = ["ash"];
if (this.region === "USW") {
datacenters = ["hil"];
} else if (this.region === "EU") {
datacenters = ["nbg1", "fsn1", "hel1"];
}
return datacenters[Math.floor(Math.random() * datacenters.length)];
}
startVM = async (name: string) => {
const data = {
name: name,
server_type: this.isLarge ? this.largeSize : this.size,
start_after_create: true,
image: Number(this.imageId),
ssh_keys: sshKeys,
public_net: {
enable_ipv4: true,
enable_ipv6: false,
},
// networks: [
// this.networks[Math.floor(Math.random() * this.networks.length)],
// ],
// user_data: `replace with vbrowser.sh startup script if we want to boot vbrowser on instance creation (won't trigger on rebuild/restart)`
labels: {
[this.getTag()]: "1",
},
location: this.getRandomDatacenter(),
};
const response = await axios({
method: "POST",
url: `https://api.hetzner.cloud/v1/servers`,
headers: {
Authorization: "Bearer " + HETZNER_TOKEN,
"Content-Type": "application/json",
"User-Agent": "test",
},
data,
});
const id = response.data.server.id;
return id;
};
terminateVM = async (id: string) => {
await axios({
method: "DELETE",
url: `https://api.hetzner.cloud/v1/servers/${id}`,
headers: {
Authorization: "Bearer " + HETZNER_TOKEN,
"User-Agent": "test",
},
});
};
rebootVM = async (id: string) => {
// Reboot the VM
await axios({
method: "POST",
url: `https://api.hetzner.cloud/v1/servers/${id}/actions/reboot`,
headers: {
Authorization: "Bearer " + HETZNER_TOKEN,
"User-Agent": "test",
},
});
return;
};
reimageVM = async (id: string) => {
// Rebuild the VM
await axios({
method: "POST",
url: `https://api.hetzner.cloud/v1/servers/${id}/actions/rebuild`,
headers: {
Authorization: "Bearer " + HETZNER_TOKEN,
"User-Agent": "test",
},
data: {
image: Number(this.imageId),
},
});
return;
};
getVM = async (id: string) => {
const response: any = await axios({
method: "GET",
url: `https://api.hetzner.cloud/v1/servers/${id}`,
headers: {
Authorization: "Bearer " + HETZNER_TOKEN,
"User-Agent": "test",
},
});
console.log(
"[GETVM] %s: %s rate limit remaining",
id,
response?.headers["ratelimit-remaining"],
);
await redis?.set(
"hetznerApiRemaining",
response?.headers["ratelimit-remaining"],
);
const server = this.mapServerObject(response.data.server);
return server;
};
listVMs = async (filter: string) => {
const limit = this.getLimitSize();
const pageCount = Math.ceil((limit || 1) / 50);
const pages = Array.from(Array(pageCount).keys()).map((i) => i + 1);
const responses: any[] = await Promise.all(
pages.map((page) =>
axios({
method: "GET",
url: `https://api.hetzner.cloud/v1/servers`,
headers: {
Authorization: "Bearer " + HETZNER_TOKEN,
"User-Agent": "test",
},
params: {
sort: "id:asc",
page,
per_page: 50,
label_selector: filter,
},
}),
),
);
const responsesMapped = responses.map((response) =>
response.data.servers.map(this.mapServerObject),
);
return responsesMapped.flat();
};
powerOn = async (id: string) => {
// Poweron the server (usually not needed)
try {
await axios({
method: "POST",
url: `https://api.hetzner.cloud/v1/servers/${id}/actions/poweron`,
headers: {
Authorization: "Bearer " + HETZNER_TOKEN,
"Content-Type": "application/json",
"User-Agent": "test",
},
});
} catch (e) {
console.log("%s failed to poweron", id);
}
};
attachToNetwork = async (id: string) => {
// // Attach server to network (usually not needed)
// try {
// const response: any = await axios({
// method: 'GET',
// url: `https://api.hetzner.cloud/v1/servers/${id}`,
// headers: {
// Authorization: 'Bearer ' + HETZNER_TOKEN,
// },
// });
// if (response.data.server.private_net?.[0] == null) {
// await axios({
// method: 'POST',
// url: `https://api.hetzner.cloud/v1/servers/${id}/actions/attach_to_network`,
// headers: {
// Authorization: 'Bearer ' + HETZNER_TOKEN,
// 'Content-Type': 'application/json',
// },
// data: {
// network:
// this.networks[Math.floor(Math.random() * this.networks.length)],
// },
// });
// }
// } catch (e: any) {
// console.log('%s failed to attach to network', id);
// console.log(e.response?.data);
// }
};
updateSnapshot = async () => {
const response = await axios({
method: "POST",
url: `https://api.hetzner.cloud/v1/servers`,
headers: {
Authorization: "Bearer " + HETZNER_TOKEN,
"Content-Type": "application/json",
"User-Agent": "test",
},
data: {
name: "vBrowserSnapshot",
server_type: "cpx11",
start_after_create: true,
image: "docker-ce", // 15512617 for Ubuntu 20.04
ssh_keys: sshKeys,
user_data: fs
.readFileSync(import.meta.dirname + "/../../dev/vbrowser.sh")
.toString()
.replace("{VBROWSER_ADMIN_KEY}", config.VBROWSER_ADMIN_KEY),
location: this.getRandomDatacenter(),
},
});
const id = response.data.server.id;
await new Promise((resolve) => setTimeout(resolve, 4 * 60 * 1000));
// Validate snapshot server was created successfully
// const response3 = await axios(
// 'http://' + response.data.server.public_net?.ipv4?.ip + ':5000'
// );
const response2 = await axios({
method: "POST",
url: `https://api.hetzner.cloud/v1/servers/${id}/actions/create_image`,
headers: {
Authorization: "Bearer " + HETZNER_TOKEN,
"Content-Type": "application/json",
},
});
const imageId = response2.data.image.id;
await this.terminateVM(id);
return imageId;
};
mapServerObject = (server: any): VM => {
const public_ip = server.public_net?.ipv4?.ip;
// const private_ip = server.private_net?.[0]?.ip;
const ip = public_ip;
// We can use either the public or private IP for communicating between gateway and VM
// Only signaling traffic goes through here since the video is transmitted over WebRTC
// The private IP requires the server and gateway to be on the same network and there is a limit to the number of servers allowed
return {
id: server.id?.toString(),
// The gateway handles SSL termination and proxies to the private IP
host: ip ? `${this.gateway}/?ip=${ip}` : "",
provider: this.id,
large: this.isLarge,
region: this.region,
};
};
}
+159
View File
@@ -0,0 +1,159 @@
import config from "../config.ts";
import axios from "axios";
import { VMManager, type VM } from "./base.ts";
const SCW_SECRET_KEY = config.SCW_SECRET_KEY;
const SCW_ORGANIZATION_ID = config.SCW_ORGANIZATION_ID;
const region = "nl-ams-1"; //fr-par-1
const gatewayHost = config.SCW_GATEWAY;
export class Scaleway extends VMManager {
size = "DEV1-S"; // DEV1-S, DEV1-M, DEV1-L, GP1-XS
largeSize = "DEV1-M";
minRetries = 5;
reuseVMs = true;
id = "Scaleway";
imageId = config.SCW_IMAGE;
startVM = async (name: string) => {
const response = await axios({
method: "POST",
url: `https://api.scaleway.com/instance/v1/zones/${region}/servers`,
headers: {
"X-Auth-Token": SCW_SECRET_KEY,
"Content-Type": "application/json",
},
data: {
name: name,
dynamic_ip_required: true,
commercial_type: this.isLarge ? this.largeSize : this.size,
image: this.imageId,
volumes: {},
organization: SCW_ORGANIZATION_ID,
tags: [this.getTag()],
},
});
// console.log(response.data);
const id = response.data.server.id;
const response2 = await axios({
method: "PATCH",
url: `https://api.scaleway.com/instance/v1/zones/${region}/servers/${id}/user_data/cloud-init`,
headers: {
"X-Auth-Token": SCW_SECRET_KEY,
"Content-Type": "text/plain",
},
// set userdata for boot action
//data: cloudInit(),
});
// console.log(response2.data);
// boot the instance
const response3 = await axios({
method: "POST",
url: `https://api.scaleway.com/instance/v1/zones/${region}/servers/${id}/action`,
headers: {
"X-Auth-Token": SCW_SECRET_KEY,
"Content-Type": "application/json",
},
data: {
action: "poweron",
},
});
// console.log(response3.data);
return id;
};
terminateVM = async (id: string) => {
const response = await axios({
method: "POST",
url: `https://api.scaleway.com/instance/v1/zones/${region}/servers/${id}/action`,
headers: {
"X-Auth-Token": SCW_SECRET_KEY,
"Content-Type": "application/json",
},
data: {
action: "terminate",
},
});
};
rebootVM = async (id: string) => {
// Reboot the VM (also destroys the Docker container since it has --rm flag)
const response2 = await axios({
method: "POST",
url: `https://api.scaleway.com/instance/v1/zones/${region}/servers/${id}/action`,
headers: {
"X-Auth-Token": SCW_SECRET_KEY,
"Content-Type": "application/json",
},
data: {
action: "reboot",
},
});
return;
};
reimageVM = async (id: string) => {
// Scaleway doesn't have a reimage/rebuild command. Delete the VM
await this.terminateVMWrapper(id);
};
getVM = async (id: string) => {
const response = await axios({
method: "GET",
url: `https://api.scaleway.com/instance/v1/zones/${region}/servers/${id}`,
headers: {
"X-Auth-Token": SCW_SECRET_KEY,
"Content-Type": "application/json",
},
});
let server = this.mapServerObject(response.data.server);
return server;
};
listVMs = async (filter: string) => {
const limit = this.getLimitSize();
const pageCount = Math.ceil((limit || 1) / 100);
const pages = Array.from(Array(pageCount).keys()).map((i) => i + 1);
const responses: any[] = await Promise.all(
pages.map((page) =>
axios({
method: "GET",
url: `https://api.scaleway.com/instance/v1/zones/${region}/servers`,
headers: {
"X-Auth-Token": SCW_SECRET_KEY,
"Content-Type": "application/json",
},
params: {
page,
per_page: 100,
tags: filter,
},
}),
),
);
const responsesMapped = responses.map((response) =>
response.data.servers.map(this.mapServerObject),
);
return responsesMapped.flat();
};
powerOn = async (_id: string) => {};
attachToNetwork = async (_id: string) => {};
updateSnapshot = async () => {
return "";
};
mapServerObject = (server: any): VM => {
// const ip = server.private_ip;
const ip = server.public_ip?.address;
return {
id: server.id,
// The gateway handles SSL termination and proxies to the private IP
host: ip ? `${gatewayHost}/?ip=${ip}` : "",
provider: this.id,
large: this.isLarge,
region: this.region,
};
};
}
+86
View File
@@ -0,0 +1,86 @@
import { type AssignedVM, VMManager } from "./base.ts";
import config from "../config.ts";
import { Scaleway } from "./scaleway.ts";
import { Hetzner } from "./hetzner.ts";
import { DigitalOcean } from "./digitalocean.ts";
import { Docker } from "./docker.ts";
// Chromium on ARM: ghcr.io/howardchung/vbrowser/arm-chromium
export const imageName = "howardc93/vbrowser";
export type PoolRegion = "US" | "USW" | "EU";
export type PoolConfig = {
provider: string;
isLarge: boolean;
region: PoolRegion;
limitSize: number | undefined;
minSize: number | undefined;
hostname: string | undefined;
};
function createVMManager(poolConfig: PoolConfig): VMManager {
let vmManager: VMManager | null = null;
if (
config.SCW_SECRET_KEY &&
config.SCW_ORGANIZATION_ID &&
config.SCW_IMAGE &&
config.SCW_GATEWAY &&
poolConfig.provider === "Scaleway"
) {
vmManager = new Scaleway(poolConfig);
} else if (
config.HETZNER_TOKEN &&
config.HETZNER_IMAGE &&
config.HETZNER_GATEWAY &&
poolConfig.provider === "Hetzner"
) {
vmManager = new Hetzner(poolConfig);
} else if (
config.DO_TOKEN &&
config.DO_IMAGE &&
config.DO_GATEWAY &&
poolConfig.provider === "DO"
) {
vmManager = new DigitalOcean(poolConfig);
} else if (poolConfig.provider === "Docker") {
vmManager = new Docker(poolConfig);
}
if (!vmManager) {
throw new Error("failed to create vmManager");
}
return vmManager;
}
export function getVMManagerConfig(): PoolConfig[] {
return config.VM_MANAGER_CONFIG.split(",")
.filter(Boolean)
.map((c) => {
const split = c.split(":");
return {
provider: split[0],
isLarge: split[1] === "large",
region: split[2] as PoolRegion,
minSize: Number(split[3]),
limitSize: Number(split[4]),
hostname: split[5],
};
});
}
export function getBgVMManagers(): { [key: string]: VMManager } {
const result: { [key: string]: VMManager } = {};
const conf = getVMManagerConfig();
conf.forEach((c) => {
const mgr = createVMManager(c);
if (mgr) {
result[mgr.getPoolName()] = mgr;
}
});
return result;
}
export function getSessionLimitSeconds(isLarge: boolean) {
return isLarge
? config.VBROWSER_SESSION_SECONDS_LARGE
: config.VBROWSER_SESSION_SECONDS;
}
+114
View File
@@ -0,0 +1,114 @@
import config from "./config.ts";
import { getBgVMManagers } from "./vm/utils.ts";
import express from "express";
import bodyParser from "body-parser";
const app = express();
const vmManagers = getBgVMManagers();
app.use(bodyParser.json());
Object.values(vmManagers).forEach((manager) => {
manager?.runBackgroundJobs();
});
app.post("/assignVM", async (req, res) => {
try {
// Find a pool that matches the size and region requirements
const pools = Object.values(vmManagers).filter((mgr) => {
return (
mgr.getIsLarge() === Boolean(req.body.isLarge) &&
(mgr.getRegion() === req.body.region || !req.body.region)
);
});
let vm = null;
// Sequentially try each to give earlier pools preference
// We might want to add the ability to load balance as well by randomly selecting between pools with same priority
for (let pool of pools) {
console.log(
"try assignVM from pool:",
pool.getPoolName(),
req.body.roomId,
req.body.uid,
);
vm = await pool.assignVM(req.body.roomId, req.body.uid);
if (vm) {
res.json(vm);
return;
}
}
res.json(null);
} catch (e) {
console.warn(e);
res.status(500).end();
}
});
app.post("/releaseVM", async (req, res) => {
try {
const pool =
vmManagers[
req.body.provider + (req.body.isLarge ? "Large" : "") + req.body.region
];
if (req.body.id) {
await pool?.resetVM(req.body.id, req.body.roomId);
}
res.end();
} catch (e) {
console.warn(e);
res.status(500).end();
}
});
app.get("/stats", async (req, res) => {
const vmManagerStats: AnyDict = {};
for (let [key, vmManager] of Object.entries(vmManagers)) {
const availableVBrowsers = await vmManager?.getAvailableVBrowsers();
const stagingVBrowsers = await vmManager?.getStagingVBrowsers();
const size = await vmManager?.getCurrentSize();
if (key && vmManager) {
vmManagerStats[key] = {
availableVBrowsers,
stagingVBrowsers,
bufferSize: vmManager?.getTargetBuffer(),
// terminationVBrowsers,
size,
};
}
}
res.json(vmManagerStats);
});
app.get("/isFreePoolFull", async (req, res) => {
const freePools = Object.values(vmManagers).filter((mgr) => {
return mgr?.getIsLarge() === false && mgr?.getLimitSize() > 0;
});
const fullResult = await Promise.all<Boolean>(
freePools.map(async (freePool) => {
let isFull = false;
if (freePool) {
const availableCount = await freePool.getAvailableCount();
const limitSize = freePool?.getLimitSize() ?? 0;
const currentSize = await freePool.getCurrentSize();
isFull = Boolean(
limitSize > 0 &&
(Number(availableCount) === 0 ||
Number(currentSize) - Number(availableCount) > limitSize * 0.95),
);
}
return isFull;
}),
);
const isFull = freePools.length && fullResult.every(Boolean);
res.json({ isFull });
});
app.post("/updateSnapshot", async (req, res) => {
const pool = vmManagers[req.body.provider + req.body.region];
const result = await pool?.updateSnapshot();
res.send(result?.toString() + "\n");
});
app.listen(config.VMWORKER_PORT, () => {
console.log("vmWorker listening on %s", config.VMWORKER_PORT);
});
+3
View File
@@ -0,0 +1,3 @@
alter table room add column "roomTitle" text;
alter table room add column "roomDescription" text;
alter table room add column "roomTitleColor" text;
+65
View File
@@ -0,0 +1,65 @@
CREATE EXTENSION pg_trgm;
CREATE TABLE room(
"roomId" text,
"creationTime" timestamp with time zone,
password text,
owner text,
vanity text,
"isChatDisabled" boolean,
"isSubRoom" boolean,
data jsonb,
"lastUpdateTime" timestamp with time zone,
"roomTitle" text,
"roomDescription" text,
"roomTitleColor" text,
"mediaPath" text,
PRIMARY KEY ("roomId")
);
CREATE UNIQUE INDEX on room(LOWER(vanity)) WHERE vanity IS NOT NULL;
CREATE INDEX on room(owner) WHERE owner IS NOT NULL;
CREATE INDEX on room("creationTime");
CREATE INDEX on room USING GIN("roomId" gin_trgm_ops);
CREATE TABLE subscriber(
"customerId" text,
email text,
status text,
uid text,
PRIMARY KEY(uid)
);
CREATE TABLE link_account(
uid text,
kind text,
accountid text,
accountname text,
discriminator text,
PRIMARY KEY(uid, kind)
);
CREATE TABLE active_user(
uid text,
"lastActiveTime" timestamp with time zone,
PRIMARY KEY(uid)
);
CREATE TABLE vbrowser(
id bigserial PRIMARY KEY, -- numeric sequence ID
pool text NOT NULL, -- name of the pool this VM is in, e.g. HetznerLargeUS
vmid text NOT NULL, -- identifier for the VM, only unique across a provider
state text NOT NULL, -- available, staging, used
"creationTime" timestamp with time zone NOT NULL, -- time the VM was created
"heartbeatTime" timestamp with time zone, -- last time a room reported this VM was in use
"assignTime" timestamp with time zone, -- last time the room was assigned
"roomId" text, -- room VM assigned to
uid text, -- user requesting the VM
data json, -- metadata for the VM
retries int DEFAULT 0, -- how many times we checked if VM is up
pass text, -- password to access vbrowser
image text -- ID of the last image applied to this VM
);
CREATE UNIQUE INDEX on vbrowser(pool, vmid);
CREATE INDEX on vbrowser(pool, state);
CREATE INDEX on vbrowser("roomId");
CREATE INDEX on vbrowser(uid);
+12
View File
@@ -0,0 +1,12 @@
import firebase from "firebase/compat/app";
import React from "react";
export const DEFAULT_STATE = {
user: undefined as firebase.User | undefined,
isSubscriber: undefined as boolean | undefined,
streamPath: undefined as string | undefined,
convertPath: undefined as string | undefined,
beta: false,
};
export const MetadataContext = React.createContext(DEFAULT_STATE);
@@ -0,0 +1,38 @@
.announce {
display: flex;
flex-direction: row;
align-items: center;
background-color: #008eff;
width: 500px;
max-width: 100%;
position: fixed;
top: 0px;
left: 50%;
transform: translate(-50%, 0);
border-radius: 4px;
z-index: 900;
height: 48px;
color: white;
overflow: hidden;
}
@media only screen and (max-width: 600px) {
.announce {
bottom: 0px;
top: initial;
}
}
.announce main {
padding: 6px 10px;
flex-grow: 1;
}
.announce main a {
color: blue;
text-decoration: underline;
}
.announce aside {
padding: 6px 10px;
}
+61
View File
@@ -0,0 +1,61 @@
import React, { useCallback, useEffect, useState } from "react";
import { Button } from "@mantine/core";
import styles from "./Announce.module.css";
import config from "../../config";
const GITHUB_REPO = "howardchung/watchparty-announcements";
type Issue = {
title: string;
body: string;
number: number;
updated_at: string;
};
const Announce = () => {
const [announcement, setAnnouncement] = useState<Issue | null>(null);
useEffect(() => {
const update = async () => {
const response = await fetch(
"https://api.github.com/search/issues?" +
new URLSearchParams({
q: `repo:${GITHUB_REPO} label:${
config.NODE_ENV === "development" ? "test" : "release"
}`,
order: "desc",
page: "1",
per_page: "1",
}),
);
const data: { items: Issue[] } = await response.json();
// console.log(data);
const top = data?.items?.[0];
if (
top?.number > Number(localStorage.getItem("announcement-dismiss")) &&
new Date(top.updated_at) >
new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
) {
setAnnouncement(top);
}
};
update();
}, []);
const onDismiss = useCallback((value: number) => {
localStorage.setItem("announcement-dismiss", value.toString());
setAnnouncement(null);
}, []);
return announcement != null ? (
<div className={styles.announce}>
{/* <h4>{announcement.title}</h4> */}
<pre style={{ whiteSpace: "pre-wrap" }}>{announcement.body}</pre>
<aside>
<Button color="blue" onClick={() => onDismiss(announcement.number)}>
Dismiss
</Button>
</aside>
</div>
) : null;
};
export default Announce;
+79
View File
@@ -0,0 +1,79 @@
:global(#leftVideo)::-webkit-media-controls {
display: none !important;
}
.videoContent {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.playerContainer {
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
/* Making this larger causes issues on mobile */
min-height: 48vh;
}
.flexCenter {
display: flex;
align-items: center;
justify-content: center;
}
.mobileStack {
display: flex;
gap: 4px;
@media only screen and (max-width: 600px) {
flex-direction: column;
width: calc(100% - 8px);
}
}
.mobileWrap {
@media only screen and (max-width: 600px) {
flex-wrap: wrap;
}
}
.leftColumn {
flex-grow: 1;
}
.rightColumn {
flex-shrink: 0;
}
.expandButton {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
top: 50%;
right: -30px;
}
.fullHeightColumn {
height: calc(100vh - 72px);
transition: width ease-out 0.5s;
&.leftColumn {
@media only screen and (max-width: 600px) {
/* video height doesn't matter on mobile since things are stacked */
height: initial;
}
}
}
.fullHeightColumnFullscreen {
height: calc(100vh);
transition: width ease-out 0.5s;
}
.shareButton {
min-width: 150px;
}
File diff suppressed because it is too large Load Diff
+194
View File
@@ -0,0 +1,194 @@
import { default as toWebVTT } from "srt-webvtt";
import { Player } from "./Player";
export class HTML implements Player {
elId: string;
constructor(elId: string) {
this.elId = elId;
}
getVideoEl = (): HTMLMediaElement => {
return document.getElementById(this.elId) as HTMLMediaElement;
};
getCurrentTime = () => {
return this.getVideoEl()?.currentTime ?? 0;
};
getDuration = () => {
return this.getVideoEl()?.duration ?? 0;
};
isMuted = () => {
return this.getVideoEl()?.muted ?? false;
};
isSubtitled = () => {
return this.getSubtitleMode() === "showing";
};
getPlaybackRate = (): number => {
return this.getVideoEl()?.playbackRate ?? 1;
};
setPlaybackRate = (rate: number) => {
if (this.getVideoEl()) {
this.getVideoEl().playbackRate = rate;
}
};
setSrcAndTime = async (src: string, time: number) => {
const leftVideo = this.getVideoEl();
if (leftVideo) {
leftVideo.currentTime = time;
leftVideo.src = src;
}
};
playVideo = async () => {
if (this.getVideoEl()) {
await this.getVideoEl().play();
}
};
pauseVideo = () => {
if (this.getVideoEl()) {
this.getVideoEl().pause();
}
};
seekVideo = (time: number) => {
if (this.getVideoEl()) {
this.getVideoEl().currentTime = time;
}
};
shouldPlay = () => {
const leftVideo = this.getVideoEl();
return Boolean(leftVideo?.paused || leftVideo?.ended);
};
setMute = (muted: boolean) => {
const leftVideo = this.getVideoEl();
if (leftVideo) {
leftVideo.muted = muted;
}
const audio = document.getElementById("iPhoneAudio") as HTMLAudioElement;
if (audio) {
audio.muted = muted;
}
};
setVolume = (volume: number) => {
if (this.getVideoEl()) {
this.getVideoEl().volume = volume;
}
};
getVolume = (): number => {
return this.getVideoEl()?.volume ?? 1;
};
setSubtitleMode = (mode?: TextTrackMode) => {
if (this.getVideoEl()) {
for (
let i = 0;
i < Math.min(this.getVideoEl().textTracks.length, 1);
i++
) {
this.getVideoEl().textTracks[i].mode =
mode ??
(this.getVideoEl().textTracks[i].mode === "hidden"
? "showing"
: "hidden");
}
}
};
getSubtitleMode = () => {
return this.getVideoEl()?.textTracks[0]?.mode ?? "hidden";
};
isReady = () => {
return Boolean(this.getVideoEl());
};
clearState = () => {
const leftVideo = this.getVideoEl();
// Clear src and srcObject
leftVideo.src = "";
leftVideo.srcObject = null;
// Clear subtitles
this.setSubtitleMode("hidden");
leftVideo.innerHTML = "";
};
loadSubtitles = async (src: string) => {
const leftVideo = this.getVideoEl();
if (!leftVideo) {
return;
}
// Clear subtitles and put new ones in
leftVideo.innerHTML = "";
if (Boolean(src)) {
let subtitleSrc = src;
if (subtitleSrc) {
const response = await fetch(subtitleSrc);
const buffer = await response.arrayBuffer();
const url = await toWebVTT(new Blob([buffer]));
const track = document.createElement("track");
track.kind = "captions";
track.label = "English";
track.srclang = "en";
track.src = url;
leftVideo.appendChild(track);
leftVideo.textTracks[0].mode = "showing";
}
}
};
syncSubtitles = (sharerTime: number) => {
// When sharing, our timestamp doesn't match the subtitles so adjust them
// For each cue, subtract the videoTS of the sharer, then add our own
const leftVideo = this.getVideoEl();
const track = leftVideo?.textTracks[0];
let offset = leftVideo.currentTime - sharerTime;
if (track && track.cues && offset) {
for (let i = 0; i < track.cues.length; i++) {
let cue = track?.cues?.[i] as TextTrackCue & {
origStart: number;
origEnd: number;
};
if (!cue) {
continue;
}
// console.log(cue.text, offset, cue.origStart, cue.origEnd);
if (!cue.origStart) {
cue.origStart = cue.startTime;
cue.origEnd = cue.endTime;
}
cue.startTime = cue.origStart + offset;
cue.endTime = cue.origEnd + offset;
}
}
};
getTimeRanges = (): { start: number; end: number }[] => {
const leftVideo = this.getVideoEl();
const buffers = [];
const rangeCount = leftVideo?.buffered.length;
for (let i = 0; i < rangeCount; i++) {
const start = leftVideo?.buffered.start(i);
const end = leftVideo?.buffered.end(i);
buffers.push({ start, end });
}
return buffers;
};
setLoop = (loop: boolean): void => {
const leftVideo = this.getVideoEl();
leftVideo.loop = loop;
};
}
+25
View File
@@ -0,0 +1,25 @@
export abstract class Player {
public abstract getCurrentTime: () => number;
public abstract getDuration: () => number;
public abstract isMuted: () => boolean;
public abstract getPlaybackRate: () => number;
public abstract setPlaybackRate: (rate: number) => void;
public abstract setSrcAndTime: (src: string, time: number) => Promise<void>;
public abstract playVideo: () => Promise<void>;
public abstract pauseVideo: () => void;
public abstract seekVideo: (time: number) => void;
public abstract shouldPlay: () => boolean;
public abstract setMute: (muted: boolean) => void;
public abstract getVolume: () => number;
public abstract setVolume: (volume: number) => void;
public abstract isSubtitled: () => boolean;
public abstract getSubtitleMode: () => TextTrackMode;
public abstract setSubtitleMode: (mode?: TextTrackMode) => void;
public abstract isReady: () => boolean;
public abstract clearState: () => void;
public abstract loadSubtitles: (src: string) => void;
public abstract syncSubtitles: (sharerTime: number) => void;
public abstract getTimeRanges: () => { start: number; end: number }[];
public abstract setLoop: (loop: boolean) => void;
public abstract getVideoEl: () => HTMLMediaElement;
}
+157
View File
@@ -0,0 +1,157 @@
import { MediaPlayerClass } from "dashjs";
import { Player } from "./Player";
export class YouTube implements Player {
watchPartyYTPlayer: YT.Player | null;
constructor(watchPartyYTPlayer: YT.Player | null) {
this.watchPartyYTPlayer = watchPartyYTPlayer;
}
clearDashState = () => {};
setDashState = (player: MediaPlayerClass) => {};
getCurrentTime = () => {
return this.watchPartyYTPlayer?.getCurrentTime() ?? 0;
};
getDuration = () => {
return this.watchPartyYTPlayer?.getDuration() ?? 0;
};
isMuted = () => {
return this.watchPartyYTPlayer?.isMuted() ?? false;
};
isSubtitled = (): boolean => {
// This actually isn't accurate after subtitles have been toggled off because track doesn't update
// try {
// const current = this.watchPartyYTPlayer?.getOption('captions', 'track');
// return Boolean(current && current.languageCode);
// } catch (e) {
// console.warn(e);
// return false;
// }
return false;
};
getPlaybackRate = (): number => {
return this.watchPartyYTPlayer?.getPlaybackRate() ?? 1;
};
setPlaybackRate = (rate: number) => {
this.watchPartyYTPlayer?.setPlaybackRate(rate);
};
setSrcAndTime = async (src: string, time: number) => {
let url = new window.URL(src);
// Standard link https://www.youtube.com/watch?v=ID
let videoId = new URLSearchParams(url.search).get("v");
// Link shortener https://youtu.be/ID
let altVideoId = src.split("/").slice(-1)[0].split("?")[0];
this.watchPartyYTPlayer?.cueVideoById(videoId || altVideoId, time);
// this.watchPartyYTPlayer?.cuePlaylist({listType: 'playlist', list: 'OLAK5uy_mtoaOGQksRdPbwlNtQ9IiK67wir5QqyIc'});
};
playVideo = async () => {
setTimeout(() => {
console.log("play yt");
this.watchPartyYTPlayer?.playVideo();
}, 200);
};
pauseVideo = () => {
this.watchPartyYTPlayer?.pauseVideo();
};
seekVideo = (time: number) => {
this.watchPartyYTPlayer?.seekTo(time, true);
};
shouldPlay = () => {
return (
this.watchPartyYTPlayer?.getPlayerState() ===
window.YT?.PlayerState.PAUSED ||
this.getCurrentTime() === this.getDuration()
);
};
setMute = (muted: boolean) => {
if (muted) {
this.watchPartyYTPlayer?.mute();
} else {
this.watchPartyYTPlayer?.unMute();
}
};
setVolume = (volume: number) => {
this.watchPartyYTPlayer?.setVolume(volume * 100);
};
getVolume = (): number => {
const volume = this.watchPartyYTPlayer?.getVolume();
return (volume ?? 0) / 100;
};
setSubtitleMode = (mode?: TextTrackMode, lang?: string) => {
// Show the available options
// console.log(this.watchPartyYTPlayer?.getOptions('captions'));
if (mode === "showing") {
console.log(lang);
//@ts-expect-error
this.watchPartyYTPlayer?.setOption("captions", "reload", true);
//@ts-expect-error
this.watchPartyYTPlayer?.setOption("captions", "track", {
languageCode: lang ?? "en",
});
}
if (mode === "hidden") {
// BUG this doesn't actually set the value of track
// so we can't determine if subtitles are on or off
// need to provide separate menu options
//@ts-expect-error
this.watchPartyYTPlayer?.setOption("captions", "track", {});
}
};
getSubtitleMode = () => {
return "hidden" as TextTrackMode;
};
isReady = () => {
return Boolean(this.watchPartyYTPlayer);
};
stopVideo = () => {
this.watchPartyYTPlayer?.stopVideo();
};
clearState = () => {
return;
};
loadSubtitles = async (src: string) => {
return;
};
syncSubtitles = (sharerTime: number) => {
return;
};
getTimeRanges = (): { start: number; end: number }[] => {
return [
{
start: 0,
end:
(this.watchPartyYTPlayer?.getVideoLoadedFraction() ?? 0) *
this.getDuration(),
},
];
};
setLoop = (loop: boolean): void => {
this.watchPartyYTPlayer?.setLoop(loop);
};
getVideoEl = (): HTMLMediaElement => {
return document.getElementById("leftYt") as HTMLMediaElement;
};
}
+251
View File
@@ -0,0 +1,251 @@
.subscriber {
color: rgb(33, 186, 69);
}
/* .subscriber {
position: relative;
} */
/* .subscriber img {
border: 3px solid transparent;
} */
/* .subscriber:before {
content: '';
background: linear-gradient(
60deg,
#f79533,
#f37055,
#ef4e7b,
#a166ab,
#5073b8,
#1098ad,
#07b39b,
#6fba82
);
position: absolute;
width: 100%;
height: 100%;
z-index: -1;
border-radius: 2px;
animation: animatedgradient 2.5s ease infinite;
background-size: 200% 200%;
} */
/* @keyframes animatedgradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
} */
.chatContainer {
flex-grow: 1;
overflow: auto;
}
.hoverEffect:hover {
filter: brightness(0.8);
}
.light {
color: gainsboro;
font-size: 14px;
}
.light.emoji {
font-size: 24px;
}
.dark {
color: gray;
}
.small {
font-size: 12px;
}
.system {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
}
.comment .commentMenu {
display: none;
justify-content: center;
position: absolute;
right: 5px;
top: -13px;
padding: 4px !important;
border-radius: 3px;
cursor: pointer;
}
.comment .commentMenu:hover,
.comment.selected .commentMenu {
background: rgb(46, 137, 212);
transition: background 0.2s ease-in-out;
}
.comment:hover,
.comment.selected {
border-radius: 5px;
background: radial-gradient(
at top right,
#7eaee02a,
transparent 70%
) !important;
}
.comment:hover .commentMenu,
.comment.selected .commentMenu {
display: flex;
}
.replyMessage {
background: radial-gradient(
at top right,
rgba(255, 176, 96, 0.22),
transparent 70%
);
}
.replyInfo {
color: rgba(180, 200, 220, 0.95);
font-size: 12px;
margin-bottom: 2px;
}
.replyTooltip {
background: rgba(20, 28, 36, 0.45);
border: 1px solid rgba(255, 255, 255, 0.16);
color: rgba(240, 248, 255, 0.94);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
max-width: 360px;
padding: 6px 8px;
font-size: 12px;
line-height: 1.2;
white-space: pre-wrap;
overflow-wrap: anywhere;
pointer-events: none;
}
.reactionMenuContainer {
display: flex;
flex-wrap: nowrap;
border-radius: 5px;
position: fixed;
font-size: 24px;
overflow: hidden;
background: rgba(255, 255, 255, 0.91);
}
.reactionMenuContainer div {
line-height: 30px;
padding: 5px;
}
.reactionMenuContainer div:hover {
background-color: rgb(46, 137, 212);
transition: background-color 0.2s ease-in-out;
}
.reactionContainer {
display: inline-flex;
align-items: center;
padding: 3px;
border-radius: 5px;
background: rgba(255, 255, 255, 0.16);
margin-right: 3px;
margin-top: 1px;
min-width: 39px;
cursor: pointer;
}
.reactionContainer.highlighted {
background: rgb(66, 111, 129);
}
.reactionContainer:hover {
background-color: rgb(46, 137, 212);
transition: background-color 0.2s ease-in-out;
}
.reaction-enter {
transform: scale(0);
}
.reaction-enter-active {
transform: scale(1);
transition: all 200ms ease-in !important;
}
.reaction-exit {
transform: scale(1);
}
.reaction-exit-active {
transform: scale(0);
transition: all 200ms ease-in !important;
}
.reactionCounter-enter {
transform: scale(0);
color: rgb(255, 136, 0) !important;
}
.reactionCounter-enter-active {
transform: scale(1);
transition: all 200ms ease-in !important;
}
.reactionCounter-exit {
transform: scale(1);
color: rgb(255, 136, 0) !important;
}
.reactionCounter-exit-active {
transform: scale(0);
transition: all 200ms ease-in !important;
}
.reactionMenu-enter {
transform: scale(0);
}
.reactionMenu-enter-active {
transform: scale(1);
transition: all 100ms ease-out !important;
}
.reactionMenu-exit {
transform: scale(1);
}
.reactionMenu-exit-active {
transform: scale(0);
transition: all 100ms ease-out !important;
}
@keyframes replyComposerIn {
0% {
opacity: 0;
transform: translateY(10px);
}
70% {
opacity: 1;
transform: translateY(-2px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.replyComposer {
animation: replyComposerIn 220ms cubic-bezier(0.22, 0.8, 0.25, 1);
}
@media (prefers-reduced-motion: reduce) {
.replyComposer {
animation: none;
}
}
+740
View File
@@ -0,0 +1,740 @@
import React, { RefObject, useContext } from "react";
import {
ActionIcon,
Avatar,
Button,
HoverCard,
TextInput,
} from "@mantine/core";
// import data from '@emoji-mart/data';
import Picker from "@emoji-mart/react";
import { init } from "emoji-mart";
// import onClickOutside from 'react-onclickoutside';
//@ts-expect-error
import Linkify from "react-linkify";
import { SecureLink } from "react-secure-link";
import styles from "./Chat.module.css";
import {
formatTimestamp,
getColorForStringHex,
getDefaultPicture,
getOrCreateClientId,
isEmojiString,
} from "../../utils/utils";
import { UserMenu } from "../UserMenu/UserMenu";
import { Socket } from "socket.io-client";
import {
CSSTransition,
SwitchTransition,
TransitionGroup,
} from "react-transition-group";
import { MetadataContext } from "../../MetadataContext";
const clientId = getOrCreateClientId();
const truncateReply = (input?: string, max = 30) =>
input && input.length > max ? `${input.slice(0, max)}...` : input || "";
interface ChatProps {
chat: ChatMessage[];
nameMap: StringDict;
pictureMap: StringDict;
socket: Socket;
scrollTimestamp: number;
className?: string;
getMediaDisplayName: (input?: string) => string;
hide?: boolean;
isChatDisabled?: boolean;
owner: string | undefined;
ref: RefObject<Chat>;
}
export class Chat extends React.Component<ChatProps> {
static contextType = MetadataContext;
declare context: React.ContextType<typeof MetadataContext>;
public state = {
chatMsg: "",
replyTo: undefined as
| { id: string; timestamp: string; msg?: string }
| undefined,
isNearBottom: true,
isPickerOpen: false,
reactionMenu: {
isOpen: false,
selectedMsgId: "",
selectedMsgTimestamp: "",
yPosition: 0,
xPosition: 0,
},
};
messagesRef = React.createRef<HTMLDivElement>();
chatInputRef = React.createRef<HTMLInputElement>();
async componentDidMount() {
this.scrollToBottom();
this.messagesRef.current?.addEventListener("scroll", this.onScroll);
init({});
}
componentDidUpdate(prevProps: ChatProps) {
if (this.props.scrollTimestamp !== prevProps.scrollTimestamp) {
if (prevProps.scrollTimestamp === 0 || this.state.isNearBottom) {
this.scrollToBottom();
}
}
if (this.props.hide !== prevProps.hide) {
this.scrollToBottom();
}
}
setReactionMenu = (
isOpen: boolean,
selectedMsgId?: string,
selectedMsgTimestamp?: string,
yPosition?: number,
xPosition?: number,
) => {
this.setState({
reactionMenu: {
isOpen,
selectedMsgId,
selectedMsgTimestamp,
yPosition,
xPosition,
},
});
};
handleReactionClick = (value: string, id?: string, timestamp?: string) => {
const msg = this.props.chat.find(
(m) => m.id === id && m.timestamp === timestamp,
);
const data = {
value,
msgId: id || this.state.reactionMenu.selectedMsgId,
msgTimestamp: timestamp || this.state.reactionMenu.selectedMsgTimestamp,
};
if (msg?.reactions?.[value].includes(clientId)) {
this.props.socket.emit("CMD:removeReaction", data);
} else {
this.props.socket.emit("CMD:addReaction", data);
}
};
updateChatMsg = (e: any) => {
// console.log(e.target.selectionStart);
this.setState({ chatMsg: e.target.value });
};
sendChatMsg = () => {
if (!this.state.chatMsg) {
return;
}
if (this.chatTooLong()) {
return;
}
if (this.state.replyTo?.id && this.state.replyTo.timestamp) {
const chatPayload: ChatPayload = {
msg: this.state.chatMsg,
replyToId: this.state.replyTo.id,
replyToTimestamp: this.state.replyTo.timestamp,
};
this.props.socket.emit("CMD:chatV2", chatPayload);
this.setState({ chatMsg: "", replyTo: undefined });
return;
}
this.setState({ chatMsg: "", replyTo: undefined });
this.props.socket.emit("CMD:chat", this.state.chatMsg);
};
setReplyTo = (id: string, timestamp: string, msg?: string) => {
this.setState({ replyTo: { id, timestamp, msg } });
this.chatInputRef.current?.focus();
};
clearReplyTo = () => {
this.setState({ replyTo: undefined });
};
chatTooLong = () => {
return Boolean(this.state.chatMsg?.length > 10000);
};
onScroll = () => {
this.setState({ isNearBottom: this.isChatNearBottom() });
};
isChatNearBottom = () => {
return (
this.messagesRef.current &&
this.messagesRef.current.scrollHeight -
this.messagesRef.current.scrollTop -
this.messagesRef.current.offsetHeight <
50
);
};
scrollToBottom = () => {
if (this.messagesRef.current) {
this.messagesRef.current.scrollTop =
this.messagesRef.current.scrollHeight;
}
};
formatMessage = (cmd: string, msg?: string): React.ReactNode | string => {
if (cmd === "host") {
return (
<React.Fragment>
{`changed the video to `}
<span style={{ textTransform: "initial" }}>
{this.props.getMediaDisplayName(msg)}
</span>
</React.Fragment>
);
} else if (cmd === "playlistAdd") {
return (
<React.Fragment>
{`added to the playlist: `}
<span style={{ textTransform: "initial" }}>
{this.props.getMediaDisplayName(msg)}
</span>
</React.Fragment>
);
} else if (cmd === "seek") {
return `jumped to ${formatTimestamp(msg)}`;
} else if (cmd === "play") {
return `started the video at ${formatTimestamp(msg)}`;
} else if (cmd === "pause") {
return `paused the video at ${formatTimestamp(msg)}`;
} else if (cmd === "playbackRate") {
return `set the playback rate to ${msg === "0" ? "auto" : `${msg}x`}`;
} else if (cmd === "lock") {
return `locked the room`;
} else if (cmd === "unlock") {
return "unlocked the room";
} else if (cmd === "vBrowserTimeout") {
return (
<React.Fragment>
The VBrowser shut down automatically.
<br />
Subscribe for longer sessions.
</React.Fragment>
);
} else if (cmd === "vBrowserAlmostTimeout") {
return (
<React.Fragment>
The VBrowser will shut down soon.
<br />
Subscribe for longer sessions.
</React.Fragment>
);
}
return cmd;
};
addEmoji = (emoji: any) => {
this.setState({ chatMsg: this.state.chatMsg + emoji.native });
};
render() {
return (
<div
className={this.props.className}
style={{
display: this.props.hide ? "none" : "flex",
flexDirection: "column",
flexGrow: 1,
minHeight: 0,
marginTop: 0,
marginBottom: 0,
padding: "8px",
backgroundColor: "rgba(30,30,30,1)",
}}
>
<div
className={styles.chatContainer}
ref={this.messagesRef}
style={{ position: "relative" }}
>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{this.props.chat.map((msg) => (
<ChatMessage
key={msg.timestamp + msg.id}
className={
msg.id === this.state.reactionMenu.selectedMsgId &&
msg.timestamp === this.state.reactionMenu.selectedMsgTimestamp
? styles.selected
: ""
}
message={msg}
pictureMap={this.props.pictureMap}
nameMap={this.props.nameMap}
formatMessage={this.formatMessage}
owner={this.props.owner}
socket={this.props.socket}
isChatDisabled={this.props.isChatDisabled}
setReactionMenu={this.setReactionMenu}
handleReactionClick={this.handleReactionClick}
onReply={this.setReplyTo}
/>
))}
{/* <div ref={this.messagesEndRef} /> */}
</div>
{!this.state.isNearBottom && (
<Button
size="xs"
onClick={this.scrollToBottom}
style={{
position: "sticky",
bottom: 0,
display: "block",
margin: "0 auto",
}}
>
Jump to bottom
</Button>
)}
</div>
{this.state.isPickerOpen && (
<div style={{ position: "absolute", bottom: "60px" }}>
<Picker
theme="dark"
previewPosition="none"
maxFrequentRows={1}
onEmojiSelect={this.addEmoji}
onClickOutside={() => this.setState({ isPickerOpen: false })}
/>
</div>
)}
<CSSTransition
in={this.state.reactionMenu.isOpen}
timeout={300}
classNames={{
enter: styles["reactionMenu-enter"],
enterActive: styles["reactionMenu-enter-active"],
exit: styles["reactionMenu-exit"],
exitActive: styles["reactionMenu-exit-active"],
}}
unmountOnExit
>
<div
style={{
position: "fixed",
top: Math.min(
this.state.reactionMenu.yPosition - 150,
window.innerHeight - 450,
),
left: this.state.reactionMenu.xPosition - 240,
}}
>
<Picker
theme="dark"
previewPosition="none"
maxFrequentRows={1}
perLine={6}
onClickOutside={() => this.setReactionMenu(false)}
onEmojiSelect={(emoji: any) => {
this.handleReactionClick(emoji.native);
this.setReactionMenu(false);
}}
/>
</div>
{/* <ReactionMenu
handleReactionClick={this.handleReactionClick}
closeMenu={() => this.setReactionMenu(false)}
yPosition={this.state.reactionMenu.yPosition}
xPosition={this.state.reactionMenu.xPosition}
/> */}
</CSSTransition>
{this.state.replyTo && (
<div
className={styles.replyComposer}
style={{
marginTop: 10,
padding: "6px 8px",
borderLeft: "2px solid rgb(46, 137, 212)",
background: "rgba(255, 255, 255, 0.06)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 8,
}}
>
<div className={styles.small + " " + styles.light}>
Replying to {this.props.nameMap[this.state.replyTo.id] || "Unknown"}
{this.state.replyTo.msg ? (
<div
style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: 260,
}}
>
{this.state.replyTo.msg}
</div>
) : null}
</div>
<Button size="xs" variant="subtle" onClick={this.clearReplyTo}>
Cancel
</Button>
</div>
)}
<TextInput
ref={this.chatInputRef}
style={{ marginTop: "10px" }}
onKeyDown={(e: any) => e.key === "Enter" && this.sendChatMsg()}
onChange={this.updateChatMsg}
value={this.state.chatMsg}
error={this.chatTooLong()}
disabled={this.props.isChatDisabled}
placeholder={
this.props.isChatDisabled
? "The chat was disabled by the room owner."
: "Enter a message..."
}
rightSection={
<ActionIcon
onClick={() => {
// Add a delay to prevent the click from triggering onClickOutside
const curr = this.state.isPickerOpen;
setTimeout(() => this.setState({ isPickerOpen: !curr }), 100);
}}
disabled={this.props.isChatDisabled}
>
<span role="img" aria-label="Emoji">
😀
</span>
</ActionIcon>
}
>
{/* <Icon onClick={this.sendChatMsg} name="send" inverted circular link /> */}
</TextInput>
</div>
);
}
}
const ChatMessage = ({
message,
nameMap,
pictureMap,
formatMessage,
socket,
owner,
isChatDisabled,
setReactionMenu,
handleReactionClick,
onReply,
className,
}: {
message: ChatMessage;
nameMap: StringDict;
pictureMap: StringDict;
formatMessage: (cmd: string, msg?: string) => React.ReactNode;
socket: Socket;
owner: string | undefined;
isChatDisabled: boolean | undefined;
setReactionMenu: (
isOpen: boolean,
selectedMsgId?: string,
selectedMsgTimestamp?: string,
yPosition?: number,
xPosition?: number,
) => void;
handleReactionClick: (value: string, id?: string, timestamp?: string) => void;
onReply: (id: string, timestamp: string, msg?: string) => void;
className: string;
}) => {
const { user } = useContext(MetadataContext);
const { id, timestamp, cmd, msg, system, isSub, reactions, videoTS } =
message;
const spellFull = 5; // the number of people whose names should be written out in full in the reaction popup
const imageMsg = renderImageString(msg);
return (
<div
style={{
display: "flex",
gap: "8px",
alignItems: "center",
position: "relative",
overflowWrap: "anywhere",
}}
className={`${styles.comment} ${className} ${
message.replyToUserId === clientId ? styles.replyMessage : ""
}`}
>
{id ? (
<Avatar
src={
pictureMap[id] ||
getDefaultPicture(nameMap[id], getColorForStringHex(id))
}
/>
) : null}
<div>
<div
style={{
display: "flex",
gap: "8px",
alignItems: "flex-end",
fontSize: 14,
}}
>
<UserMenu
displayName={nameMap[id] || id}
timestamp={timestamp}
socket={socket}
userToManage={id}
isChatMessage
disabled={!Boolean(owner && owner === user?.uid)}
trigger={
<div
style={{ cursor: "pointer", fontWeight: 700 }}
title={isSub ? "WatchParty Plus subscriber" : ""}
className={`${isSub ? styles.subscriber : styles.light} ${styles.hoverEffect}`}
>
{Boolean(system) && "System"}
{nameMap[id] || id}
</div>
}
/>
<div className={styles.small + " " + styles.dark}>
<div title={new Date(timestamp).toLocaleDateString()}>
{new Date(timestamp).toLocaleTimeString()}
{Boolean(videoTS) && " @ "}
{formatTimestamp(videoTS)}
</div>
</div>
</div>
<div className={styles.light + " " + styles.system}>
{cmd && formatMessage(cmd, msg)}
</div>
{message.replyToUserId && (
<HoverCard withinPortal={false} openDelay={120}>
<HoverCard.Target>
<div className={styles.replyInfo}>
{`Replying to @${nameMap[message.replyToUserId] || "user"}: `}
{truncateReply(message.replyToMsg, 30)}
</div>
</HoverCard.Target>
<HoverCard.Dropdown className={styles.replyTooltip}>
{message.replyToMsg || ""}
</HoverCard.Dropdown>
</HoverCard>
)}
<Linkify
componentDecorator={(
decoratedHref: string,
decoratedText: string,
key: string,
) => (
<SecureLink href={decoratedHref} key={key}>
{decoratedText}
</SecureLink>
)}
>
<div
className={`${styles.light} ${
isEmojiString(msg) ? styles.emoji : ""
}`}
>
{!cmd && msg}
</div>
</Linkify>
{imageMsg}
<div className={styles.commentMenu}>
{id && id !== clientId && (
<ActionIcon
onClick={() => onReply(id, timestamp, msg)}
disabled={isChatDisabled}
style={{
opacity: 1,
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: 0,
margin: 0,
marginRight: 4,
}}
>
<span role="img" aria-label="Reply" style={{ margin: 0, fontSize: 16 }}>
</span>
</ActionIcon>
)}
<ActionIcon
onClick={(e) => {
//@ts-expect-error
const viewportOffset = e.target.getBoundingClientRect();
setTimeout(() => {
setReactionMenu(
true,
id,
timestamp,
viewportOffset.top,
viewportOffset.right,
);
}, 100);
}}
disabled={isChatDisabled}
style={{
opacity: 1,
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: 0,
margin: 0,
}}
>
<span
role="img"
aria-label="React"
style={{ margin: 0, fontSize: 18 }}
>
😀
</span>
</ActionIcon>
</div>
<TransitionGroup>
{Object.keys(reactions ?? []).map((key) =>
reactions?.[key].length ? (
<CSSTransition
key={key}
timeout={200}
classNames={{
enter: styles["reaction-enter"],
enterActive: styles["reaction-enter-active"],
exit: styles["reaction-exit"],
exitActive: styles["reaction-exit-active"],
}}
unmountOnExit
>
<HoverCard>
<HoverCard.Target>
<div
className={`${styles.reactionContainer} ${
reactions[key].includes(clientId)
? styles.highlighted
: ""
}`}
onClick={() =>
handleReactionClick(key, message.id, message.timestamp)
}
>
<span
style={{
fontSize: 17,
position: "relative",
bottom: 1,
}}
>
{key}
</span>
<SwitchTransition>
<CSSTransition
key={key + "-" + reactions[key].length}
classNames={{
enter: styles["reactionCounter-enter"],
enterActive: styles["reactionCounter-enter-active"],
exit: styles["reactionCounter-exit"],
exitActive: styles["reactionCounter-exit-active"],
}}
addEndListener={(node, done) =>
node.addEventListener("transitionend", done, false)
}
unmountOnExit
>
<span
className={styles.reactionCounter}
style={{
color: "rgba(255, 255, 255, 0.85)",
marginLeft: 3,
}}
>
{reactions[key].length}
</span>
</CSSTransition>
</SwitchTransition>
</div>
</HoverCard.Target>
<HoverCard.Dropdown>
{`${reactions[key]
.slice(0, spellFull)
.map((id) => nameMap[id] || "Unknown")
.concat(
reactions[key].length > spellFull
? [`${reactions[key].length - spellFull} more`]
: [],
)
.reduce(
(text, value, i, array) =>
text +
(i < array.length - 1 ? ", " : " and ") +
value,
)} reacted.`}
</HoverCard.Dropdown>
</HoverCard>
</CSSTransition>
) : null,
)}
</TransitionGroup>
</div>
</div>
);
};
export const renderImageString = (
input: string | undefined,
): React.ReactNode | null => {
// If a valid image string, return an image
let regex =
/^https?:\/\/.*\/.*\.(png|gif|webp|jpeg|jpg|heic|heif|jfif)\??.*$/gim;
if (String(input)?.match(regex)) {
return <img style={{ maxWidth: "100%" }} src={input} />;
}
return null;
};
// class ReactionMenuInner extends React.Component<{
// handleReactionClick: (value: string, id?: string, timestamp?: string) => void;
// closeMenu: () => void;
// yPosition: number;
// xPosition: number;
// }> {
// state = {
// containerWidth: 0,
// };
// handleClickOutside = () => {
// this.props.closeMenu();
// };
// containerRef = React.createRef<HTMLDivElement>();
// componentDidMount() {
// this.setState({ containerWidth: this.containerRef.current?.offsetWidth });
// }
// render() {
// return (
// <div
// ref={this.containerRef}
// className={styles.reactionMenuContainer}
// style={{
// top: this.props.yPosition - 9,
// left: this.props.xPosition - this.state.containerWidth - 35,
// }}
// >
// {reactionEmojis.map((reaction) => (
// <div
// onClick={() => {
// this.props.handleReactionClick(reaction);
// this.props.closeMenu();
// }}
// style={{ cursor: 'pointer' }}
// >
// {reaction}
// </div>
// ))}
// </div>
// );
// }
// }
// const ReactionMenu = onClickOutside(ReactionMenuInner);
@@ -0,0 +1,69 @@
.Card {
background: rgba(0, 0, 0, 0.1);
border-radius: 8px;
padding: 8px;
}
.Wrapper {
display: flex;
align-items: center;
display: flex;
gap: 8px;
}
.ThumbnailWrapper {
flex: 0 0 auto;
width: 100px;
height: 56px;
overflow: hidden;
position: relative;
background: rgba(0, 0, 0, 0.1);
}
.Thumbnail {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.Content {
flex-grow: 1;
}
.Title {
line-height: 1.5;
/* max-width: 400px; */
overflow: hidden;
/* text-overflow: ellipsis;
white-space: nowrap; */
overflow-wrap: anywhere;
}
.ChannelName {
margin-top: 4px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.DurationLabel {
position: absolute;
z-index: 1;
bottom: 0;
right: 0;
padding: 2px 4px;
background: rgba(0, 0, 0, 0.95);
color: #fff;
font-size: 12px;
font-weight: 600;
pointer-events: none;
}
.Controls {
margin-left: auto;
display: flex;
gap: 4px;
}
@@ -0,0 +1,153 @@
import React from "react";
import { ActionIcon } from "@mantine/core";
import { decodeEntities, formatTimestamp } from "../../utils/utils";
import classes from "./ChatVideoCard.module.css";
import {
IconArrowUp,
IconBrandYoutubeFilled,
IconFile,
IconMagnetFilled,
IconPlayerPlayFilled,
IconPlaylistAdd,
IconTrash,
} from "@tabler/icons-react";
const ChatVideoCard: React.FC<{
video: PlaylistVideo;
index: number;
controls?: boolean;
onPlay?: (index: number) => void;
onRemove?: (index: number) => void;
onPlayNext?: (index: number) => void;
onSetMedia?: (value: string) => void;
onPlaylistAdd?: (value: string) => void;
disabled?: boolean;
}> = (props) => {
const {
video,
index,
controls,
onPlay,
onPlayNext,
onRemove,
onSetMedia,
disabled,
onPlaylistAdd,
} = props;
const handlePlayClick = React.useCallback(
(e: any) => {
if (onPlay) {
onPlay(index);
}
},
[onPlay, index],
);
const handlePlayNextClick = React.useCallback(
(e: any) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
if (onPlayNext) {
onPlayNext(index);
}
},
[onPlayNext, index],
);
const handleRemoveClick = React.useCallback(
(e: any) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
if (onRemove) {
onRemove(index);
}
},
[onRemove, index],
);
return (
<div
title={video.name}
className={classes.Card}
onClick={
onSetMedia
? (e) => {
onSetMedia(video.url);
}
: undefined
}
>
<div className={classes.Wrapper}>
<div className={classes.ThumbnailWrapper}>
{!!video.duration && (
<div className={classes.DurationLabel}>
{formatTimestamp(video.duration)}
</div>
)}
{!!video.img && (
<img
className={classes.Thumbnail}
src={video.img}
alt={video.name}
/>
)}
</div>
<div style={{ flexShrink: 0 }}>
{video.type === "youtube" && <IconBrandYoutubeFilled color="red" />}
{video.type === "file" && <IconFile />}
{video.type === "magnet" && <IconMagnetFilled />}
</div>
<div className={classes.Content}>
<div className={classes.Title}>{decodeEntities(video.name)}</div>
<div className={classes.ChannelName}>{video.channel}</div>
</div>
{onPlaylistAdd && (
<div className={classes.Controls}>
<ActionIcon
title="Add to Playlist"
onClick={(e) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
onPlaylistAdd(video.url);
}}
>
<IconPlaylistAdd />
</ActionIcon>
</div>
)}
{controls && (
<div className={classes.Controls}>
<ActionIcon
color="green"
title="Play now"
onClick={handlePlayClick}
disabled={disabled}
>
<IconPlayerPlayFilled size={16} />
</ActionIcon>
<ActionIcon
color="black"
title="Play next"
onClick={handlePlayNextClick}
disabled={disabled}
>
<IconArrowUp size={16} />
</ActionIcon>
<ActionIcon
color="red"
title="Remove"
onClick={handleRemoveClick}
disabled={disabled}
>
<IconTrash size={16} />
</ActionIcon>
</div>
)}
</div>
</div>
);
};
export default ChatVideoCard;
+176
View File
@@ -0,0 +1,176 @@
import React from "react";
import {
debounce,
getMediaPathResults,
getYouTubeResults,
isHttp,
isMagnet,
isYouTube,
} from "../../utils/utils";
import { examples } from "../../utils/examples";
import ChatVideoCard from "../ChatVideoCard/ChatVideoCard";
import { IconLink, IconX } from "@tabler/icons-react";
import {
ActionIcon,
Autocomplete,
Loader,
type AutocompleteProps,
} from "@mantine/core";
type ComboBoxProps = {
roomSetMedia: (value: string) => void;
playlistAdd: (value: string) => void;
roomMedia: string;
getMediaDisplayName: (input: string) => string;
mediaPath: string | undefined;
disabled?: boolean;
};
type ComboBoxState = {
inputMedia?: string;
items: SearchResult[];
loading: boolean;
};
export class ComboBox extends React.Component<ComboBoxProps, ComboBoxState> {
state: ComboBoxState = {
inputMedia: undefined as string | undefined,
items: [] as SearchResult[],
loading: false,
};
setMediaAndClose = (value: string) => {
this.props.roomSetMedia(value);
this.setState({ inputMedia: undefined, items: [] });
};
doSearch = async () => {
const value = this.state.inputMedia;
this.setState({ loading: true });
const query: string = value || "";
let items = examples;
if (
query === "" ||
// Anything that doesn't pass this check we pass to YouTube as a search query
(query && (isHttp(query) || isMagnet(query)))
) {
if (!value && this.props.mediaPath) {
items = await getMediaPathResults(this.props.mediaPath, "");
}
if (query) {
let type: SearchResult["type"] = "file";
if (isYouTube(query)) {
type = "youtube";
}
if (isMagnet(query)) {
type = "magnet";
}
// Create entry from user input
items = [
{
name: query,
type,
url: query,
duration: 0,
},
];
}
} else {
const data = await getYouTubeResults(query);
items = data;
}
this.setState({
loading: false,
items,
});
};
debouncedSearch = debounce(this.doSearch);
onChange = (value: string) => {
this.setState({ inputMedia: value }, this.debouncedSearch);
};
render() {
const { roomMedia: currentMedia, getMediaDisplayName } = this.props;
const renderOption: AutocompleteProps["renderOption"] = ({ option }) => {
const video = this.state.items.find((item) => item.url === option.value);
return (
<div
key={option.value}
onClick={(e) => this.setMediaAndClose(option.value)}
style={{ width: "100%" }}
>
{video && (
<ChatVideoCard
video={video}
index={0}
onPlaylistAdd={this.props.playlistAdd}
/>
)}
</div>
);
};
return (
<Autocomplete
maxDropdownHeight={400}
style={{ width: "100%" }}
disabled={this.props.disabled}
onChange={this.onChange}
onFocus={(e: any) => {
this.setState(
{
// Display the real string value (currentMedia) when focused
inputMedia:
isHttp(currentMedia) || isMagnet(currentMedia)
? currentMedia
: getMediaDisplayName(currentMedia),
},
() => {
if (
!this.state.inputMedia ||
(this.state.inputMedia &&
(isHttp(this.state.inputMedia) ||
isMagnet(this.state.inputMedia)))
) {
this.doSearch();
}
e.target.select();
},
);
}}
onBlur={() => {
this.setState({
inputMedia: undefined,
items: [],
});
}}
onKeyDown={(e: any) => {
if (e.key === "Enter") {
this.setMediaAndClose(this.state.inputMedia ?? "");
e.target.blur();
}
}}
rightSection={
<ActionIcon
color="red"
onClick={(e: any) => this.setMediaAndClose("")}
title="Clear"
>
<IconX />
</ActionIcon>
}
leftSection={this.state.loading ? <Loader size="sm" /> : <IconLink />}
placeholder="Enter video file URL, magnet link, YouTube link, or YouTube search term"
value={
this.state.inputMedia !== undefined
? this.state.inputMedia
: getMediaDisplayName(currentMedia)
}
renderOption={renderOption}
data={this.state.items.map((item) => item.url)}
filter={({ options }) => options}
/>
);
}
}
@@ -0,0 +1,31 @@
.controlsContainer {
position: absolute;
width: 100%;
bottom: 0px;
}
.controls {
color: whitesmoke;
display: flex;
align-items: center;
line-height: 14px;
padding-left: 10px;
padding-top: 4px;
padding-bottom: 4px;
gap: 6px;
}
@media only screen and (max-width: 600px) {
.controls {
flex-wrap: wrap;
}
}
.text {
font-weight: 700;
font-size: 0.8em;
}
.action {
cursor: pointer;
}
+387
View File
@@ -0,0 +1,387 @@
import React, { useMemo, useState } from "react";
import { Badge, Button, Menu, Progress, Slider } from "@mantine/core";
import { formatTimestamp, softWhite } from "../../utils/utils";
import styles from "./Controls.module.css";
import { MetadataContext } from "../../MetadataContext";
import {
IconPlayerPlayFilled,
IconPlayerPauseFilled,
IconRefresh,
IconCheck,
IconRepeat,
IconBadgeCc,
IconVolumeOff,
IconVolume,
IconTheater,
IconMaximize,
IconPlayerSkipForwardFilled,
} from "@tabler/icons-react";
interface ControlsProps {
duration: number;
video: string;
paused: boolean;
muted: boolean;
volume: number;
subtitled: boolean;
currentTime: number;
disabled?: boolean;
leaderTime?: number;
isPauseDisabled?: boolean;
playbackRate: number;
roomPlaybackRate: number;
isYouTube: boolean;
isLiveStream: boolean;
timeRanges: { start: number; end: number }[];
loop: boolean;
roomTogglePlay: () => void;
roomSeek: (time: number) => void;
roomSetPlaybackRate: (rate: number) => void;
roomSetLoop: (loop: boolean) => void;
localFullScreen: (fs: boolean) => void;
localToggleMute: () => void;
localSubtitleModal: () => void;
localSeek: () => void;
localSetVolume: (volume: number) => void;
localSetSubtitleMode: (mode: TextTrackMode, lang?: string) => void;
roomPlaylistPlay: (index: number) => void;
playlist: PlaylistVideo[];
}
export const Controls = (props: ControlsProps) => {
const [hoverState, setHoverState] = useState({
hoverTimestamp: 0,
hoverPos: 0,
});
const [showTimestamp, setShowTimestamp] = useState(false);
const getEnd = () => props.duration;
const getStart = () => 0;
const getLength = () => getEnd() - getStart();
const getCurrent = () => props.currentTime;
const getPercent = () => (getCurrent() - getStart()) / getLength();
const zeroTime = useMemo(
() => Math.floor(Date.now() / 1000) - props.duration,
[props.video, Boolean(props.duration)],
);
const onMouseOver = () => {
setShowTimestamp(true);
};
const onMouseOut = () => {
setShowTimestamp(false);
};
const onMouseMove = (e: any) => {
const rect = e.target.getBoundingClientRect();
const x = e.clientX - rect.left;
const max = rect.width;
const pct = x / max;
// console.log(x, max);
const target = getStart() + pct * getLength();
// console.log(pct);
if (pct >= 0) {
setHoverState({ hoverTimestamp: target, hoverPos: pct });
}
};
const {
roomTogglePlay,
roomSeek,
localFullScreen,
localToggleMute,
localSubtitleModal,
localSeek,
currentTime,
leaderTime,
isPauseDisabled,
disabled,
subtitled,
paused,
muted,
volume,
isLiveStream,
playlist,
roomPlaylistPlay,
timeRanges,
roomSetPlaybackRate,
roomPlaybackRate,
} = props;
// console.log(leaderTime, currentTime);
const behindThreshold = 10;
const behindTime =
!isLiveStream && leaderTime && leaderTime < Infinity
? leaderTime - currentTime
: getEnd() - getCurrent();
const isBehind = behindTime > behindThreshold;
const buffers = timeRanges.map(({ start, end }) => {
const buffStartPct = (start / getLength()) * 100;
const buffLengthPct = ((end - start) / getLength()) * 100;
return (
<div
key={start}
style={{
position: "absolute",
height: "8px",
backgroundColor: "grey",
left: buffStartPct + "%",
width: buffLengthPct + "%",
bottom: "0em",
zIndex: 0,
pointerEvents: "none",
}}
></div>
);
});
const playPauseProps = {
onClick: () => {
roomTogglePlay();
},
className: ` ${styles.action}`,
disabled: disabled || isPauseDisabled,
};
return (
<div className={styles.controls}>
{paused ? (
<IconPlayerPlayFilled {...playPauseProps} />
) : (
<IconPlayerPauseFilled {...playPauseProps} />
)}
{playlist.length > 0 && (
<IconPlayerSkipForwardFilled
title="Skip to next"
className={styles.action}
onClick={() => roomPlaylistPlay(0)}
/>
)}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
flexDirection: "column",
position: "relative",
}}
>
<Button
size="compact-xs"
color={isBehind ? "blue" : "grey"}
title="Sync"
onClick={() => {
if (isLiveStream) {
// in live case we want to seek the entire room to edge
roomSeek(props.duration);
} else {
localSeek();
}
}}
>
Sync
</Button>
{/* <div style={{ position: 'absolute', fontSize: '6px', zIndex: -1 }}>
{Math.max(Math.floor(behindTime), 0)}
</div> */}
</div>
<div className={` ${styles.text}`}>
{formatTimestamp(getCurrent(), isLiveStream ? zeroTime : undefined)}
</div>
<Progress.Root
radius="0px"
onClick={(e: any) => {
if (!disabled) {
// Read the time from the click event
if (e) {
const rect = e.target.getBoundingClientRect();
const x = e.clientX - rect.left;
const max = rect.width;
const pct = x / max;
// roomseek operates on actual media element times, not timestamps
// for DASH we set getStart() value to when stream started, so left side is 0 video time
// even though it's not all seekable this makes the click to seek simple
let target = getLength() * pct;
roomSeek(target);
}
}
}}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
onMouseMove={onMouseMove}
style={{
flexGrow: 1,
marginTop: 0,
marginBottom: 0,
position: "relative",
minWidth: "50px",
overflow: "visible",
cursor: "pointer",
}}
>
<Progress.Section
style={{ pointerEvents: "none", zIndex: 1 }}
value={getPercent() * 100}
></Progress.Section>
{buffers}
{/* {
<div
style={{
position: 'absolute',
bottom: '0px',
left: `calc(${this.getPercent() * 100 + '% - 6px'})`,
pointerEvents: 'none',
width: '12px',
height: '12px',
transform:
this.getLength() < Infinity && showTimestamp
? 'scale(1, 1)'
: 'scale(0, 0)',
transition: '0.25s all',
borderRadius: '50%',
backgroundColor: '#54c8ff',
}}
></div>
} */}
{getLength() < Infinity && showTimestamp && (
<Badge
style={{
position: "absolute",
bottom: "10px",
left: `calc(${hoverState.hoverPos * 100 + "%"})`,
transform: "translate(-50%)",
display: "inline-block",
}}
>
{formatTimestamp(
hoverState.hoverTimestamp,
isLiveStream ? zeroTime : undefined,
)}
</Badge>
)}
</Progress.Root>
<div className={` ${styles.text}`}>{formatTimestamp(getEnd())}</div>
{isLiveStream && (
<Badge size="xs" color="red">
LIVE
</Badge>
)}
{
<Menu disabled={disabled}>
<Menu.Target>
<div
className={`${styles.text} ${styles.action}`}
style={{
backgroundColor: "rgba(100,100,100, 0.6)",
fontSize: 10,
borderRadius: "4px",
padding: "2px",
}}
>
{props.playbackRate?.toFixed(2)}x
</div>
</Menu.Target>
<Menu.Dropdown>
{[
{ key: "Auto", text: "Auto", value: 0 },
{ key: "0.25", text: "0.25x", value: 0.25 },
{ key: "0.5", text: "0.5x", value: 0.5 },
// { key: '0.75', text: '0.75x', value: 0.75 },
{ key: "1", text: "1x", value: 1 },
// { key: '1.25', text: '1.25x', value: 1.25 },
{ key: "1.5", text: "1.5x", value: 1.5 },
// { key: '1.75', text: '1.75x', value: 1.75 },
{ key: "2", text: "2x", value: 2 },
{ key: "3", text: "3x", value: 3 },
].map((item) => (
<Menu.Item
key={item.key}
onClick={() => roomSetPlaybackRate(item.value)}
rightSection={
roomPlaybackRate === item.value ? <IconCheck /> : null
}
>
{item.text}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
}
<IconRepeat
onClick={() => {
if (!disabled) {
props.roomSetLoop(Boolean(!props.loop));
}
}}
className={` ${styles.action}`}
title="Loop"
color={props.loop ? "green" : softWhite}
/>
{props.isYouTube ? (
<Menu>
<Menu.Target>
<IconBadgeCc className={styles.action} />
</Menu.Target>
<Menu.Dropdown>
{[
{ key: "hidden", text: "Off", value: "hidden" },
{ key: "en", text: "English", value: "showing" },
{ key: "es", text: "Spanish", value: "showing" },
].map((item) => (
<Menu.Item
key={item.key}
onClick={() =>
props.localSetSubtitleMode(
item.value as TextTrackMode,
item.key,
)
}
>
{item.text}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
) : (
<IconBadgeCc
onClick={() => {
localSubtitleModal();
}}
className={` ${styles.action}`}
title="Captions"
color={subtitled ? "green" : softWhite}
/>
)}
<IconTheater
onClick={() => localFullScreen(false)}
className={` ${styles.action}`}
title="Theater Mode"
/>
<IconMaximize
onClick={() => localFullScreen(true)}
className={` ${styles.action}`}
title="Fullscreen"
/>
{muted ? (
<IconVolumeOff
onClick={() => {
localToggleMute();
}}
className={` ${styles.action}`}
/>
) : (
<IconVolume
onClick={() => {
localToggleMute();
}}
className={` ${styles.action}`}
/>
)}
<div style={{ width: "100px" }}>
<Slider
defaultValue={volume}
disabled={muted}
min={0}
max={1}
step={0.01}
onChangeEnd={(value: number) => {
props.localSetVolume(value);
}}
/>
</div>
</div>
);
};
+39
View File
@@ -0,0 +1,39 @@
import React, { useContext, useRef } from "react";
import { createRoom } from "../TopBar/TopBar";
import { Loader } from "@mantine/core";
import { MetadataContext } from "../../MetadataContext";
export const Create = () => {
const { user } = useContext(MetadataContext);
const buttonEl = useRef<HTMLButtonElement>(null);
setTimeout(() => {
buttonEl?.current?.click();
}, 1000);
return (
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
width: "100vw",
height: "100vh",
}}
>
<Loader></Loader>
<div>Creating room. . .</div>
<button
style={{ display: "none" }}
ref={buttonEl}
onClick={() => {
createRoom(
user,
false,
new URLSearchParams(window.location.search).get("video") ??
undefined,
);
}}
/>
</div>
);
};
+169
View File
@@ -0,0 +1,169 @@
import React, { useState, useEffect } from "react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
} from "recharts";
import { serverPath } from "../../utils/utils";
import { useFetch } from "../../utils/useFetch.hook";
import CountUp from "react-countup";
import { Table } from "@mantine/core";
const timeSeriesUrl = serverPath + `/timeSeries${window.location.search}`;
// const timeSeriesUrl = 'https://www.watchparty.me' + `/timeSeries${window.location.search}`;
const statsUrl = serverPath + `/stats${window.location.search}`;
// const statsUrl = 'https://www.watchparty.me' + `/stats${window.location.search}`;
// Rendering:
// Anything that's a Record<string, number> should render as 2 column table
// e.g. counts, roomsizecounts, per shard stats
// vBrowserClientIDs etc. should be converted to key/value pairs
// vmManagerStats and currentRoomData should render in JSON blocks
const Debug = () => {
let timeSeries: any[] = useFetch(timeSeriesUrl) ?? [];
const rev = [...timeSeries].reverse();
const [state, setState] = useState({
current: {} as Record<string, any>,
last: {} as Record<string, any>,
});
useEffect(() => {
let last = state.current;
const update = async () => {
const resp = await fetch(statsUrl);
const json = await resp.json();
const nextState = { current: json, last };
setState(nextState);
// Save the value for the next update
last = nextState.current;
};
update();
setInterval(update, 10000);
}, []);
// Get the keys from the last/mostrecent element
const keys = Object.keys(timeSeries.slice(-1)[0] ?? {});
return (
<div>
<div
style={{
display: "flex",
overflowX: "auto",
flexWrap: "wrap",
flexDirection: "column",
height: "2000px",
}}
>
{Object.keys(state.current).map((k) => {
if (k === "vmManagerStats") {
return (
<div style={{ overflow: "auto" }}>
<pre style={{ fontSize: 12 }} key={k}>
{JSON.stringify(state.current[k], null, 2)}
</pre>
</div>
);
} else if (Array.isArray(state.current[k])) {
// One column table
return (
<div
style={{
maxWidth: k === "currentRoomData" ? "400px" : undefined,
overflow: "auto",
}}
>
<Table style={{}} key={k}>
<Table.Thead>
<Table.Tr>
<Table.Th>{k}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{state.current[k].map((row) => {
return (
<Table.Tr>
<Table.Td>
{k === "currentRoomData" ? (
<div style={{ wordBreak: "break-all" }}>
{JSON.stringify(row, null, 2)}
</div>
) : (
row
)}
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</div>
);
} else {
// Map
return (
<div style={{ overflow: "auto" }}>
<Table style={{}} key={k}>
<Table.Thead>
<div>{k}</div>
<Table.Tr>
<Table.Th>Key</Table.Th>
<Table.Th>Value</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{Object.keys(state.current[k]).map((key) => {
return (
<Table.Tr>
<Table.Td>{key}</Table.Td>
<Table.Td>
<CountUp
start={
state.last[k]?.[key] ?? state.current[k][key]
}
end={state.current[k][key]}
duration={10}
delay={0}
useEasing={false}
/>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</div>
);
}
})}
</div>
{keys
.filter((k) => k !== "time")
.map((key) => {
return (
<LineChart
width={1400}
height={400}
data={rev}
margin={{
top: 5,
left: 20,
bottom: 5,
}}
>
<CartesianGrid />
<XAxis dataKey="time" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey={key} stroke="#8884d8" />
</LineChart>
);
})}
</div>
);
};
export default Debug;
+47
View File
@@ -0,0 +1,47 @@
import { useContext, useEffect, useState } from "react";
import firebase from "firebase/compat/app";
import { serverPath } from "../../utils/utils";
import { MetadataContext } from "../../MetadataContext";
export const Discord = () => {
const [errorMsg, setErrorMsg] = useState("");
const { user } = useContext(MetadataContext);
useEffect(() => {
async function auth() {
const fragment = new URLSearchParams(window.location.hash.slice(1));
const [accessToken, tokenType] = [
fragment.get("access_token"),
fragment.get("token_type"),
];
const token = await user?.getIdToken();
const authResponse = await fetch(serverPath + "/linkAccount", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
uid: user?.uid,
kind: "discord",
token: token,
tokenType,
accessToken,
}),
});
if (authResponse.status !== 200) {
const body = await authResponse.json();
setErrorMsg(body.error);
} else {
window.opener.location.reload(false);
window.close();
}
}
if (user) {
auth();
}
}, [user]);
if (errorMsg) {
return <div style={{ color: "red", fontSize: 20 }}>{errorMsg}</div>;
}
return null;
};
+20
View File
@@ -0,0 +1,20 @@
import React from "react";
import { Link } from "react-router-dom";
import { softWhite } from "../../utils/utils";
export const Footer = () => (
<div
style={{
margin: "1em",
paddingBottom: "1em",
fontSize: "14px",
color: softWhite,
}}
>
<Link to="/terms">Terms</Link>
{" · "}
<Link to="/privacy">Privacy</Link>
{" · "}
<Link to="/faq">FAQ</Link>
</div>
);
+66
View File
@@ -0,0 +1,66 @@
.container {
display: flex;
flex-direction: column;
color: white;
/* background-image: linear-gradient(to bottom, #222222, #008cdd); */
}
.hero {
color: white;
width: 100%;
background-image: linear-gradient(to bottom, #222222, #006199);
border-bottom-left-radius: 0%;
border-bottom-right-radius: 50%;
display: flex;
justify-content: center;
}
.hero.green {
background-image: linear-gradient(to bottom, #222222, rgb(21, 114, 43));
border-bottom-left-radius: 50%;
border-bottom-right-radius: 0%;
}
.heroInner {
padding: 30px;
max-width: 1200px;
display: flex;
align-items: center;
}
@media only screen and (max-width: 600px) {
.heroInner {
flex-direction: column;
}
}
.heroText {
font-size: 32px;
line-height: 1.2em;
font-weight: 600;
}
.subText {
color: lightgray;
font-size: 20px;
line-height: 1.5em;
margin-top: 8px;
}
.featureSection {
display: flex;
max-width: 1200px;
justify-content: center;
margin: 0 auto;
flex-wrap: wrap;
}
.featureTitle {
text-transform: uppercase;
letter-spacing: 1px;
}
.featureText {
color: lightgray;
font-size: 14px;
}
+221
View File
@@ -0,0 +1,221 @@
import React, { useContext } from "react";
import { Button, Stepper } from "@mantine/core";
import {
IconBrandDiscordFilled,
IconBrandYoutubeFilled,
IconBrowser,
IconFile,
IconLink,
IconList,
IconMessageFilled,
type IconProps,
IconRefresh,
IconScreenShare,
IconVideo,
} from "@tabler/icons-react";
import { NewRoomButton } from "../TopBar/TopBar";
import styles from "./Home.module.css";
import { MetadataContext } from "../../MetadataContext";
export const Home = () => {
const { user } = useContext(MetadataContext);
return (
<div>
<div className={styles.container}>
<Hero
heroText={"Watch videos together with friends anywhere."}
subText={"No registration or download required."}
action={
<div style={{ marginTop: "8px", width: "300px" }}>
<NewRoomButton size="xl" />
</div>
}
image={"/screenshot4.png"}
/>
<div className={styles.featureSection}>
<Feature
Icon={IconBrowser}
title={`VBrowser`}
text="Watch together on a virtual browser running in the cloud."
/>
<Feature
Icon={IconBrandYoutubeFilled}
title={`YouTube`}
text="Watch videos together from YouTube."
/>
<Feature
Icon={IconScreenShare}
title={`Screensharing`}
text="Share a browser tab or your desktop."
/>
<Feature
Icon={IconFile}
title={`File`}
text="Upload and stream your own file."
/>
<Feature
Icon={IconLink}
title={`URL`}
text="Paste in a video URL for everyone to watch from."
/>
</div>
<Hero
heroText={"React to moments together."}
subText={"Find moments of shared joy even when you're apart."}
image={"/screenshot18.png"}
color="green"
/>
<div className={styles.featureSection}>
<Feature
Icon={IconRefresh}
title="Synchronized Play"
text="Starts, stops, and seeks are synchronized to everyone, so take those restroom and snack breaks without worrying about falling behind."
/>
<Feature
Icon={IconMessageFilled}
title="Chat"
text="Chat with others in your room. Memes and inside jokes encouraged."
/>
<Feature
Icon={IconList}
title="Playlists"
text="Set up a whole list of videos to play next, and rearrange to your heart's content."
/>
<Feature
Icon={IconVideo}
title="Video chat"
text="Jump into video chat if you'd rather be face-to-face."
/>
</div>
<Hero
heroText={"Theater mode."}
subText={
"Bring video and chat front-and-center for minimal distractions."
}
image={"/screenshot14.png"}
/>
<div
style={{
padding: "30px",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<div className={styles.heroText}>Get started!</div>
<div className={styles.featureSection}>
<Stepper active={-1}>
<Stepper.Step label="Make a room" />
<Stepper.Step label="Share link with friends" />
<Stepper.Step label="Pick something to watch" />
<Stepper.Step label="Success!" />
</Stepper>
</div>
{/* <div style={{ width: '160px' }}>
<NewRoomButton />
</div> */}
</div>
</div>
<DiscordBot />
</div>
);
};
const Feature = ({
Icon,
text,
title,
}: {
Icon: React.ForwardRefExoticComponent<IconProps>;
text: string;
title: string;
}) => {
return (
<div
style={{
display: "flex",
flex: "1 1 0px",
flexDirection: "column",
alignItems: "center",
padding: "10px",
minWidth: "180px",
}}
>
<Icon size={80} />
<h4 className={styles.featureTitle}>{title}</h4>
<div className={styles.featureText}>{text}</div>
</div>
);
};
export const Hero = ({
heroText,
subText,
subText2,
action,
image,
color,
}: {
heroText?: string;
subText?: string;
subText2?: string;
action?: React.ReactNode;
image?: string;
color?: string;
}) => {
return (
<div className={`${styles.hero} ${color === "green" ? styles.green : ""}`}>
<div
style={{ flexDirection: color === "green" ? "row-reverse" : undefined }}
className={styles.heroInner}
>
<div style={{ padding: "30px", flex: "1 1 0" }}>
<div className={styles.heroText}>{heroText}</div>
<div className={styles.subText}>{subText}</div>
<div className={styles.subText}>{subText2}</div>
{action}
</div>
<div
style={{
flex: "1 1 0",
}}
>
<img
alt="hero"
style={{ width: "100%", borderRadius: "10px" }}
src={image}
/>
</div>
</div>
</div>
);
};
export const DiscordBot = () => {
return (
<div>
<Hero
color="green"
heroText={
"Add the WatchParty Discord bot to your server to easily generate WatchParty links."
}
subText={"/watch to generate a new empty room"}
subText2={"/watch video <URL_HERE> to create a room with a video"}
action={
<Button
leftSection={<IconBrandDiscordFilled />}
component="a"
size="lg"
target="_blank"
href="https://discord.com/api/oauth2/authorize?client_id=1071394728513380372&permissions=2147485696&scope=bot"
>
Add to Discord
</Button>
}
image={"/screenshot5.png"}
/>
</div>
);
};
@@ -0,0 +1,24 @@
import React, { useState } from "react";
import { ActionIcon } from "@mantine/core";
import { InviteModal } from "../Modal/InviteModal";
import { IconUserPlus } from "@tabler/icons-react";
export const InviteButton = () => {
const [inviteModalOpen, setInviteModalOpen] = useState(false);
return (
<>
{inviteModalOpen && (
<InviteModal closeInviteModal={() => setInviteModalOpen(false)} />
)}
<ActionIcon
size="36px"
color="green"
title="Invite friends"
onClick={() => setInviteModalOpen(true)}
>
<IconUserPlus />
</ActionIcon>
</>
);
};
+37
View File
@@ -0,0 +1,37 @@
import React from "react";
import { Modal, Button } from "@mantine/core";
import { IconHome, IconRefresh } from "@tabler/icons-react";
export const ErrorModal = ({ error }: { error: string }) => {
return (
<Modal opened onClose={() => {}} title={error} centered>
<div
style={{
display: "flex",
flexWrap: "wrap",
justifyContent: "center",
gap: "8px",
}}
>
<Button
size="xl"
onClick={() => {
window.location.reload();
}}
leftSection={<IconRefresh />}
>
Try again
</Button>
<Button
size="xl"
onClick={() => {
window.location.href = "/";
}}
leftSection={<IconHome />}
>
Go to home
</Button>
</div>
</Modal>
);
};

Some files were not shown because too many files have changed in this diff Show More