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>
|
||||||
|
);
|
||||||
|
};
|
||||||