init
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,84 @@
|
||||
# WatchParty
|
||||
|
||||

|
||||
|
||||
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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"CORSRules": [
|
||||
{
|
||||
"AllowedOrigins": ["*"],
|
||||
"AllowedHeaders": ["*"],
|
||||
"AllowedMethods": ["GET", "HEAD", "POST", "PUT", "DELETE"],
|
||||
"MaxAgeSeconds": 3000,
|
||||
"ExposeHeaders": ["Etag"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<CORSConfiguration>
|
||||
<CORSRule>
|
||||
<AllowedOrigin>*</AllowedOrigin>
|
||||
<AllowedMethod>GET</AllowedMethod>
|
||||
<AllowedHeader>*</AllowedHeader>
|
||||
<MaxAgeSeconds>3000</MaxAgeSeconds>
|
||||
<ExposeHeader>ETag</ExposeHeader>
|
||||
</CORSRule>
|
||||
</CORSConfiguration>
|
||||
@@ -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'"
|
||||
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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));
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
docker run --name watchparty-redis --rm --net=host -d redis
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
|
After Width: | Height: | Size: 971 KiB |
|
After Width: | Height: | Size: 656 KiB |
|
After Width: | Height: | Size: 712 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 980 KiB |
|
After Width: | Height: | Size: 2.5 MiB |
@@ -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();
|
||||
}));
|
||||
})();
|
||||
@@ -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]");
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const findPlaylistVideoByUrl = (
|
||||
playlist: PlaylistVideo[],
|
||||
url?: string,
|
||||
) => {
|
||||
if (!url) return;
|
||||
return playlist.find((video) => video.url === url);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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/;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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/")
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||