archive
This commit is contained in:
25
opentogethertube/Dockerfile
Executable file
25
opentogethertube/Dockerfile
Executable file
@@ -0,0 +1,25 @@
|
||||
FROM node:13.7.0-buster
|
||||
|
||||
RUN npm install npx \
|
||||
&& apt update \
|
||||
&& apt -y install redis
|
||||
|
||||
WORKDIR /main
|
||||
|
||||
COPY opentogethertube /main/opentogethertube
|
||||
|
||||
WORKDIR /main/opentogethertube
|
||||
RUN true \
|
||||
&& npm install \
|
||||
&& npx sequelize-cli db:migrate
|
||||
|
||||
COPY development.env /main/opentogethertube/env/
|
||||
RUN true \
|
||||
&& for env in $(cat /main/opentogethertube/env/development.env); do eval "export $env"; done \
|
||||
&& echo doing env \
|
||||
&& env | grep API \
|
||||
&& echo /doing env \
|
||||
&& npm run build
|
||||
|
||||
CMD []
|
||||
ENTRYPOINT ["bash", "-c", "true; set -e; redis-server /etc/redis/redis.conf; npm start"]
|
||||
2
opentogethertube/development.env
Executable file
2
opentogethertube/development.env
Executable file
@@ -0,0 +1,2 @@
|
||||
YOUTUBE_API_KEY=AIzaSyDefVBf_xeyecFoKFh1xsepr-L467mXayQ
|
||||
GOOGLE_DRIVE_API_KEY=AIzaSyD3ak87dsE3oh7UhFhxXbaQcNf9bSayVP0
|
||||
2
opentogethertube/opentogethertube/.browserslistrc
Normal file
2
opentogethertube/opentogethertube/.browserslistrc
Normal file
@@ -0,0 +1,2 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
4
opentogethertube/opentogethertube/.eslintignore
Normal file
4
opentogethertube/opentogethertube/.eslintignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/*
|
||||
dist/*
|
||||
db/*
|
||||
coverage/*
|
||||
66
opentogethertube/opentogethertube/.eslintrc.js
Normal file
66
opentogethertube/opentogethertube/.eslintrc.js
Normal file
@@ -0,0 +1,66 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true
|
||||
},
|
||||
plugins: [
|
||||
"jest",
|
||||
],
|
||||
'extends': [
|
||||
'eslint:recommended',
|
||||
'plugin:vue/base',
|
||||
'plugin:vue/essential',
|
||||
],
|
||||
rules: {
|
||||
'no-console': 'error',
|
||||
'no-debugger': 'error',
|
||||
'array-bracket-newline': ['error', { "multiline": true, "minItems": 2 }],
|
||||
'array-bracket-spacing': ['error', 'never'],
|
||||
'brace-style': ['error', 'stroustrup', { 'allowSingleLine': false }],
|
||||
'comma-dangle': ['error', {
|
||||
'arrays': 'always-multiline',
|
||||
'objects': 'always-multiline',
|
||||
'imports': 'never',
|
||||
'exports': 'always-multiline',
|
||||
'functions': 'never',
|
||||
}],
|
||||
'comma-spacing': ['error', {'before': false, 'after': true}],
|
||||
'curly': ['error', 'all'],
|
||||
'func-call-spacing': ['error', 'never'],
|
||||
'implicit-arrow-linebreak': ['error', 'beside'],
|
||||
'keyword-spacing': ['error', { 'before': true, 'after': true }],
|
||||
'no-eval': ['error', {}],
|
||||
'no-multiple-empty-lines': ['error', { 'max': 1, 'maxBOF': 0 }],
|
||||
'no-var': 'error',
|
||||
'no-dupe-keys': 'error',
|
||||
'no-prototype-builtins': 'off',
|
||||
'prefer-arrow-callback': 'error',
|
||||
'semi': ['error', 'always'],
|
||||
'semi-spacing': ["error", {"before": false, "after": true}],
|
||||
'space-before-blocks': ['error', 'always'],
|
||||
'eol-last': ["error", "always"],
|
||||
|
||||
// HACK: this rule is required, otherwise travis-ci will fail (for some reason)
|
||||
// even through when run locally, no linting errors occur.
|
||||
"vue/no-parsing-error": ["error", {
|
||||
"invalid-first-character-of-tag-name": false,
|
||||
}],
|
||||
|
||||
'jest/consistent-test-it': ["error", {"fn": "it"}],
|
||||
'jest/expect-expect': 'warn',
|
||||
'jest/no-duplicate-hooks': 'error',
|
||||
'jest/no-focused-tests': 'error',
|
||||
'jest/no-identical-title': 'error',
|
||||
'jest/no-if': 'error',
|
||||
'jest/no-expect-resolves': 'warn',
|
||||
'jest/no-export': 'error',
|
||||
'jest/no-standalone-expect': 'error',
|
||||
'jest/no-truthy-falsy': 'warn',
|
||||
'jest/prefer-spy-on': 'error',
|
||||
'jest/require-top-level-describe': 'warn',
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 6,
|
||||
parser: 'babel-eslint'
|
||||
}
|
||||
};
|
||||
1
opentogethertube/opentogethertube/.github/CODEOWNERS
vendored
Normal file
1
opentogethertube/opentogethertube/.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* @dyc3
|
||||
50
opentogethertube/opentogethertube/.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
50
opentogethertube/opentogethertube/.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug, uncomfirmed
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Current Behavior
|
||||
<!-- REQUIRED: A clear and concise description of what the bug is. -->
|
||||
|
||||
### How To Reproduce
|
||||
<!-- REQUIRED -->
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
## Expected behavior
|
||||
<!-- REQUIRED: A clear and concise description of what you expected to happen. -->
|
||||
|
||||
### Screenshots
|
||||
<!--
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
If you don't have any screenshots, omit this section.
|
||||
-->
|
||||
|
||||
## Environment
|
||||
<!--
|
||||
Check off all applicable statements with an `x`.
|
||||
If you don't know if the statement applies, leave it blank.
|
||||
-->
|
||||
- [ ] This happens on the official site: opentogethertube.com
|
||||
- [ ] This happens using a self-hosted version.
|
||||
|
||||
### Desktop (please complete the following information):
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
### Smartphone (please complete the following information):
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
#### Additional context
|
||||
<!-- Add any other context about the problem here. -->
|
||||
20
opentogethertube/opentogethertube/.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
opentogethertube/opentogethertube/.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
23
opentogethertube/opentogethertube/.github/ISSUE_TEMPLATE/service-support-request.md
vendored
Normal file
23
opentogethertube/opentogethertube/.github/ISSUE_TEMPLATE/service-support-request.md
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: Service Support Request
|
||||
about: Request support for a video service
|
||||
title: 'Service support: X'
|
||||
labels: enhancement, service support request
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
Name of service: REQUIRED
|
||||
URL of homepage: https://example.com
|
||||
|
||||
Sample video URLs
|
||||
1. https://example.com
|
||||
2. https://example.com
|
||||
3. https://example.com
|
||||
|
||||
- [ ] Service has a public API
|
||||
- [ ] Service requires an API key
|
||||
- [ ] Service provides it's own iframe embed
|
||||
- [ ] Video playback can be controlled via javascript
|
||||
|
||||
React with 👍 if you are interested in this.
|
||||
41
opentogethertube/opentogethertube/.github/workflows-disabled/main.yml
vendored
Normal file
41
opentogethertube/opentogethertube/.github/workflows-disabled/main.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: CI
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: self-hosted
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
run: |
|
||||
echo "::set-output name=dir::$(npm config get cache)"
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ${{ steps.npm-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: npm install
|
||||
- run: npx sequelize-cli db:migrate
|
||||
env:
|
||||
NODE_ENV: test
|
||||
- run: npm run build --if-present
|
||||
- run: npm run lint-ci
|
||||
env:
|
||||
NODE_ENV: test
|
||||
- run: npm test
|
||||
env:
|
||||
CI: true
|
||||
NODE_ENV: test
|
||||
25
opentogethertube/opentogethertube/.gitignore
vendored
Normal file
25
opentogethertube/opentogethertube/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
/db
|
||||
/env
|
||||
/coverage
|
||||
/logs
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
*.log
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
21
opentogethertube/opentogethertube/.travis.yml
Normal file
21
opentogethertube/opentogethertube/.travis.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
language: node_js
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
node_js:
|
||||
- lts/*
|
||||
cache:
|
||||
directories:
|
||||
- "$HOME/.npm"
|
||||
services:
|
||||
- redis-server
|
||||
install:
|
||||
- npm ci
|
||||
before_script:
|
||||
- NODE_ENV=test npx sequelize-cli db:migrate
|
||||
- npm run build
|
||||
script:
|
||||
- npm run lint-ci
|
||||
- npm test
|
||||
after_script:
|
||||
- bash <(curl -s https://codecov.io/bash)
|
||||
14
opentogethertube/opentogethertube/.vscode/extensions.json
vendored
Normal file
14
opentogethertube/opentogethertube/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
// See http://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
|
||||
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
|
||||
|
||||
// List of extensions which should be recommended for users of this workspace.
|
||||
"recommendations": [
|
||||
"octref.vetur",
|
||||
"dbaeumer.vscode-eslint",
|
||||
],
|
||||
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
|
||||
"unwantedRecommendations": [
|
||||
|
||||
]
|
||||
}
|
||||
32
opentogethertube/opentogethertube/.vscode/launch.json
vendored
Normal file
32
opentogethertube/opentogethertube/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Program",
|
||||
"program": "${workspaceFolder}/app.js"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Jest All",
|
||||
"program": "${workspaceFolder}/node_modules/.bin/vue-cli-service",
|
||||
"args": [
|
||||
"test:unit",
|
||||
"--runInBand",
|
||||
"--config",
|
||||
"jest.config.js",
|
||||
],
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"disableOptimisticBPs": true,
|
||||
"windows": {
|
||||
"program": "${workspaceFolder}/node_modules/vue-cli-service/bin/vue-cli-service",
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
100
opentogethertube/opentogethertube/README.md
Normal file
100
opentogethertube/opentogethertube/README.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# OpenTogetherTube
|
||||
|
||||
[](https://travis-ci.com/dyc3/opentogethertube)
|
||||
[](https://codecov.io/gh/dyc3/opentogethertube)
|
||||
|
||||
The easy way to watch videos with your friends.
|
||||
|
||||
http://opentogethertube.com/
|
||||
|
||||
# Contributing
|
||||
|
||||
Contributions are welcome. The current iteration is named "Firework", and you can
|
||||
see what's currently being worked on under the "projects" tab.
|
||||
|
||||
## Setting up your dev environment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
This project targets the lastest LTS version of node.js.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Fork this repo and clone it.
|
||||
|
||||
*If you are planning to deploy this yourself, make sure you are on the `master` branch.*
|
||||
|
||||
2. In a terminal, navigate to the `opentogethertube` folder and run
|
||||
```
|
||||
npm install
|
||||
```
|
||||
3. Next you need to set up your configuration. Start by copying the example
|
||||
config in the `env` folder to a new file called `development.env`
|
||||
```
|
||||
cp env/example.env env/development.env
|
||||
```
|
||||
4. Create a new project on [Google Cloud](https://console.cloud.google.com)
|
||||
5. Add "YouTube Data API v3" and "Google Drive API" to the project
|
||||
6. Obtain a YouTube API key
|
||||
7. Obtain a Google Drive API key
|
||||
- _Not necessary if you don't plan to stream videos from Google Drive, which you probably shouldn't do anyway because Google doesn't like that._
|
||||
8. Open `env/development.env` and replace `API_KEY_GOES_HERE` with the appropriate api key.
|
||||
9. Initialize your local database.
|
||||
```
|
||||
npx sequelize-cli db:migrate
|
||||
```
|
||||
10. Install [redis](https://redis.io). This is used to store room state and user sessions across server restarts.
|
||||
|
||||
### Testing
|
||||
|
||||
To run the test suite, run
|
||||
```
|
||||
npm test
|
||||
```
|
||||
|
||||
## How to run
|
||||
|
||||
This project has 2 main components: the client and the server. You can run
|
||||
both of them simultaneously using the command
|
||||
#### Linux / Mac
|
||||
```
|
||||
npm run dev
|
||||
```
|
||||
#### Windows
|
||||
```
|
||||
npm run dev-windows
|
||||
```
|
||||
|
||||
Sometimes, you may want to run them seperately so you can use breakpoints to
|
||||
debug. Using VSCode, this is trivial.
|
||||
|
||||
To start the server: `Debug > Select "Launch Program" > Start`
|
||||
|
||||
To start the client: `npm run serve`
|
||||
|
||||
|
||||
# Deployment
|
||||
|
||||
1. Clone this repo.
|
||||
```
|
||||
git clone https://github.com/dyc3/opentogethertube.git
|
||||
```
|
||||
2. Install despendencies.
|
||||
```
|
||||
npm install
|
||||
```
|
||||
3. Build Vue files so they can be served statically.
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
4. Run the server.
|
||||
```
|
||||
npm start
|
||||
```
|
||||
|
||||
You can also specify the port the server will listen on by setting the
|
||||
`PORT` environment variable.
|
||||
|
||||
```
|
||||
PORT=8080 npm start
|
||||
```
|
||||
53
opentogethertube/opentogethertube/announce.sh
Executable file
53
opentogethertube/opentogethertube/announce.sh
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
|
||||
# This is a script to easily send announcements to all currently connected clients.
|
||||
|
||||
USAGE="Usage: ./announce.sh -k APIKEY [-d HOST] MESSAGE"
|
||||
|
||||
if [ $# == 0 ] ; then
|
||||
echo $USAGE
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
HOST="http://localhost:3000"
|
||||
APIKEY=""
|
||||
|
||||
while getopts ":d:k:h" optname; do
|
||||
case "$optname" in
|
||||
"d")
|
||||
HOST="$OPTARG"
|
||||
;;
|
||||
"k")
|
||||
APIKEY="$OPTARG"
|
||||
;;
|
||||
"h")
|
||||
echo $USAGE
|
||||
exit 0;
|
||||
;;
|
||||
"?")
|
||||
echo "Unknown option $OPTARG"
|
||||
exit 0;
|
||||
;;
|
||||
":")
|
||||
echo "No argument value for option $OPTARG"
|
||||
exit 0;
|
||||
;;
|
||||
*)
|
||||
echo "Unknown error while processing options"
|
||||
exit 0;
|
||||
;;
|
||||
esac
|
||||
done
|
||||
shift $(($OPTIND - 1))
|
||||
|
||||
if [[ -z "$NODE_ENV" ]]; then
|
||||
NODE_ENV="development"
|
||||
fi
|
||||
|
||||
if [[ "$APIKEY" == "" ]]; then
|
||||
APIKEY=$(cat ./env/$NODE_ENV.env | grep OPENTOGETHERTUBE_API_KEY | cut -d = -f 2 | tr -d '[:space:]')
|
||||
fi
|
||||
|
||||
MESSAGE="$1"
|
||||
|
||||
curl -L -X POST -d apikey="$APIKEY" -d text="$MESSAGE" $HOST/api/announce
|
||||
535
opentogethertube/opentogethertube/api.js
Normal file
535
opentogethertube/opentogethertube/api.js
Normal file
@@ -0,0 +1,535 @@
|
||||
const express = require('express');
|
||||
const rateLimit = require("express-rate-limit");
|
||||
const RateLimitStore = require('rate-limit-redis');
|
||||
const uuid = require("uuid/v4");
|
||||
const _ = require("lodash");
|
||||
const InfoExtract = require("./infoextract");
|
||||
const { getLogger } = require('./logger.js');
|
||||
const { redisClient } = require('./redisclient.js');
|
||||
|
||||
const log = getLogger("api");
|
||||
|
||||
// These strings are not allowed to be used as room names.
|
||||
const RESERVED_ROOM_NAMES = [
|
||||
"list",
|
||||
"create",
|
||||
"generate",
|
||||
];
|
||||
|
||||
const VALID_ROOM_VISIBILITY = [
|
||||
"public",
|
||||
"unlisted",
|
||||
"private",
|
||||
];
|
||||
|
||||
const VALID_ROOM_QUEUE_MODE = [
|
||||
"manual",
|
||||
"vote",
|
||||
];
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
module.exports = function(_roommanager, storage) {
|
||||
const roommanager = _roommanager;
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/room/list", (req, res) => {
|
||||
let isAuthorized = req.get("apikey") === process.env.OPENTOGETHERTUBE_API_KEY;
|
||||
if (req.get("apikey") && !isAuthorized) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "apikey is invalid",
|
||||
});
|
||||
return;
|
||||
}
|
||||
let rooms = [];
|
||||
for (const room of roommanager.rooms) {
|
||||
if (room.visibility !== "public" && !isAuthorized) {
|
||||
continue;
|
||||
}
|
||||
rooms.push({
|
||||
name: room.name,
|
||||
title: room.title,
|
||||
description: room.description,
|
||||
isTemporary: room.isTemporary,
|
||||
visibility: room.visibility,
|
||||
currentSource: room.currentSource,
|
||||
users: room.clients.length,
|
||||
});
|
||||
}
|
||||
rooms = _.sortBy(_.sortBy(rooms, "name").reverse(), "users").reverse();
|
||||
res.json(rooms);
|
||||
});
|
||||
|
||||
router.get("/room/:name", (req, res) => {
|
||||
roommanager.getOrLoadRoom(req.params.name).then(room => {
|
||||
room = _.cloneDeep(_.pick(room, [
|
||||
"name",
|
||||
"title",
|
||||
"description",
|
||||
"isTemporary",
|
||||
"visibility",
|
||||
"queueMode",
|
||||
"queue",
|
||||
"clients",
|
||||
]));
|
||||
for (let client of room.clients) {
|
||||
client.name = client.username;
|
||||
delete client.session;
|
||||
delete client.socket;
|
||||
}
|
||||
for (let video of room.queue) {
|
||||
delete video._lastVotesChanged;
|
||||
if (room.queueMode === "vote") {
|
||||
video.votes = video.votes ? video.votes.length : 0;
|
||||
}
|
||||
else {
|
||||
delete video.votes;
|
||||
}
|
||||
}
|
||||
res.json(room);
|
||||
}).catch(err => {
|
||||
if (err.name === "RoomNotFoundException") {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: "Room not found",
|
||||
});
|
||||
}
|
||||
else {
|
||||
log.error("Unhandled exception when getting room:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to get room",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let createRoomLimiter = rateLimit({ store: new RateLimitStore({ client: redisClient, resetExpiryOnChange: true, prefix: "rl:RoomCreate" }), windowMs: 60 * 60 * 1000, max: 4, message: "You are creating too many rooms. Please try again later." });
|
||||
router.post("/room/create", process.env.NODE_ENV === "production" ? createRoomLimiter : (req, res, next) => next(), async (req, res) => {
|
||||
if (!req.body.name) {
|
||||
log.info(req.body);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: "Missing argument (name)",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (RESERVED_ROOM_NAMES.includes(req.body.name)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: "Room name not allowed (reserved)",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.body.name.length < 3) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: "Room name not allowed (too short, must be at least 3 characters)",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.body.name.length > 32) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: "Room name not allowed (too long, must be at most 32 characters)",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!(/^[A-za-z0-9_-]+$/).exec(req.body.name)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: "Room name not allowed (invalid characters)",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.body.visibility && !VALID_ROOM_VISIBILITY.includes(req.body.visibility)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: "Invalid value for room visibility",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!req.body.temporary) {
|
||||
req.body.temporary = false;
|
||||
}
|
||||
if (!req.body.visibility) {
|
||||
req.body.visibility = "public";
|
||||
}
|
||||
try {
|
||||
if (req.user) {
|
||||
await roommanager.createRoom({ ...req.body, owner: req.user });
|
||||
}
|
||||
else {
|
||||
await roommanager.createRoom(req.body);
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
if (e.name === "RoomNameTakenException") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
name: e.name,
|
||||
message: "Room with that name already exists",
|
||||
},
|
||||
});
|
||||
}
|
||||
else {
|
||||
log.error(`Unable to create room: ${e} ${e.message}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
name: "Unknown",
|
||||
message: "An unknown error occured when creating this room. Try again later.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/room/generate", process.env.NODE_ENV === "production" ? createRoomLimiter : (req, res, next) => next(), async (req, res) => {
|
||||
let roomName = uuid();
|
||||
await roommanager.createRoom(roomName, true);
|
||||
res.json({
|
||||
success: true,
|
||||
room: roomName,
|
||||
});
|
||||
});
|
||||
|
||||
router.patch("/room/:name", (req, res) => {
|
||||
roommanager.getOrLoadRoom(req.params.name).then(room => {
|
||||
let filtered = _.pick(req.body, [
|
||||
"title",
|
||||
"description",
|
||||
"visibility",
|
||||
"queueMode",
|
||||
]);
|
||||
filtered = _.pickBy(filtered, n => n !== null);
|
||||
if (filtered.visibility && !VALID_ROOM_VISIBILITY.includes(filtered.visibility)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid value for room visibility",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (filtered.queueMode && !VALID_ROOM_QUEUE_MODE.includes(filtered.queueMode)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid value for room queue mode",
|
||||
});
|
||||
return;
|
||||
}
|
||||
Object.assign(room, filtered);
|
||||
if (!room.isTemporary) {
|
||||
if (req.body.claim && !room.owner) {
|
||||
if (req.user) {
|
||||
room.owner = req.user;
|
||||
}
|
||||
else {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: "Must be logged in to claim room ownership.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
storage.updateRoom(room).then(success => {
|
||||
res.status(success ? 200 : 500).json({
|
||||
success,
|
||||
});
|
||||
}).catch(err => {
|
||||
log.error(`Failed to update room: ${err} ${err.message}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
else {
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
if (err.name === "RoomNotFoundException") {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: "Room not found",
|
||||
});
|
||||
}
|
||||
else {
|
||||
log.error(`Unhandled exception when getting room: ${err} ${err.message}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to get room",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.delete("/room/:name", (req, res) => {
|
||||
roommanager.getOrLoadRoom(req.params.name).then(room => {
|
||||
roommanager.unloadRoom(room);
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
}).catch(err => {
|
||||
if (err.name === "RoomNotFoundException") {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: "Room not found",
|
||||
});
|
||||
}
|
||||
else {
|
||||
log.error(`Unhandled exception when getting room: ${err} ${err.message}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to get room",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let addToQueueLimiter = rateLimit({ store: new RateLimitStore({ client: redisClient, resetExpiryOnChange: true, prefix: "rl:QueueAdd" }), windowMs: 30 * 1000, max: 30, message: "Wait a little bit longer before adding more videos." });
|
||||
router.post("/room/:name/queue", process.env.NODE_ENV === "production" ? addToQueueLimiter : (req, res, next) => next(), (req, res) => {
|
||||
roommanager.getOrLoadRoom(req.params.name).then(room => {
|
||||
if (req.body.url) {
|
||||
room.addToQueue({ url: req.body.url }, req.session).then(success => {
|
||||
res.json({
|
||||
success,
|
||||
});
|
||||
});
|
||||
}
|
||||
else if (req.body.service && req.body.id) {
|
||||
room.addToQueue({ service: req.body.service, id: req.body.id }, req.session).then(success => {
|
||||
res.json({
|
||||
success,
|
||||
});
|
||||
});
|
||||
}
|
||||
else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid parameters",
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
if (err.name === "RoomNotFoundException") {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: "Room not found",
|
||||
});
|
||||
}
|
||||
else {
|
||||
log.error(`Unhandled exception when getting room: ${err}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to get room",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let removeFromQueueLimiter = rateLimit({ store: new RateLimitStore({ client: redisClient, resetExpiryOnChange: true, prefix: "rl:QueueRemove" }), windowMs: 30 * 1000, max: 30, message: "Wait a little bit longer before removing more videos." });
|
||||
router.delete("/room/:name/queue", process.env.NODE_ENV === "production" ? removeFromQueueLimiter : (req, res, next) => next(), (req, res) => {
|
||||
roommanager.getOrLoadRoom(req.params.name).then(room => {
|
||||
if (req.body.service && req.body.id) {
|
||||
const success = room.removeFromQueue({ service: req.body.service, id: req.body.id }, req.session);
|
||||
res.json({
|
||||
success,
|
||||
});
|
||||
}
|
||||
else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid parameters",
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
if (err.name === "RoomNotFoundException") {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: "Room not found",
|
||||
});
|
||||
}
|
||||
else {
|
||||
log.error(`Unhandled exception when getting room: ${err}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to get room",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/room/:name/vote", (req, res) => {
|
||||
roommanager.getOrLoadRoom(req.params.name).then(room => {
|
||||
if (req.body.service && req.body.id) {
|
||||
let success = room.voteVideo({ service: req.body.service, id: req.body.id }, req.session);
|
||||
res.json({
|
||||
success,
|
||||
});
|
||||
}
|
||||
else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid parameters",
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
if (err.name === "RoomNotFoundException") {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: "Room not found",
|
||||
});
|
||||
}
|
||||
else {
|
||||
log.error("Unhandled exception when getting room:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to get room",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.delete("/room/:name/vote", (req, res) => {
|
||||
roommanager.getOrLoadRoom(req.params.name).then(room => {
|
||||
if (req.body.service && req.body.id) {
|
||||
let success = room.removeVoteVideo({ service: req.body.service, id: req.body.id }, req.session);
|
||||
res.json({
|
||||
success,
|
||||
});
|
||||
}
|
||||
else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid parameters",
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
if (err.name === "RoomNotFoundException") {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: "Room not found",
|
||||
});
|
||||
}
|
||||
else {
|
||||
log.error("Unhandled exception when getting room:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to get room",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/room/:name/undo", (req, res) => {
|
||||
roommanager.getOrLoadRoom(req.params.name).then(room => {
|
||||
room.undoEvent(req.body.event);
|
||||
}).catch(err => {
|
||||
if (err.name === "RoomNotFoundException") {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: "Room not found",
|
||||
});
|
||||
}
|
||||
else {
|
||||
log.error(`Unhandled exception when getting room: ${err}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to get room",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let addPreviewLimiter = rateLimit({ store: new RateLimitStore({ client: redisClient, resetExpiryOnChange: true, prefix: "rl:AddPreview" }), windowMs: 40 * 1000, max: 20, message: "Wait a little bit longer before requesting more add previews." });
|
||||
router.get("/data/previewAdd", process.env.NODE_ENV === "production" ? addPreviewLimiter : (req, res, next) => next(), (req, res) => {
|
||||
log.info(`Getting queue add preview for ${req.query.input}`);
|
||||
InfoExtract.getAddPreview(req.query.input.trim(), { fromUser: req.ip }).then(result => {
|
||||
res.json(result);
|
||||
log.info(`Sent add preview response with ${result.length} items`);
|
||||
}).catch(err => {
|
||||
if (err.name === "UnsupportedServiceException" || err.name === "InvalidAddPreviewInputException" || err.name === "OutOfQuotaException" || err.name === "InvalidVideoIdException" || err.name === "FeatureDisabledException" || err.name === "UnsupportedMimeTypeException" || err.name === "LocalFileException" || err.name === "MissingMetadataException") {
|
||||
log.error(`Unable to get add preview: ${err.name}`);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
name: err.name,
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
else {
|
||||
log.error(`Unable to get add preview: ${err}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
name: "Unknown",
|
||||
message: "Unknown error occurred.",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/announce", (req, res) => {
|
||||
if (req.body.apikey) {
|
||||
if (req.body.apikey !== process.env.OPENTOGETHERTUBE_API_KEY) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "apikey is invalid",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "apikey was not supplied",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!req.body.text) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "text was not supplied",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
roommanager.sendAnnouncement(req.body.text);
|
||||
}
|
||||
catch (error) {
|
||||
log.error(`An unknown error occurred while sending an announcement: ${error}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Unknown, check logs",
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
143
opentogethertube/opentogethertube/app.js
Normal file
143
opentogethertube/opentogethertube/app.js
Normal file
@@ -0,0 +1,143 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { uniqueNamesGenerator } = require('unique-names-generator');
|
||||
const { getLogger, setLogLevel } = require('./logger.js');
|
||||
const passport = require('passport');
|
||||
const LocalStrategy = require('passport-local').Strategy;
|
||||
|
||||
const log = getLogger("app");
|
||||
|
||||
if (!process.env.NODE_ENV) {
|
||||
log.warn("NODE_ENV not set, assuming dev environment");
|
||||
process.env.NODE_ENV = "development";
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "example") {
|
||||
log.error("Invalid NODE_ENV! Aborting...");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config_path = path.resolve(process.cwd(), `env/${process.env.NODE_ENV}.env`);
|
||||
log.info(`Reading config from ${process.env.NODE_ENV}.env`);
|
||||
if (!fs.existsSync(config_path)) {
|
||||
log.error(`No config found! Things will break! ${config_path}`);
|
||||
}
|
||||
require('dotenv').config({ path: config_path });
|
||||
|
||||
if (process.env.LOG_LEVEL) {
|
||||
log.info(`Set log level to ${process.env.LOG_LEVEL}`);
|
||||
setLogLevel(process.env.LOG_LEVEL);
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
|
||||
const { redisClient } = require('./redisclient.js');
|
||||
|
||||
const session = require('express-session');
|
||||
let RedisStore = require('connect-redis')(session);
|
||||
let sessionOpts = {
|
||||
store: new RedisStore({ client: redisClient }),
|
||||
secret: process.env.SESSION_SECRET || "opentogethertube",
|
||||
resave: false,
|
||||
saveUninitialized: true,
|
||||
unset: 'keep',
|
||||
cookie: {
|
||||
expires: false,
|
||||
maxAge: 99999999999,
|
||||
},
|
||||
};
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
app.set('trust proxy', 1);
|
||||
sessionOpts.cookie.secure = true;
|
||||
}
|
||||
const sessions = session(sessionOpts);
|
||||
app.use(sessions);
|
||||
|
||||
const usermanager = require("./usermanager");
|
||||
passport.use(new LocalStrategy({ usernameField: 'email' }, usermanager.authCallback));
|
||||
passport.serializeUser(usermanager.serializeUser);
|
||||
passport.deserializeUser(usermanager.deserializeUser);
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
app.use(usermanager.passportErrorHandler);
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (!req.user && !req.session.username) {
|
||||
let username = uniqueNamesGenerator();
|
||||
log.debug(`Generated name for new user (on request): ${username}`);
|
||||
req.session.username = username;
|
||||
req.session.save();
|
||||
}
|
||||
else {
|
||||
log.debug("User is logged in, skipping username generation");
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
const storage = require("./storage");
|
||||
const roommanager = require("./roommanager");
|
||||
const api = require("./api")(roommanager, storage);
|
||||
roommanager.start(server, sessions);
|
||||
|
||||
const bodyParser = require('body-parser');
|
||||
app.use(bodyParser.json()); // to support JSON-encoded bodies
|
||||
app.use(bodyParser.urlencoded({ // to support URL-encoded bodies
|
||||
extended: true,
|
||||
}));
|
||||
|
||||
// Redirect urls with trailing slashes
|
||||
app.get('\\S+/$', (req, res) => {
|
||||
return res.redirect(301, req.path.slice(0, -1) + req.url.slice(req.path.length));
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (!req.path.startsWith("/api")) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
log.info(`> ${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
function serveBuiltFiles(req, res) {
|
||||
fs.readFile("dist/index.html", (err, contents) => {
|
||||
res.setHeader("Content-type", "text/html");
|
||||
if (contents) {
|
||||
res.send(contents.toString());
|
||||
}
|
||||
else {
|
||||
res.status(500).send("Failed to serve page, try again later.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.use("/api/user", usermanager.router);
|
||||
app.use("/api", api);
|
||||
if (fs.existsSync("./dist")) {
|
||||
app.use(express.static(__dirname + "/dist", false));
|
||||
app.get("/", serveBuiltFiles);
|
||||
app.get("/faq", serveBuiltFiles);
|
||||
app.get("/rooms", serveBuiltFiles);
|
||||
app.get("/room/:roomId", serveBuiltFiles);
|
||||
app.get("/privacypolicy", serveBuiltFiles);
|
||||
}
|
||||
else {
|
||||
log.warn("no dist folder found");
|
||||
}
|
||||
|
||||
//start our server
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
server.listen(process.env.PORT || 3000, () => {
|
||||
log.info(`Server started on port ${server.address().port}`);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
app,
|
||||
redisClient,
|
||||
server,
|
||||
};
|
||||
8
opentogethertube/opentogethertube/babel.config.js
Normal file
8
opentogethertube/opentogethertube/babel.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
presets: ['@vue/app'],
|
||||
env: {
|
||||
test: {
|
||||
plugins: ['@babel/plugin-transform-modules-commonjs'],
|
||||
},
|
||||
},
|
||||
};
|
||||
12
opentogethertube/opentogethertube/codecov.yml
Normal file
12
opentogethertube/opentogethertube/codecov.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
comment: false
|
||||
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: 0%
|
||||
threshold: 10%
|
||||
patch:
|
||||
default:
|
||||
target: 0%
|
||||
threshold: 10%
|
||||
50
opentogethertube/opentogethertube/common/video.js
Normal file
50
opentogethertube/opentogethertube/common/video.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const _ = require("lodash");
|
||||
|
||||
/**
|
||||
* Represents a video on any video providing service.
|
||||
*/
|
||||
class Video {
|
||||
constructor(args=undefined) {
|
||||
this.service = null;
|
||||
this.id = null;
|
||||
this.url = null;
|
||||
this.title = null;
|
||||
this.description = null;
|
||||
this.thumbnail = null;
|
||||
this.length = null;
|
||||
this.mime = null;
|
||||
if (args) {
|
||||
Object.assign(this, args);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line array-bracket-newline
|
||||
if (["youtube", "vimeo", "dailymotion"].includes(this.service)) {
|
||||
delete this.mime;
|
||||
delete this.url;
|
||||
}
|
||||
// eslint-disable-next-line array-bracket-newline
|
||||
else if (["googledrive"].includes(this.service)) {
|
||||
delete this.description;
|
||||
delete this.url;
|
||||
}
|
||||
else if (["direct"].includes(this.service)) {
|
||||
delete this.id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges together 2 video's metadata, favoring video B's info if there is a conflict. Service and ID must match.
|
||||
* @param {Video} a A video object
|
||||
* @param {Video} b Another video object
|
||||
* @returns A new video object
|
||||
*/
|
||||
static merge(a, b) {
|
||||
if (a.service !== b.service || a.id !== b.id) {
|
||||
throw new Error("Both video's service and id must match in order to merge");
|
||||
}
|
||||
|
||||
return Object.assign(_.cloneDeep(a), _.pickBy(b, x => x));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Video;
|
||||
39
opentogethertube/opentogethertube/config/config.js
Normal file
39
opentogethertube/opentogethertube/config/config.js
Normal file
@@ -0,0 +1,39 @@
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
let rootDir = path.resolve(__dirname + "/..");
|
||||
if (!fs.existsSync(path.join(rootDir, "./db"))) {
|
||||
fs.mkdirSync(path.join(rootDir, "./db"));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
"development": {
|
||||
"username": "root",
|
||||
"password": null,
|
||||
"database": "db_opentogethertube_dev",
|
||||
"host": "127.0.0.1",
|
||||
"dialect": "sqlite",
|
||||
"operatorsAliases": false,
|
||||
"storage": "db/dev.sqlite",
|
||||
},
|
||||
"test": {
|
||||
"username": "root",
|
||||
"password": null,
|
||||
"database": "db_opentogethertube_test",
|
||||
"host": "127.0.0.1",
|
||||
"dialect": "sqlite",
|
||||
"operatorsAliases": false,
|
||||
"storage": "db/test.sqlite",
|
||||
"logging": false,
|
||||
},
|
||||
"production": {
|
||||
"username": "ott",
|
||||
"password": process.env.DB_PASSWORD,
|
||||
"database": "db_opentogethertube_prod",
|
||||
"host": "127.0.0.1",
|
||||
"dialect": "postgres",
|
||||
"operatorsAliases": false,
|
||||
"logging": false,
|
||||
},
|
||||
};
|
||||
22
opentogethertube/opentogethertube/ffprobe.js
Normal file
22
opentogethertube/opentogethertube/ffprobe.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const util = require('util');
|
||||
const ffprobeInstaller = require('@ffprobe-installer/ffprobe');
|
||||
const { getLogger } = require("./logger.js");
|
||||
const child_process = require('child_process');
|
||||
|
||||
const log = getLogger("infoextract.ffprobe");
|
||||
const FFPROBE_PATH = process.env.FFPROBE_PATH || ffprobeInstaller.path;
|
||||
const exec = util.promisify(child_process.exec);
|
||||
|
||||
log.debug(`ffprobe installed at ${FFPROBE_PATH}`);
|
||||
|
||||
module.exports = {
|
||||
async getFileInfo(uri) {
|
||||
log.debug(`Grabbing file info from ${uri}`);
|
||||
const { error, stdout } = await exec(`${FFPROBE_PATH} -v quiet -i ${uri} -print_format json -show_streams`);
|
||||
if (error) {
|
||||
log.error(`Failed to probe file info: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
return JSON.parse(stdout);
|
||||
},
|
||||
};
|
||||
6
opentogethertube/opentogethertube/husky.config.js
Normal file
6
opentogethertube/opentogethertube/husky.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
"hooks": {
|
||||
"pre-commit": "npm run lint",
|
||||
"pre-push": "npm test",
|
||||
},
|
||||
};
|
||||
999
opentogethertube/opentogethertube/infoextract.js
Normal file
999
opentogethertube/opentogethertube/infoextract.js
Normal file
@@ -0,0 +1,999 @@
|
||||
const axios = require("axios");
|
||||
const url = require("url");
|
||||
const querystring = require('querystring');
|
||||
const moment = require("moment");
|
||||
const _ = require("lodash");
|
||||
const storage = require("./storage");
|
||||
const Video = require("./common/video.js");
|
||||
const { getLogger } = require("./logger.js");
|
||||
const { redisClient } = require('./redisclient.js');
|
||||
const ffprobe = require('./ffprobe.js');
|
||||
|
||||
const log = getLogger("infoextract");
|
||||
|
||||
const YOUTUBE_API_URL = "https://www.googleapis.com/youtube/v3";
|
||||
const ADD_PREVIEW_SEARCH_MIN_LENGTH = 3;
|
||||
const YtApi = axios.create({
|
||||
baseURL: YOUTUBE_API_URL,
|
||||
});
|
||||
const YtFallbackApi = axios.create();
|
||||
const VIMEO_OEMBED_API_URL = "https://vimeo.com/api/oembed.json";
|
||||
const VimeoApi = axios.create();
|
||||
const DAILYMOTION_API_URL = "https://api.dailymotion.com";
|
||||
// const DAILYMOTION_OEMBED_API_URL = "http://www.dailymotion.com/services/oembed";
|
||||
const DailymotionApi = axios.create({
|
||||
baseURL: DAILYMOTION_API_URL,
|
||||
});
|
||||
const GOOGLE_DRIVE_API_URL = "https://www.googleapis.com/drive/v3";
|
||||
const GoogleDriveApi = axios.create({
|
||||
baseURL: GOOGLE_DRIVE_API_URL,
|
||||
});
|
||||
|
||||
class UnsupportedServiceException extends Error {
|
||||
constructor(hostname) {
|
||||
super(`The service at "${hostname}" is not yet supported.`);
|
||||
this.name = "UnsupportedServiceException";
|
||||
}
|
||||
}
|
||||
|
||||
class InvalidAddPreviewInputException extends Error {
|
||||
constructor() {
|
||||
super(`Your search query must at least ${ADD_PREVIEW_SEARCH_MIN_LENGTH} characters, or supply a Youtube video, playlist, or channel link.`);
|
||||
this.name = "InvalidAddPreviewInputException";
|
||||
}
|
||||
}
|
||||
|
||||
class OutOfQuotaException extends Error {
|
||||
constructor(service) {
|
||||
if (service === "youtube") {
|
||||
super(`We don't have enough Youtube API quota to complete the request. We currently have a limit of 50,000 quota per day.`);
|
||||
}
|
||||
else if (service === "googledrive") {
|
||||
super(`We don't have enough Google Drive API quota to complete the request.`);
|
||||
}
|
||||
else {
|
||||
super(`We don't have enough API quota to complete the request. Try again later.`);
|
||||
}
|
||||
this.name = "OutOfQuotaException";
|
||||
}
|
||||
}
|
||||
|
||||
class InvalidVideoIdException extends Error {
|
||||
constructor(service, id) {
|
||||
super(`"${id} is an invalid ${service} video ID."`);
|
||||
this.name = "InvalidVideoIdException";
|
||||
}
|
||||
}
|
||||
|
||||
class FeatureDisabledException extends Error {
|
||||
constructor(reason) {
|
||||
super(`Sorry, this feature is disabled: ${reason}`);
|
||||
this.name = "FeatureDisabledException";
|
||||
}
|
||||
}
|
||||
|
||||
class UnsupportedMimeTypeException extends Error {
|
||||
constructor(mime) {
|
||||
if (mime.startsWith("video/")) {
|
||||
super(`Files that are ${mime} are not supported.`);
|
||||
}
|
||||
else {
|
||||
super(`The requested resource was not actually a video, it was a ${mime}`);
|
||||
}
|
||||
this.name = "UnsupportedMimeTypeException";
|
||||
}
|
||||
}
|
||||
|
||||
class LocalFileException extends Error {
|
||||
constructor() {
|
||||
super(`The video URL provided references a local file. It is not possible to play videos on your computer, nor files located on the server. Videos must be hosted somewhere all users in the room can access.`);
|
||||
this.name = "LocalFileException";
|
||||
}
|
||||
}
|
||||
|
||||
class MissingMetadataException extends Error {
|
||||
constructor() {
|
||||
super(`The video provided is missing metadata required to let playback work correctly (probably length). For best results, reencode the video as an mp4.`);
|
||||
this.name = "MissingMetadataException";
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.DEBUG_FAKE_YOUTUBE_OUT_OF_QUOTA) {
|
||||
YtApi.get = () => Promise.reject({ response: { status: 403 } });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
YtApi,
|
||||
YtFallbackApi,
|
||||
VimeoApi,
|
||||
DailymotionApi,
|
||||
redisClient,
|
||||
ffprobe,
|
||||
|
||||
/**
|
||||
* Gets all necessary information needed to represent a video. Handles
|
||||
* local caching and obtaining missing data from external sources.
|
||||
* @param {string} service The service that hosts the source video.
|
||||
* @param {string} id The id of the video on the given service.
|
||||
* @return {Promise<Video>} Video object
|
||||
*/
|
||||
getVideoInfo(service, id) {
|
||||
if (service === "youtube") {
|
||||
if (!(/^[A-za-z0-9_-]+$/).exec(id)) {
|
||||
return Promise.reject(new InvalidVideoIdException(service, id));
|
||||
}
|
||||
}
|
||||
else if (service === "vimeo") {
|
||||
if (!(/^[0-9]+$/).exec(id)) {
|
||||
return Promise.reject(new InvalidVideoIdException(service, id));
|
||||
}
|
||||
}
|
||||
else if (service === "dailymotion") {
|
||||
if (!(/^[A-za-z0-9]+$/).exec(id)) {
|
||||
return Promise.reject(new InvalidVideoIdException(service, id));
|
||||
}
|
||||
}
|
||||
else if (service === "googledrive") {
|
||||
if (!(/^[A-za-z0-9_-]+$/).exec(id)) {
|
||||
return Promise.reject(new InvalidVideoIdException(service, id));
|
||||
}
|
||||
}
|
||||
else if (service === "direct") {
|
||||
return this.getVideoInfoDirect(id);
|
||||
}
|
||||
|
||||
return storage.getVideoInfo(service, id).then(result => {
|
||||
let video = _.cloneDeep(result);
|
||||
let missingInfo = storage.getVideoInfoFields(video.service).filter(p => !video.hasOwnProperty(p));
|
||||
if (missingInfo.length === 0) {
|
||||
video = new Video(video);
|
||||
if (video.service === "googledrive" && !this.isSupportedMimeType(video.mime)) {
|
||||
throw new UnsupportedMimeTypeException(video.mime);
|
||||
}
|
||||
return video;
|
||||
}
|
||||
|
||||
log.warn(`MISSING INFO for ${video.service}:${video.id}: ${missingInfo}`);
|
||||
|
||||
if (video.service === "youtube") {
|
||||
return this.getVideoInfoYoutube([video.id], missingInfo).then(result => {
|
||||
return Video.merge(video, result[video.id]);
|
||||
}).catch(err => {
|
||||
if (err.name === "OutOfQuotaException") {
|
||||
log.error("Failed to get youtube video info: Out of quota");
|
||||
if (missingInfo.length < storage.getVideoInfoFields(video.service).length) {
|
||||
log.warn(`Returning cached results for ${video.service}:${video.id}`);
|
||||
return result;
|
||||
}
|
||||
else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
else {
|
||||
log.error(`Failed to get youtube video info: ${err}`);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (video.service === "vimeo") {
|
||||
return this.getVideoInfoVimeo(video.id);
|
||||
}
|
||||
else if (video.service === "dailymotion") {
|
||||
return this.getVideoInfoDailymotion(video.id);
|
||||
}
|
||||
else if (video.service === "googledrive") {
|
||||
return this.getVideoInfoGoogleDrive(video.id).then(result => {
|
||||
return Video.merge(video, result);
|
||||
}).catch(err => {
|
||||
if (err.name === "OutOfQuotaException") {
|
||||
log.error("Failed to get google drive file info: Out of quota");
|
||||
if (missingInfo.length < storage.getVideoInfoFields(video.service).length) {
|
||||
log.warn(`Returning cached results for ${video.service}:${video.id}`);
|
||||
return result;
|
||||
}
|
||||
else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
else {
|
||||
log.error(`Failed to get google drive file info: ${err}`);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
log.error(`Failed to get video metadata: ${err}`);
|
||||
throw err;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets all necessary information needed to represent all videos in the
|
||||
* given list. Handles local caching and obtaining missing data from
|
||||
* external sources.
|
||||
*
|
||||
* This also optimizes the number of requests made to external sources.
|
||||
* @param {Array.<Video|Object>} videos
|
||||
* @returns {Promise.<Array.<Video>>}
|
||||
*/
|
||||
getManyVideoInfo(videos) {
|
||||
let grouped = _.groupBy(videos, "service");
|
||||
let retrievalPromises = [];
|
||||
for (let service in grouped) {
|
||||
let retrievalPromise = storage.getManyVideoInfo(grouped[service]).then(serviceVideos => {
|
||||
// group by missing info
|
||||
// WARNING: Arrays can't be used as keys, so the array of strings gets turned in to a string. May cause issues?
|
||||
let groupedServiceVideos = _.groupBy(serviceVideos, video => storage.getVideoInfoFields(service).filter(p => !video.hasOwnProperty(p)));
|
||||
|
||||
if (service === "youtube") {
|
||||
let promises = [];
|
||||
for (let missingInfo in groupedServiceVideos) {
|
||||
let missingInfoGroup = groupedServiceVideos[missingInfo];
|
||||
if (!missingInfo) {
|
||||
promises.push(Promise.resolve(missingInfoGroup));
|
||||
continue;
|
||||
}
|
||||
let promise = this.getVideoInfoYoutube(missingInfoGroup.map(video => video.id), missingInfo).then(results => {
|
||||
return missingInfoGroup.filter(video => results[video.id]).map(video => {
|
||||
return Video.merge(video, results[video.id]);
|
||||
});
|
||||
});
|
||||
promises.push(promise);
|
||||
}
|
||||
return Promise.all(promises);
|
||||
}
|
||||
else {
|
||||
log.error(`Unknown service: ${service}`);
|
||||
return Promise.resolve(serviceVideos);
|
||||
}
|
||||
});
|
||||
retrievalPromises.push(retrievalPromise);
|
||||
}
|
||||
return Promise.all(retrievalPromises).then(results => {
|
||||
results = _.flattenDeep(results);
|
||||
|
||||
// ensure the original order is preserved
|
||||
let finalResults = [];
|
||||
for (let result of results) {
|
||||
let idx = _.findIndex(videos, {
|
||||
service: result.service,
|
||||
id: result.id,
|
||||
});
|
||||
finalResults[idx] = new Video(result);
|
||||
}
|
||||
return finalResults;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets a list of videos to make an add preview.
|
||||
* @param {string} input User input
|
||||
* @param {Object} options Optional extra parameters
|
||||
* @param {string} options.fromUser A unique identifier indicating the user that made the request for the add preview. Should not contain sensitive information, because it will be sent to the youtube API as `quotaUser`.
|
||||
* @returns {Promise.<Array<Video>>}
|
||||
* @throws UnsupportedServiceException
|
||||
* @throws InvalidAddPreviewInputException
|
||||
* @throws OutOfQuotaException
|
||||
*/
|
||||
getAddPreview(input, options={}) {
|
||||
const service = this.getService(input);
|
||||
|
||||
let id = null;
|
||||
|
||||
const urlParsed = url.parse(input.trim());
|
||||
const queryParams = querystring.parse(urlParsed.query);
|
||||
if (service == "youtube" && (queryParams["v"] || urlParsed.host === "youtu.be")) {
|
||||
id = this.getVideoIdYoutube(input);
|
||||
}
|
||||
else if (service === "vimeo") {
|
||||
id = this.getVideoIdVimeo(input);
|
||||
}
|
||||
else if (service === "dailymotion") {
|
||||
id = this.getVideoIdDailymotion(input);
|
||||
}
|
||||
else if (service === "googledrive") {
|
||||
id = this.getVideoIdGoogleDrive(input);
|
||||
}
|
||||
|
||||
if (urlParsed.host && service !== "youtube" && service !== "vimeo" && service !== "dailymotion" && service !== "googledrive" && service !== "direct") {
|
||||
return Promise.reject(new UnsupportedServiceException(urlParsed.host));
|
||||
}
|
||||
else if (!urlParsed.host) {
|
||||
if (process.env.ENABLE_YOUTUBE_SEARCH) {
|
||||
if (input.length < ADD_PREVIEW_SEARCH_MIN_LENGTH) {
|
||||
return Promise.reject(new InvalidAddPreviewInputException());
|
||||
}
|
||||
return this.searchYoutube(input, options)
|
||||
.then(searchResults => this.getManyVideoInfo(searchResults))
|
||||
.catch(err => {
|
||||
if (err.name === "OutOfQuotaException") {
|
||||
log.error("Failed to search youtube for add preview: Out of quota");
|
||||
throw new OutOfQuotaException("youtube");
|
||||
}
|
||||
else {
|
||||
log.error(`Failed to search youtube for add preview: ${err}`);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
return Promise.reject(new FeatureDisabledException("Youtube searches have been disabled by the administrator. See dyc3/opentogethertube#226 for more information."));
|
||||
}
|
||||
}
|
||||
|
||||
if (service === "youtube" && queryParams["list"]) {
|
||||
// there is a playlist associated with this link
|
||||
log.info("playlist found");
|
||||
return new Promise((resolve, reject) => {
|
||||
this.getPlaylistYoutube(queryParams["list"]).then(playlist => {
|
||||
log.info(`Found ${playlist.length} videos in playlist`);
|
||||
this.getManyVideoInfo(playlist).then(previews => {
|
||||
if (id) {
|
||||
let highlighted = false;
|
||||
for (let preview of previews) {
|
||||
if (preview && preview.id === id) {
|
||||
preview.highlight = true;
|
||||
highlighted = true;
|
||||
}
|
||||
}
|
||||
if (!highlighted) {
|
||||
// Guarentee video is in add preview
|
||||
this.getVideoInfo(service, id).then(video => {
|
||||
video.highlight = true;
|
||||
resolve(_.concat([video], previews));
|
||||
}).catch(() => {
|
||||
resolve(previews);
|
||||
});
|
||||
}
|
||||
else {
|
||||
resolve(previews);
|
||||
}
|
||||
}
|
||||
else {
|
||||
resolve(previews);
|
||||
}
|
||||
});
|
||||
}).catch(err => {
|
||||
if (queryParams.v) {
|
||||
log.warn(`Playlist does not exist, retreiving video...`);
|
||||
return this.getVideoInfo(service, queryParams.v).then(video => {
|
||||
resolve([video]);
|
||||
}).catch(err => {
|
||||
log.error(`Failed to compile add preview: error getting video: ${err}`);
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
else {
|
||||
if (err.response && err.response.status === 403) {
|
||||
log.error("Failed to compile add preview: error getting playlist: Out of quota");
|
||||
reject(new OutOfQuotaException("youtube"));
|
||||
}
|
||||
else {
|
||||
log.error(`Failed to compile add preview: error getting playlist: ${err}`);
|
||||
reject(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
else if (service === "youtube" && (urlParsed.path.startsWith('/user') || urlParsed.path.startsWith('/channel'))) {
|
||||
log.info('channel found');
|
||||
const channelData = {};
|
||||
const channelId = urlParsed.path.slice(urlParsed.path.lastIndexOf('/') + 1);
|
||||
if (urlParsed.path.startsWith('/channel/')) {
|
||||
channelData.channel = channelId;
|
||||
}
|
||||
else {
|
||||
channelData.user = channelId;
|
||||
}
|
||||
return this.getChanneInfoYoutube(channelData)
|
||||
.then(newestVideos => this.getManyVideoInfo(newestVideos))
|
||||
.catch(err => log.error(`Error getting channel info: ${err}`));
|
||||
}
|
||||
else if (service === "googledrive" && urlParsed.path.startsWith("/drive")) {
|
||||
let folderId = this.getFolderIdGoogleDrive(input);
|
||||
log.info(`google drive folder found: ${folderId}`);
|
||||
return this.getFolderGoogleDrive(folderId)
|
||||
// .then(videos => this.getManyVideoInfo(videos))
|
||||
.catch(err => log.error(`Error getting google drive info: ${err}`));
|
||||
}
|
||||
else if (service === "direct") {
|
||||
return this.getVideoInfo(service, input).then(video => {
|
||||
return [video];
|
||||
});
|
||||
}
|
||||
else {
|
||||
let video = new Video({
|
||||
service: service,
|
||||
id: id,
|
||||
title: id,
|
||||
});
|
||||
return this.getVideoInfo(video.service, video.id).then(result => {
|
||||
return Video.merge(video, result);
|
||||
}).catch(err => {
|
||||
log.error(`Failed to get video info ${err}`);
|
||||
throw err;
|
||||
}).then(result => {
|
||||
return [result];
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
getService(link) {
|
||||
if (typeof link !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let srcUrl = url.parse(link);
|
||||
if (srcUrl.host === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (srcUrl.host.endsWith("youtube.com") || srcUrl.host.endsWith("youtu.be")) {
|
||||
return "youtube";
|
||||
}
|
||||
else if (srcUrl.host.endsWith("vimeo.com")) {
|
||||
return "vimeo";
|
||||
}
|
||||
else if (srcUrl.host.endsWith("dailymotion.com") || srcUrl.host.endsWith("dai.ly")) {
|
||||
return "dailymotion";
|
||||
}
|
||||
else if (srcUrl.host.endsWith("drive.google.com")) {
|
||||
return "googledrive";
|
||||
}
|
||||
else if (/\/*\.(mp4|webm|flv|mkv)$/.exec(srcUrl.path.split("?")[0])) {
|
||||
return "direct";
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
isSupportedMimeType(mime) {
|
||||
return !!/^video\/(?!x-flv)(?!x-matroska)[a-z0-9-]+$/.exec(mime);
|
||||
},
|
||||
|
||||
/* YOUTUBE */
|
||||
|
||||
/**
|
||||
* Gets the Youtube video id from the link.
|
||||
* @param {string} link Youtube URL
|
||||
* @returns {string|null} Youtube video id, or null if invalid
|
||||
*/
|
||||
getVideoIdYoutube(link) {
|
||||
let urlParsed = url.parse(link);
|
||||
if (urlParsed.host.endsWith("youtu.be")) {
|
||||
return urlParsed.path.replace("/", "").split("?")[0].trim();
|
||||
}
|
||||
else {
|
||||
let query = querystring.parse(urlParsed.query);
|
||||
if (query["v"]) {
|
||||
return query["v"].trim();
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getVideoInfoYoutube(ids, onlyProperties=null) {
|
||||
if (!Array.isArray(ids)) {
|
||||
return Promise.reject(new Error("`ids` must be an array of youtube video IDs."));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let parts = [];
|
||||
if (onlyProperties !== null) {
|
||||
if (onlyProperties.includes("title") || onlyProperties.includes("description") || onlyProperties.includes("thumbnail")) {
|
||||
parts.push("snippet");
|
||||
}
|
||||
if (onlyProperties.includes("length")) {
|
||||
parts.push("contentDetails");
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
log.error(`onlyProperties must have valid values or be null! Found ${onlyProperties}`);
|
||||
reject(new Error("onlyProperties must have valid values or be null!"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
else {
|
||||
parts = [
|
||||
"snippet",
|
||||
"contentDetails",
|
||||
];
|
||||
}
|
||||
log.silly(`Requesting ${parts.length} parts for ${ids.length} videos`);
|
||||
YtApi.get(`/videos?key=${process.env.YOUTUBE_API_KEY}&part=${parts.join(",")}&id=${ids.join(",")}`).then(res => {
|
||||
let results = {};
|
||||
for (let i = 0; i < res.data.items.length; i++) {
|
||||
let item = res.data.items[i];
|
||||
let video = new Video({
|
||||
service: "youtube",
|
||||
id: item.id,
|
||||
});
|
||||
if (item.snippet) {
|
||||
video.title = item.snippet.title;
|
||||
video.description = item.snippet.description;
|
||||
if (item.snippet.thumbnails) {
|
||||
if (item.snippet.thumbnails.medium) {
|
||||
video.thumbnail = item.snippet.thumbnails.medium.url;
|
||||
}
|
||||
else {
|
||||
video.thumbnail = item.snippet.thumbnails.default.url;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (item.contentDetails) {
|
||||
video.length = moment.duration(item.contentDetails.duration).asSeconds();
|
||||
}
|
||||
results[item.id] = video;
|
||||
}
|
||||
|
||||
// update cache
|
||||
// for (let video of _.values(results)) {
|
||||
// storage.updateVideoInfo(video);
|
||||
// }
|
||||
// resolve(results);
|
||||
|
||||
storage.updateManyVideoInfo(_.values(results)).then(() => {
|
||||
resolve(results);
|
||||
}).catch(err => {
|
||||
log.error(`Failed to cache video info, will return metadata anyway: ${err}`);
|
||||
resolve(results);
|
||||
});
|
||||
}).catch(err => {
|
||||
if (err.response && err.response.status === 403) {
|
||||
if (!onlyProperties || onlyProperties.includes("length")) {
|
||||
log.warn(`Attempting youtube fallback method for ${ids.length} videos`);
|
||||
let getLengthPromises = ids.map(id => this.getVideoLengthYoutube_Fallback(`https://youtube.com/watch?v=${id}`));
|
||||
Promise.all(getLengthPromises).then(results => {
|
||||
let videos = _.zip(ids, results).map(i => new Video({
|
||||
service: "youtube",
|
||||
id: i[0],
|
||||
length: i[1],
|
||||
// HACK: we can guess what the thumbnail url is, but this could possibly change without warning
|
||||
thumbnail: `https://i.ytimg.com/vi/${i[0]}/default.jpg`,
|
||||
}));
|
||||
let finalResult = _.zipObject(ids, videos);
|
||||
storage.updateManyVideoInfo(videos).then(() => {
|
||||
resolve(finalResult);
|
||||
}).catch(err => {
|
||||
log.error(`Failed to cache video info, will return metadata anyway: ${err}`);
|
||||
resolve(finalResult);
|
||||
});
|
||||
}).catch(err => {
|
||||
log.error(`Youtube fallback failed ${err}`);
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
else {
|
||||
log.warn("No fallback method for requested metadata properties");
|
||||
reject(new OutOfQuotaException("youtube"));
|
||||
}
|
||||
}
|
||||
else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async getVideoLengthYoutube_Fallback(url) {
|
||||
let res = await YtFallbackApi.get(url);
|
||||
let regexs = [
|
||||
/length_seconds":"\d+/, /lengthSeconds\\":\\"\d+/,
|
||||
];
|
||||
for (let r = 0; r < regexs.length; r++) {
|
||||
let matches = res.data.match(regexs[r]);
|
||||
if (matches == null) {
|
||||
continue;
|
||||
}
|
||||
for (let m = 0; m < matches.length; m++) {
|
||||
const match = matches[m];
|
||||
let extracted = match.split(":")[1].substring(r == 0 ? 1 : 2);
|
||||
log.silly(`MATCH ${match}`);
|
||||
log.debug(`EXTRACTED ${extracted}`);
|
||||
return parseInt(extracted);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
getPlaylistYoutube(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Unfortunately, we have to request the `snippet` part in order to get the youtube video ids
|
||||
// The `id` part just gives playlistItemIds
|
||||
// The `contentDetails` part just gives the video id and the date the video was published.
|
||||
// Youtube API docs makes it unclear whether snippet or contentDetails costs more api quota,
|
||||
// so just stick with snippet i guess?
|
||||
YtApi.get(`/playlistItems?key=${process.env.YOUTUBE_API_KEY}&part=snippet&playlistId=${id}&maxResults=30`).then(res => {
|
||||
let results = [];
|
||||
for (let i = 0; i < res.data.items.length; i++) {
|
||||
let item = res.data.items[i];
|
||||
let video = new Video({
|
||||
service: "youtube",
|
||||
id: item.snippet.resourceId.videoId,
|
||||
title: item.snippet.title,
|
||||
description: item.snippet.description,
|
||||
});
|
||||
if (item.snippet.thumbnails) {
|
||||
if (item.snippet.thumbnails.medium) {
|
||||
video.thumbnail = item.snippet.thumbnails.medium.url;
|
||||
}
|
||||
else {
|
||||
video.thumbnail = item.snippet.thumbnails.default.url;
|
||||
}
|
||||
}
|
||||
results.push(video);
|
||||
}
|
||||
|
||||
// update cache
|
||||
// for (let video of results) {
|
||||
// storage.updateVideoInfo(video);
|
||||
// }
|
||||
// resolve(results);
|
||||
|
||||
storage.updateManyVideoInfo(results).then(() => {
|
||||
resolve(results);
|
||||
});
|
||||
}).catch(err => {
|
||||
if (err.response && err.response.status === 403) {
|
||||
reject(new OutOfQuotaException("youtube"));
|
||||
}
|
||||
else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async getChanneInfoYoutube(channelData) {
|
||||
// TODO: maybe use relational db for this cache instead?
|
||||
let cachedPlaylistId = await new Promise((resolve, reject) => {
|
||||
redisClient.get(`ytchannel:${_.keys(channelData)[0]}:${_.values(channelData)[0]}`, (err, value) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
if (!value) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
resolve(value);
|
||||
});
|
||||
});
|
||||
if (cachedPlaylistId) {
|
||||
// use the cached playlist id
|
||||
log.info("Using cached uploads playlist id");
|
||||
return this.getPlaylistYoutube(cachedPlaylistId);
|
||||
}
|
||||
|
||||
return YtApi.get('/channels' +
|
||||
`?key=${process.env.YOUTUBE_API_KEY}&` +
|
||||
'part=contentDetails&' +
|
||||
`${Object.keys(channelData)[0] === 'channel' ? 'id' : 'forUsername'}=${Object.values(channelData)[0]}`
|
||||
//if the link passed is a channel link, ie: /channel/$CHANNEL_ID, then the id filter must be used
|
||||
//on the other hand, a user link requires the forUsername filter
|
||||
).then(res => {
|
||||
let uploadsPlaylistId = res.data.items[0].contentDetails.relatedPlaylists.uploads;
|
||||
redisClient.set(`ytchannel:${_.keys(channelData)[0]}:${_.values(channelData)[0]}`, uploadsPlaylistId, err => {
|
||||
if (err) {
|
||||
log.error(`Failed to cache channel uploads playlist: ${err}`);
|
||||
}
|
||||
else {
|
||||
log.info(`Cached channel uploads playlist: ytchannel:${_.keys(channelData)[0]}:${_.values(channelData)[0]}`);
|
||||
}
|
||||
});
|
||||
if (channelData.user) {
|
||||
// we can add a cache entry for the channel id as well.
|
||||
let channelId = res.data.items[0].id;
|
||||
redisClient.set(`ytchannel:channel:${channelId}`, uploadsPlaylistId, err => {
|
||||
if (err) {
|
||||
log.error(`Failed to cache channel uploads playlist: ${err}`);
|
||||
}
|
||||
else {
|
||||
log.info(`Cached channel uploads playlist: ytchannel:channel:${channelId}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
return this.getPlaylistYoutube(uploadsPlaylistId);
|
||||
}).catch(err => {
|
||||
if (err.response && err.response.status === 403) {
|
||||
log.error(`Error when getting channel upload playlist ID: Out of Quota`);
|
||||
throw new OutOfQuotaException("youtube");
|
||||
}
|
||||
else {
|
||||
log.error(`Error when getting channel upload playlist ID: ${err}`);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Search Youtube for videos most related to the user's query
|
||||
* @param {string} query The user's search query
|
||||
* @param {Object} options Optional extra parameters
|
||||
* @param {string|undefined} [options.fromUser=undefined] A unique identifier indicating the user that made the request for the add preview. Should not contain sensitive information, because it will be sent to the youtube API as `quotaUser`.
|
||||
* @param {Number} [options.maxResults=8] The max number of results to return from the query.
|
||||
* @returns {Array<Video>} An array of videos with only service and id set.
|
||||
*/
|
||||
async searchYoutube(query, options={}) {
|
||||
let cachedResults = await new Promise((resolve, reject) => {
|
||||
redisClient.get(`search:${query}`, (err, value) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
if (!value) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
resolve(JSON.parse(value));
|
||||
});
|
||||
});
|
||||
if (cachedResults) {
|
||||
log.info("Using cached results for youtube search");
|
||||
return cachedResults;
|
||||
}
|
||||
|
||||
options = _.defaults(options, {
|
||||
maxResults: 8,
|
||||
});
|
||||
let queryParams = {
|
||||
key: process.env.YOUTUBE_API_KEY,
|
||||
part: "id",
|
||||
type: "video",
|
||||
maxResults: options.maxResults,
|
||||
safeSearch: "none",
|
||||
videoEmbeddable: true,
|
||||
videoSyndicated: true,
|
||||
q: query,
|
||||
};
|
||||
if (options.fromUser) {
|
||||
queryParams.quotaUser = options.fromUser;
|
||||
}
|
||||
return YtApi.get(`/search?${querystring.stringify(queryParams)}`).then(res => {
|
||||
let results = res.data.items.map(searchResult => new Video({
|
||||
service: "youtube",
|
||||
id: searchResult.id.videoId,
|
||||
}));
|
||||
// results expire in 24 hours
|
||||
redisClient.set(`search:${query}`, JSON.stringify(results), "EX", 60 * 60 * 24, err => {
|
||||
if (err) {
|
||||
log.error(`Failed to cache search results: ${err}`);
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}).catch(err => {
|
||||
if (err.response && err.response.status === 403) {
|
||||
throw new OutOfQuotaException("youtube");
|
||||
}
|
||||
else {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/* VIMEO */
|
||||
|
||||
/**
|
||||
* Gets the Vimeo video id from the link.
|
||||
* @param {string} link Vimeo URL
|
||||
* @returns {string} Vimeo video id
|
||||
*/
|
||||
getVideoIdVimeo(link) {
|
||||
let urlParsed = url.parse(link);
|
||||
return urlParsed.path.split("/").slice(-1)[0].split("?")[0].trim();
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets video metadata for vimeo videos.
|
||||
*
|
||||
* https://developer.vimeo.com/api/oembed/videos#embedding-a-video-with-oembed
|
||||
* https://developer.vimeo.com/api/reference/videos#get_video
|
||||
* @param {string} id The video id on vimeo
|
||||
* @returns {Promise<Video>|null} Video with metadata, null if it fails to get metadata
|
||||
*/
|
||||
getVideoInfoVimeo(id) {
|
||||
// HACK: This API method doesn't require us to use authentication, but it gives us somewhat low res thumbnail urls
|
||||
return VimeoApi.get(`${VIMEO_OEMBED_API_URL}?url=https://vimeo.com/${id}`).then(res => {
|
||||
let video = new Video({
|
||||
service: "vimeo",
|
||||
id,
|
||||
title: res.data.title,
|
||||
description: res.data.description,
|
||||
thumbnail: res.data.thumbnail_url,
|
||||
length: res.data.duration,
|
||||
});
|
||||
storage.updateVideoInfo(video);
|
||||
return video;
|
||||
}).catch(err => {
|
||||
if (err.response && err.response.status === 403) {
|
||||
log.error("Failed to get vimeo video info: Embedding for this video is disabled");
|
||||
return null;
|
||||
}
|
||||
else {
|
||||
log.error(`Failed to get vimeo video info: ${err}`);
|
||||
return new Video({
|
||||
service: "vimeo",
|
||||
id,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/* DAILYMOTION */
|
||||
|
||||
/**
|
||||
* Gets the Dailymotion video id from the link.
|
||||
* @param {string} link Dailymotion URL
|
||||
* @returns {string} Dailymotion video id
|
||||
*/
|
||||
getVideoIdDailymotion(link) {
|
||||
let urlParsed = url.parse(link);
|
||||
return urlParsed.path.split("/").slice(-1)[0].split("?")[0].trim();
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets video metadata for dailymotion videos.
|
||||
*
|
||||
* https://developer.dailymotion.com/player/#player-oembed
|
||||
* https://developer.dailymotion.com/tools/#/video
|
||||
* @param {string} id The video id on dailymotion
|
||||
* @returns {Promise<Video>|null} Video with metadata, null if it fails to get metadata
|
||||
*/
|
||||
getVideoInfoDailymotion(id) {
|
||||
return DailymotionApi.get(`/video/${id}?fields=title,description,thumbnail_url,duration`).then(res => {
|
||||
let video = new Video({
|
||||
service: "dailymotion",
|
||||
id,
|
||||
title: res.data.title,
|
||||
description: res.data.description,
|
||||
thumbnail: res.data.thumbnail_url,
|
||||
length: res.data.duration,
|
||||
});
|
||||
storage.updateVideoInfo(video);
|
||||
return video;
|
||||
}).catch(err => {
|
||||
log.error(`Failed to get dailymotion video info: ${err}`);
|
||||
return null;
|
||||
});
|
||||
},
|
||||
|
||||
/* GOOGLE DRIVE */
|
||||
|
||||
getVideoIdGoogleDrive(link) {
|
||||
let urlParsed = url.parse(link);
|
||||
if (urlParsed.path.startsWith("/file/d/")) {
|
||||
return urlParsed.path.split("/")[3];
|
||||
}
|
||||
else {
|
||||
let query = querystring.parse(urlParsed.query);
|
||||
return query["id"];
|
||||
}
|
||||
},
|
||||
|
||||
getFolderIdGoogleDrive(link) {
|
||||
let urlParsed = url.parse(link);
|
||||
if (/^\/drive\/u\/\d\/folders\//.exec(urlParsed.path)) {
|
||||
return urlParsed.path.split("/")[5].split("?")[0].trim();
|
||||
}
|
||||
else if (urlParsed.path.startsWith("/drive/folders")) {
|
||||
return urlParsed.path.split("/")[3].split("?")[0].trim();
|
||||
}
|
||||
else {
|
||||
throw new Error("Invalid google drive folder");
|
||||
}
|
||||
},
|
||||
|
||||
parseGoogleDriveFile(file) {
|
||||
return new Video({
|
||||
service: "googledrive",
|
||||
id: file.id,
|
||||
title: file.name,
|
||||
thumbnail: file.thumbnailLink,
|
||||
length: Math.ceil(file.videoMediaMetadata.durationMillis / 1000),
|
||||
mime: file.mimeType,
|
||||
});
|
||||
},
|
||||
|
||||
getVideoInfoGoogleDrive(id) {
|
||||
// https://stackoverflow.com/questions/57585838/how-to-get-thumbnail-of-a-video-uploaded-to-google-drive
|
||||
return GoogleDriveApi.get(`/files/${id}?key=${process.env.GOOGLE_DRIVE_API_KEY}&fields=id,name,mimeType,thumbnailLink,videoMediaMetadata(durationMillis)`).then(res => {
|
||||
// description is not provided
|
||||
let video = this.parseGoogleDriveFile(res.data);
|
||||
// video.id = id;
|
||||
storage.updateVideoInfo(video);
|
||||
if (!this.isSupportedMimeType(video.mime)) {
|
||||
throw new UnsupportedMimeTypeException(video.mime);
|
||||
}
|
||||
return video;
|
||||
}).catch(err => {
|
||||
if (err.response && err.response.data.error && err.response.data.error.errors[0].reason === "dailyLimitExceeded") {
|
||||
throw new OutOfQuotaException("googledrive");
|
||||
}
|
||||
else {
|
||||
if (err.response && err.response.data.error) {
|
||||
log.error(`Failed to get google drive video metadata: ${err.response.data.error.message} ${JSON.stringify(err.response.data.error.errors)}`);
|
||||
}
|
||||
else {
|
||||
log.error(`Failed to get google drive video metadata: ${err}: ${JSON.stringify(err.response.data)}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getFolderGoogleDrive(id) {
|
||||
return GoogleDriveApi.get(`/files?q="${id}"+in+parents&key=${process.env.GOOGLE_DRIVE_API_KEY}&fields=files(id,name,mimeType,thumbnailLink,videoMediaMetadata(durationMillis))`).then(res => {
|
||||
log.info(`Found ${res.data.files.length} items in folder`);
|
||||
return res.data.files.map(item => this.parseGoogleDriveFile(item));
|
||||
}).catch(err => {
|
||||
if (err.response && err.response.data.error && err.response.data.error.errors[0].reason === "dailyLimitExceeded") {
|
||||
throw new OutOfQuotaException("googledrive");
|
||||
}
|
||||
else {
|
||||
if (err.response && err.response.data.error) {
|
||||
log.error(`Failed to get google drive folder: ${err.response.data.error.message} ${JSON.stringify(err.response.data.error.errors)}`);
|
||||
}
|
||||
else {
|
||||
log.error(`Failed to get google drive folder: ${err}: ${JSON.stringify(err.response.data)}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/* DIRECT */
|
||||
|
||||
async getVideoInfoDirect(link) {
|
||||
let srcUrl = url.parse(link);
|
||||
if (srcUrl.protocol === "file:") {
|
||||
throw new LocalFileException();
|
||||
}
|
||||
let fileName = srcUrl.path.split("/").slice(-1)[0].split("?")[0].trim();
|
||||
let extension = fileName.split(".")[1];
|
||||
let mime = "unknown";
|
||||
// TODO: swap this out with something more robust
|
||||
// http://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types
|
||||
switch (extension) {
|
||||
case "mp4":
|
||||
case "mp4v":
|
||||
case "mpg4":
|
||||
mime = "video/mp4";
|
||||
break;
|
||||
case "mkv":
|
||||
case "mk3d":
|
||||
case "mks":
|
||||
mime = "video/x-matroska";
|
||||
break;
|
||||
case "mov":
|
||||
case "qt":
|
||||
mime = "video/quicktime";
|
||||
break;
|
||||
case "webm":
|
||||
mime = "video/webm";
|
||||
break;
|
||||
case "flv":
|
||||
mime = "video/x-flv";
|
||||
break;
|
||||
}
|
||||
if (!this.isSupportedMimeType(mime)) {
|
||||
throw new UnsupportedMimeTypeException(mime);
|
||||
}
|
||||
const fileInfo = await ffprobe.getFileInfo(link);
|
||||
let videoStream = _.find(fileInfo.streams, { "codec_type": "video" });
|
||||
if (!videoStream.duration) {
|
||||
log.error("Video duration could not be determined");
|
||||
throw new MissingMetadataException();
|
||||
}
|
||||
return new Video({
|
||||
service: "direct",
|
||||
url: link,
|
||||
title: fileName,
|
||||
description: `Full Link: ${link}`,
|
||||
mime,
|
||||
length: Math.ceil(videoStream.duration),
|
||||
});
|
||||
},
|
||||
};
|
||||
52
opentogethertube/opentogethertube/jest.config.js
Normal file
52
opentogethertube/opentogethertube/jest.config.js
Normal file
@@ -0,0 +1,52 @@
|
||||
module.exports = {
|
||||
moduleFileExtensions: [
|
||||
'js',
|
||||
'jsx',
|
||||
'json',
|
||||
'vue',
|
||||
],
|
||||
transform: {
|
||||
'^.+\\.vue$': 'vue-jest',
|
||||
'.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub',
|
||||
'^.+\\.js(|x)?$': 'babel-jest',
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
// '/node_modules/',
|
||||
'!/src/',
|
||||
'/tests/unit/server/',
|
||||
'/common/',
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
snapshotSerializers: ['jest-serializer-vue'],
|
||||
testMatch: ['**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'],
|
||||
testURL: 'http://localhost/',
|
||||
watchPlugins: [
|
||||
'jest-watch-typeahead/filename',
|
||||
'jest-watch-typeahead/testname',
|
||||
],
|
||||
'collectCoverage': true,
|
||||
"coverageReporters": [
|
||||
"text-summary",
|
||||
"text",
|
||||
"json",
|
||||
"html",
|
||||
],
|
||||
'collectCoverageFrom': [
|
||||
'**/*.{js,vue}',
|
||||
'!**/node_modules/**',
|
||||
'!**/dist/**',
|
||||
'!**/*.config.js',
|
||||
'!**/*.eslintrc.js',
|
||||
'!**/config/**',
|
||||
'!**/migrations/**',
|
||||
'!**/models/**',
|
||||
'!**/seeders/**',
|
||||
'!**/coverage/**',
|
||||
'!app.js',
|
||||
'!src/App.vue',
|
||||
'!src/plugins/vuetify.js',
|
||||
'!src/(main|router).js',
|
||||
],
|
||||
};
|
||||
77
opentogethertube/opentogethertube/logger.js
Normal file
77
opentogethertube/opentogethertube/logger.js
Normal file
@@ -0,0 +1,77 @@
|
||||
const { createLogger, format, transports } = require('winston');
|
||||
const colors = require('ansi-colors');
|
||||
|
||||
const myFormat = format.printf(({ level, message, timestamp, namespace, roomName, roomEvent }) => {
|
||||
if (roomEvent) {
|
||||
// HACK: video descriptions are long, so remove then to make logs easier to read.
|
||||
if (roomEvent.parameters && roomEvent.parameters.video) {
|
||||
delete roomEvent.parameters.video.description;
|
||||
}
|
||||
return `${timestamp} ${namespace} Room/${roomEvent.roomName} ${level} Room event: ${JSON.stringify(roomEvent)}`;
|
||||
}
|
||||
if (roomName) {
|
||||
return `${timestamp} ${namespace} Room/${roomName} ${level} ${message}`;
|
||||
}
|
||||
return `${timestamp} ${namespace} ${level} ${message}`;
|
||||
});
|
||||
|
||||
const customColorizer = format(info => {
|
||||
info.timestamp = colors.green(info.timestamp);
|
||||
info.namespace = colors.blue(info.namespace);
|
||||
if (info.level == "error") {
|
||||
info.level = colors.bold.red(info.level);
|
||||
info.message = colors.red(info.message);
|
||||
}
|
||||
else if (info.level == "warn") {
|
||||
info.level = colors.bold.yellow(info.level);
|
||||
info.message = colors.yellow(info.message);
|
||||
}
|
||||
else if (info.level == "info") {
|
||||
info.level = colors.bold.white(info.level);
|
||||
info.message = colors.white(info.message);
|
||||
}
|
||||
else if (info.level == "debug") {
|
||||
info.level = colors.bold.green(info.level);
|
||||
info.message = colors.green(info.message);
|
||||
}
|
||||
else {
|
||||
info.level = colors.bold(info.level);
|
||||
}
|
||||
return info;
|
||||
});
|
||||
|
||||
const logger = createLogger({
|
||||
level: 'info',
|
||||
format: format.combine(
|
||||
format.timestamp({
|
||||
format: 'YYYY-MM-DD HH:mm:ss',
|
||||
}),
|
||||
myFormat
|
||||
),
|
||||
transports: [new transports.File({ filename: process.env.LOG_FILE || "./logs/ott.log" })],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logger.add(new transports.Console({
|
||||
format: format.combine(
|
||||
customColorizer(),
|
||||
myFormat
|
||||
),
|
||||
}));
|
||||
}
|
||||
else {
|
||||
logger.add(new transports.Console({
|
||||
format: format.combine(
|
||||
myFormat
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getLogger(namespace) {
|
||||
return logger.child({ namespace });
|
||||
},
|
||||
setLogLevel(level) {
|
||||
logger.level = level;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
'use strict';
|
||||
module.exports = {
|
||||
up: (queryInterface, Sequelize) => {
|
||||
return queryInterface.createTable('Rooms', {
|
||||
id: {
|
||||
allowNull: false,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
type: Sequelize.INTEGER,
|
||||
},
|
||||
name: {
|
||||
type: Sequelize.STRING,
|
||||
unique: true,
|
||||
allowNull: false,
|
||||
},
|
||||
title: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: "Room",
|
||||
},
|
||||
description: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false,
|
||||
defaultValue: "",
|
||||
},
|
||||
createdAt: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
},
|
||||
updatedAt: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
},
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
down: (queryInterface, Sequelize) => {
|
||||
return queryInterface.dropTable('Rooms');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
'use strict';
|
||||
module.exports = {
|
||||
up: (queryInterface, Sequelize) => {
|
||||
return queryInterface.createTable('CachedVideos', {
|
||||
id: {
|
||||
allowNull: false,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
type: Sequelize.INTEGER,
|
||||
},
|
||||
service: {
|
||||
type: Sequelize.STRING,
|
||||
},
|
||||
serviceId: {
|
||||
type: Sequelize.STRING,
|
||||
},
|
||||
title: {
|
||||
type: Sequelize.STRING,
|
||||
},
|
||||
description: {
|
||||
type: Sequelize.TEXT,
|
||||
},
|
||||
thumbnail: {
|
||||
type: Sequelize.STRING,
|
||||
},
|
||||
length: {
|
||||
type: Sequelize.INTEGER,
|
||||
},
|
||||
createdAt: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
},
|
||||
updatedAt: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
},
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
down: (queryInterface, Sequelize) => {
|
||||
return queryInterface.dropTable('CachedVideos');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: (queryInterface, Sequelize) => {
|
||||
return queryInterface.addColumn('Rooms', 'visibility', {
|
||||
type: Sequelize.STRING,
|
||||
defaultValue: 'public',
|
||||
allowNull: false,
|
||||
});
|
||||
},
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
down: (queryInterface, Sequelize) => {
|
||||
return queryInterface.removeColumn('Rooms', 'visibility');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.changeColumn('CachedVideos', 'service', {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
});
|
||||
await queryInterface.changeColumn('CachedVideos', 'serviceId', {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.changeColumn('CachedVideos', 'service', {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
});
|
||||
await queryInterface.changeColumn('CachedVideos', 'serviceId', {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: (queryInterface, Sequelize) => {
|
||||
return queryInterface.addColumn('CachedVideos', 'mime', {
|
||||
type: Sequelize.STRING,
|
||||
defaultValue: null,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
down: (queryInterface, Sequelize) => {
|
||||
return queryInterface.removeColumn('CachedVideos', 'mime');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
'use strict';
|
||||
module.exports = {
|
||||
up: (queryInterface, Sequelize) => {
|
||||
return queryInterface.createTable('Users', {
|
||||
id: {
|
||||
allowNull: false,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
type: Sequelize.INTEGER,
|
||||
},
|
||||
username: {
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
type: Sequelize.STRING,
|
||||
},
|
||||
email: {
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
type: Sequelize.STRING,
|
||||
},
|
||||
salt: {
|
||||
allowNull: false,
|
||||
type: Sequelize.BLOB,
|
||||
},
|
||||
hash: {
|
||||
allowNull: false,
|
||||
type: Sequelize.BLOB,
|
||||
},
|
||||
createdAt: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
},
|
||||
updatedAt: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
},
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
down: (queryInterface, Sequelize) => {
|
||||
return queryInterface.dropTable('Users');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: (queryInterface, Sequelize) => {
|
||||
return queryInterface.addColumn('Rooms', 'ownerId', {
|
||||
type: Sequelize.INTEGER,
|
||||
defaultValue: -1,
|
||||
allowNull: false,
|
||||
});
|
||||
},
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
down: (queryInterface, Sequelize) => {
|
||||
return queryInterface.removeColumn('Rooms', 'ownerId');
|
||||
},
|
||||
};
|
||||
23
opentogethertube/opentogethertube/models/cachedvideo.js
Normal file
23
opentogethertube/opentogethertube/models/cachedvideo.js
Normal file
@@ -0,0 +1,23 @@
|
||||
'use strict';
|
||||
module.exports = (sequelize, DataTypes) => {
|
||||
const CachedVideo = sequelize.define('CachedVideo', {
|
||||
service: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
serviceId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
title: DataTypes.STRING,
|
||||
description: DataTypes.TEXT,
|
||||
thumbnail: DataTypes.STRING,
|
||||
length: DataTypes.INTEGER,
|
||||
mime: DataTypes.STRING,
|
||||
}, {});
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
CachedVideo.associate = function(models) {
|
||||
// associations can be defined here
|
||||
};
|
||||
return CachedVideo;
|
||||
};
|
||||
45
opentogethertube/opentogethertube/models/index.js
Normal file
45
opentogethertube/opentogethertube/models/index.js
Normal file
@@ -0,0 +1,45 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Sequelize = require('sequelize');
|
||||
const basename = path.basename(__filename);
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
const config = require(__dirname + '/../config/config.js')[env];
|
||||
const db = {};
|
||||
|
||||
let sequelize;
|
||||
if (process.env.NODE_ENV === 'production' && process.env.DATABASE_URL) {
|
||||
// for heroku
|
||||
sequelize = new Sequelize(process.env.DATABASE_URL, {
|
||||
dialect: "postgres",
|
||||
protocol: "postgres",
|
||||
});
|
||||
}
|
||||
else if (config.use_env_variable) {
|
||||
sequelize = new Sequelize(process.env[config.use_env_variable], config);
|
||||
}
|
||||
else {
|
||||
sequelize = new Sequelize(config.database, config.username, config.password, config);
|
||||
}
|
||||
|
||||
fs
|
||||
.readdirSync(__dirname)
|
||||
.filter(file => {
|
||||
return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js');
|
||||
})
|
||||
.forEach(file => {
|
||||
const model = sequelize['import'](path.join(__dirname, file));
|
||||
db[model.name] = model;
|
||||
});
|
||||
|
||||
Object.keys(db).forEach(modelName => {
|
||||
if (db[modelName].associate) {
|
||||
db[modelName].associate(db);
|
||||
}
|
||||
});
|
||||
|
||||
db.sequelize = sequelize;
|
||||
db.Sequelize = Sequelize;
|
||||
|
||||
module.exports = db;
|
||||
19
opentogethertube/opentogethertube/models/room.js
Normal file
19
opentogethertube/opentogethertube/models/room.js
Normal file
@@ -0,0 +1,19 @@
|
||||
'use strict';
|
||||
module.exports = (sequelize, DataTypes) => {
|
||||
const Room = sequelize.define('Room', {
|
||||
name: DataTypes.STRING,
|
||||
title: DataTypes.STRING,
|
||||
description: DataTypes.STRING,
|
||||
visibility: DataTypes.STRING,
|
||||
ownerId: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: -1,
|
||||
allowNull: false,
|
||||
},
|
||||
}, {});
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
Room.associate = function(models) {
|
||||
Room.belongsTo(models.User, { foreignKey: "ownerId", as: "owner" });
|
||||
};
|
||||
return Room;
|
||||
};
|
||||
45
opentogethertube/opentogethertube/models/user.js
Normal file
45
opentogethertube/opentogethertube/models/user.js
Normal file
@@ -0,0 +1,45 @@
|
||||
'use strict';
|
||||
module.exports = (sequelize, DataTypes) => {
|
||||
const User = sequelize.define('User', {
|
||||
username: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
validate: {
|
||||
// eslint-disable-next-line array-bracket-newline
|
||||
len: [1, 255],
|
||||
},
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
validate: {
|
||||
isEmail: {
|
||||
args: [
|
||||
{
|
||||
require_tld: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
salt: {
|
||||
type: DataTypes.BLOB,
|
||||
allowNull: false,
|
||||
validate: {
|
||||
// eslint-disable-next-line array-bracket-newline
|
||||
len: [1, 256],
|
||||
},
|
||||
},
|
||||
hash: {
|
||||
type: DataTypes.BLOB,
|
||||
allowNull: false,
|
||||
},
|
||||
}, {});
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
User.associate = function(models) {
|
||||
// User.hasMany(models.Room, { as: "rooms" });
|
||||
};
|
||||
return User;
|
||||
};
|
||||
28177
opentogethertube/opentogethertube/package-lock.json
generated
Normal file
28177
opentogethertube/opentogethertube/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
90
opentogethertube/opentogethertube/package.json
Normal file
90
opentogethertube/opentogethertube/package.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"name": "opentogethertube",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node app.js",
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint",
|
||||
"lint-ci": "vue-cli-service --no-fix lint",
|
||||
"test": "vue-cli-service test:unit --runInBand --detectOpenHandles --forceExit",
|
||||
"api-server": "nodemon app.js",
|
||||
"dev": "NODE_ENV=development concurrently \"npm:api-server\" \"npm:serve\"",
|
||||
"dev-windows": "SET NODE_ENV=development&&concurrently \"npm:api-server\" \"npm:serve\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@ffprobe-installer/ffprobe": "^1.0.12",
|
||||
"@fortawesome/fontawesome-free": "^5.12.1",
|
||||
"@mdi/font": "^3.9.97",
|
||||
"@vimeo/player": "^2.10.0",
|
||||
"@vue/cli": "^3.12.1",
|
||||
"ansi-colors": "^4.1.1",
|
||||
"axios": "^0.19.2",
|
||||
"connect-redis": "^4.0.4",
|
||||
"dotenv": "^8.2.0",
|
||||
"express": "^4.17.1",
|
||||
"express-rate-limit": "^5.1.1",
|
||||
"express-session": "^1.17.0",
|
||||
"load-script": "^1.0.0",
|
||||
"lodash": "^4.17.15",
|
||||
"material-design-icons-iconfont": "^5.0.1",
|
||||
"moment": "^2.24.0",
|
||||
"nanotimer": "^0.3.15",
|
||||
"passport": "^0.4.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"pg": "^7.18.1",
|
||||
"pg-hstore": "^2.3.3",
|
||||
"rate-limit-redis": "^1.7.0",
|
||||
"redis": "^3.0.2",
|
||||
"secure-password": "^3.1.0",
|
||||
"sequelize": "^5.21.4",
|
||||
"sequelize-cli": "^5.5.1",
|
||||
"unique-names-generator": "^3.1.1",
|
||||
"uuid": "^3.4.0",
|
||||
"validator": "^13.0.0",
|
||||
"video.js": "^7.6.6",
|
||||
"vue": "^2.6.11",
|
||||
"vue-axios": "^2.1.5",
|
||||
"vue-events": "^3.1.0",
|
||||
"vue-gtag": "^1.6.1",
|
||||
"vue-native-websocket": "^2.0.14",
|
||||
"vue-router": "^3.1.5",
|
||||
"vue-slider-component": "^3.1.1",
|
||||
"vue-youtube": "^1.4.0",
|
||||
"vuedraggable": "^2.23.2",
|
||||
"vuetify": "^2.2.19",
|
||||
"vuex": "^3.1.2",
|
||||
"winston": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.9.0",
|
||||
"@vue/cli-plugin-babel": "^4.3.0",
|
||||
"@vue/cli-plugin-eslint": "^4.2.3",
|
||||
"@vue/cli-plugin-unit-jest": "^4.3.0",
|
||||
"@vue/cli-service": "^4.3.0",
|
||||
"@vue/test-utils": "1.0.0-beta.29",
|
||||
"babel-eslint": "^10.0.3",
|
||||
"babel-jest": "^25.2.6",
|
||||
"concurrently": "^4.1.2",
|
||||
"core-js": "^3.6.4",
|
||||
"eslint": "^6.7.2",
|
||||
"eslint-plugin-jest": "^23.8.2",
|
||||
"eslint-plugin-vue": "^6.1.2",
|
||||
"husky": "^4.2.3",
|
||||
"node-sass": "^4.13.1",
|
||||
"nodemon": "^1.19.4",
|
||||
"sass": "^1.19.0",
|
||||
"sass-loader": "^7.1.0",
|
||||
"sqlite3": "^4.1.1",
|
||||
"supertest": "^4.0.2",
|
||||
"vue-cli-plugin-vuetify": "^2.0.5",
|
||||
"vue-cli-plugin-webpack-bundle-analyzer": "^2.0.0",
|
||||
"vue-template-compiler": "^2.6.11",
|
||||
"vuetify-loader": "^1.4.3",
|
||||
"webpack-bundle-analyzer": "^3.6.0"
|
||||
},
|
||||
"husky": {
|
||||
"pre-commit": "npm run lint"
|
||||
}
|
||||
}
|
||||
5
opentogethertube/opentogethertube/postcss.config.js
Normal file
5
opentogethertube/opentogethertube/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
opentogethertube/opentogethertube/public/favicon.ico
Normal file
BIN
opentogethertube/opentogethertube/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
21
opentogethertube/opentogethertube/public/index.html
Normal file
21
opentogethertube/opentogethertube/public/index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<meta name="description" content="Watch videos together online with your friends. Real-time syncronized playback. Optional voting system. Dark theme. No sign up required. All Open Source.">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Roboto:400,500,700,400italic|Material+Icons">
|
||||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-148983263-2"></script>
|
||||
<title>OpenTogetherTube - Enjoy Together - Watch videos together online with your friends</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but OpenTogetherTube doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
||||
9
opentogethertube/opentogethertube/public/robots.txt
Normal file
9
opentogethertube/opentogethertube/public/robots.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
# This robots.txt file tells webcrawlers and search engines which parts of the sites they cannot access.
|
||||
|
||||
# The User-agent field is the name of the webcrawler. We're going to put '*' to reference all robots.
|
||||
User-agent: *
|
||||
|
||||
# The Disallow field tells which routes we do not want the webcrawlers to have access to.
|
||||
# Right now the only route we're going to deny access to is the room page.
|
||||
# NOTE: this also affects all subroutes so /room/test and /room/myroom are also blocked.
|
||||
Disallow: /room/*
|
||||
14
opentogethertube/opentogethertube/redisclient.js
Normal file
14
opentogethertube/opentogethertube/redisclient.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const redis = require('redis');
|
||||
|
||||
const redisClient = process.env.REDIS_URL ?
|
||||
redis.createClient(process.env.REDIS_URL) :
|
||||
redis.createClient({
|
||||
port: process.env.REDIS_PORT || undefined,
|
||||
host: process.env.REDIS_HOST || undefined,
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
db: process.env.REDIS_DB || undefined,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
redisClient,
|
||||
};
|
||||
979
opentogethertube/opentogethertube/roommanager.js
Normal file
979
opentogethertube/opentogethertube/roommanager.js
Normal file
@@ -0,0 +1,979 @@
|
||||
const WebSocket = require('ws');
|
||||
const _ = require("lodash");
|
||||
const moment = require("moment");
|
||||
const { uniqueNamesGenerator } = require('unique-names-generator');
|
||||
const NanoTimer = require("nanotimer");
|
||||
const InfoExtract = require("./infoextract");
|
||||
const storage = require("./storage");
|
||||
const Video = require("./common/video.js");
|
||||
const { getLogger } = require("./logger.js");
|
||||
const { redisClient } = require('./redisclient.js');
|
||||
|
||||
const log = getLogger("roommanager");
|
||||
|
||||
const SUPPORTED_SERVICES = [
|
||||
"youtube", "vimeo", "dailymotion", "googledrive", "direct",
|
||||
];
|
||||
|
||||
// Custom websocket error codes
|
||||
const WS_ERROR_INVALID_CONNECTION_URL = 4001;
|
||||
const WS_ERROR_ROOM_NOT_FOUND = 4002;
|
||||
const WS_ERROR_ROOM_UNLOADED = 4003;
|
||||
|
||||
/**
|
||||
* Represents a Room and all it's associated state, settings, connected clients.
|
||||
*/
|
||||
class Room {
|
||||
/**
|
||||
* DO NOT CREATE NEW ROOMS WITH THIS CONSTRUCTOR. Create/get Rooms using the RoomManager.
|
||||
*/
|
||||
constructor(args=undefined) {
|
||||
this._dirtyProps = [];
|
||||
|
||||
this.name = "";
|
||||
this.title = "";
|
||||
this.description = "";
|
||||
this.isTemporary = false;
|
||||
this.visibility = "public";
|
||||
this.queueMode = "manual"; // manual, vote
|
||||
this.currentSource = {};
|
||||
this.queue = [];
|
||||
this.isPlaying = false;
|
||||
this.playbackPosition = 0;
|
||||
this.clients = [];
|
||||
this.keepAlivePing = null;
|
||||
this.owner = null;
|
||||
this.playbackStartTime = null;
|
||||
if (args) {
|
||||
Object.assign(this, args);
|
||||
}
|
||||
|
||||
this.log = log.child({
|
||||
roomName: this.name,
|
||||
});
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this._name;
|
||||
}
|
||||
|
||||
set name(value) {
|
||||
this._name = value;
|
||||
this._dirtyProps.push("name");
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this._title;
|
||||
}
|
||||
|
||||
set title(value) {
|
||||
this._title = value;
|
||||
this._dirtyProps.push("title");
|
||||
}
|
||||
|
||||
get description() {
|
||||
return this._description;
|
||||
}
|
||||
|
||||
set description(value) {
|
||||
this._description = value;
|
||||
this._dirtyProps.push("description");
|
||||
}
|
||||
|
||||
get isTemporary() {
|
||||
return this._isTemporary;
|
||||
}
|
||||
|
||||
set isTemporary(value) {
|
||||
this._isTemporary = value;
|
||||
this._dirtyProps.push("isTemporary");
|
||||
}
|
||||
|
||||
get visibility() {
|
||||
return this._visibility;
|
||||
}
|
||||
|
||||
set visibility(value) {
|
||||
this._visibility = value;
|
||||
this._dirtyProps.push("visibility");
|
||||
}
|
||||
|
||||
get queueMode() {
|
||||
return this._queueMode;
|
||||
}
|
||||
|
||||
set queueMode(value) {
|
||||
this._queueMode = value;
|
||||
this._dirtyProps.push("queueMode");
|
||||
}
|
||||
|
||||
get currentSource() {
|
||||
return this._currentSource;
|
||||
}
|
||||
|
||||
set currentSource(value) {
|
||||
this._currentSource = value;
|
||||
this._dirtyProps.push("currentSource");
|
||||
}
|
||||
|
||||
get isPlaying() {
|
||||
return this._isPlaying;
|
||||
}
|
||||
|
||||
set isPlaying(value) {
|
||||
this._isPlaying = value;
|
||||
this._dirtyProps.push("isPlaying");
|
||||
}
|
||||
|
||||
get playbackPosition() {
|
||||
return this._playbackPosition;
|
||||
}
|
||||
|
||||
set playbackPosition(value) {
|
||||
this._playbackPosition = value;
|
||||
this._dirtyProps.push("playbackPosition");
|
||||
}
|
||||
|
||||
get owner() {
|
||||
return this._owner;
|
||||
}
|
||||
|
||||
set owner(value) {
|
||||
this._owner = value;
|
||||
this._dirtyProps.push("hasOwner");
|
||||
}
|
||||
|
||||
get playbackStartTime() {
|
||||
return this._playbackStartTime;
|
||||
}
|
||||
|
||||
set playbackStartTime(value) {
|
||||
this._playbackStartTime = value;
|
||||
this._dirtyProps.push("playbackStartTime");
|
||||
}
|
||||
|
||||
getTruePlaybackPosition(now=moment()) {
|
||||
// FIXME: This function is basically the same as calculateCurrentPosition in timestamp.js
|
||||
// on the client side, there has to be some way to share functions between the client and server.
|
||||
return this.playbackPosition + (this.isPlaying * now.diff(this.playbackStartTime, "seconds"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies the room state based on the room event given, sends the event to clients, and syncs clients.
|
||||
* @param {RoomEvent} event
|
||||
*/
|
||||
commitRoomEvent(event, now=moment()) {
|
||||
this.log.debug(`Commiting room event ${event.eventType}`);
|
||||
if (event.eventType === ROOM_EVENT_TYPE.PLAY) {
|
||||
this.playbackStartTime = now.clone();
|
||||
this.isPlaying = true;
|
||||
}
|
||||
else if (event.eventType === ROOM_EVENT_TYPE.PAUSE) {
|
||||
this.isPlaying = false;
|
||||
this.playbackPosition += now.diff(this.playbackStartTime, "seconds");
|
||||
}
|
||||
else if (event.eventType === ROOM_EVENT_TYPE.SEEK) {
|
||||
event.parameters.previousPosition = this.getTruePlaybackPosition(now);
|
||||
this.playbackPosition = event.parameters.position;
|
||||
this.playbackStartTime = now.clone();
|
||||
}
|
||||
else if (event.eventType === ROOM_EVENT_TYPE.SKIP) {
|
||||
this.playbackPosition = this.currentSource.length + 1;
|
||||
this.playbackStartTime = now.clone();
|
||||
this.update();
|
||||
}
|
||||
else {
|
||||
log.error(`Can't commit event, unknown event type ${event.eventType}`);
|
||||
}
|
||||
this.sendRoomEvent(event);
|
||||
this.sync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtains metadata for a given video and adds it to the queue
|
||||
* @param {Video|Object} video The video to add. Should contain either a `url` property, or `service` and `id` properties.
|
||||
*/
|
||||
addToQueue(video, session=null) {
|
||||
let queueItem = new Video();
|
||||
|
||||
if (video.hasOwnProperty("url")) {
|
||||
queueItem.service = InfoExtract.getService(video.url);
|
||||
|
||||
if (queueItem.service === "youtube") {
|
||||
queueItem.id = InfoExtract.getVideoIdYoutube(video.url);
|
||||
}
|
||||
else if (queueItem.service === "vimeo") {
|
||||
queueItem.id = InfoExtract.getVideoIdVimeo(video.url);
|
||||
}
|
||||
else if (queueItem.service === "dailymotion") {
|
||||
queueItem.id = InfoExtract.getVideoIdDailymotion(video.url);
|
||||
}
|
||||
else if (queueItem.service === "googledrive") {
|
||||
queueItem.id = InfoExtract.getVideoIdGoogledrive(video.url);
|
||||
}
|
||||
}
|
||||
else {
|
||||
queueItem.service = video.service;
|
||||
queueItem.id = video.id;
|
||||
}
|
||||
|
||||
if (SUPPORTED_SERVICES.includes(queueItem.service)) {
|
||||
// TODO: fallback to "unofficial" methods of retreiving if using the youtube API fails.
|
||||
return InfoExtract.getVideoInfo(queueItem.service, queueItem.id).then(result => {
|
||||
queueItem = result;
|
||||
}).catch(err => {
|
||||
this.log.error(`Failed to get video info: ${err}`);
|
||||
queueItem.title = queueItem.id;
|
||||
}).then(() => {
|
||||
this.queue.push(queueItem);
|
||||
this._dirtyProps.push("queue");
|
||||
this.update();
|
||||
this.sync();
|
||||
|
||||
if (session) {
|
||||
let client = _.find(this.clients, { session: { id: session.id } });
|
||||
this.sendRoomEvent(new RoomEvent(this.name, ROOM_EVENT_TYPE.ADD_TO_QUEUE, client.username, { video: queueItem }));
|
||||
|
||||
if (this.queueMode === "vote") {
|
||||
this.voteVideo(queueItem, session);
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.log.warn("UNABLE TO SEND ROOM EVENT: Couldn't send room event addToQueue because no session information was provided.");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
else {
|
||||
throw `Service ${queueItem.service} not yet supported`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vote for a video if the room is in voting mode.
|
||||
* @param {Video|Object} video The video to vote for.
|
||||
* @param {Object} session The user session that is voting for the video
|
||||
*/
|
||||
voteVideo(video, session) {
|
||||
if (this.queueMode !== "vote") {
|
||||
this.log.error("Room not in voting mode");
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if the voted video is in the queue
|
||||
let matchIdx = _.findIndex(this.queue, item => item.service === video.service && item.id === video.id);
|
||||
if (matchIdx < 0) {
|
||||
this.log.error("Can't vote for video not in queue");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.queue[matchIdx].votes) {
|
||||
this.queue[matchIdx].votes = [];
|
||||
}
|
||||
|
||||
// check to see if the vote already exists
|
||||
if (_.findIndex(this.queue[matchIdx].votes, { userSessionId: session.id }) >= 0) {
|
||||
this.log.error("Vote for video already exists");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.queue[matchIdx].votes.push({ userSessionId: session.id });
|
||||
this.queue[matchIdx]._lastVotesChanged = moment();
|
||||
this._dirtyProps.push("queue");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a user's vote for a video if the room is in voting mode.
|
||||
* @param {Video|Object} video The video to remove the vote for.
|
||||
* @param {Object} session The user session that is voting for the video
|
||||
*/
|
||||
removeVoteVideo(video, session) {
|
||||
if (this.queueMode !== "vote") {
|
||||
this.log.error("Room not in voting mode");
|
||||
return false;
|
||||
}
|
||||
|
||||
let matchIdx = _.findIndex(this.queue, item => item.service === video.service && item.id === video.id);
|
||||
if (matchIdx < 0) {
|
||||
this.log.error("Can't remove vote for video not in queue");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.queue[matchIdx].votes = _.reject(this.queue[matchIdx].votes, { userSessionId: session.id });
|
||||
this.queue[matchIdx]._lastVotesChanged = moment();
|
||||
this._dirtyProps.push("queue");
|
||||
return true;
|
||||
}
|
||||
|
||||
removeFromQueue(video, session=null) {
|
||||
let matchIdx = _.findIndex(this.queue, item => item.service === video.service && item.id === video.id);
|
||||
if (matchIdx < 0) {
|
||||
return false;
|
||||
}
|
||||
// remove the item from the queue
|
||||
let removed = this.queue.splice(matchIdx, 1)[0];
|
||||
this._dirtyProps.push("queue");
|
||||
if (session) {
|
||||
let client = _.find(this.clients, { session: { id: session.id } });
|
||||
this.sendRoomEvent(new RoomEvent(this.name, ROOM_EVENT_TYPE.REMOVE_FROM_QUEUE, client.username, { video: removed, queueIdx: matchIdx }));
|
||||
}
|
||||
else {
|
||||
this.log.warn("UNABLE TO SEND ROOM EVENT: Couldn't send room event removeFromQueue because no session information was provided.");
|
||||
}
|
||||
this.sync();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the room state. Any logic that makes the room do
|
||||
* something automatically without a user's input goes here
|
||||
* (automatically playing the next video in the queue, etc.)
|
||||
*/
|
||||
update(now=moment()) {
|
||||
// remove inactive clients
|
||||
for (let i = 0; i < this.clients.length; i++) {
|
||||
let ws = this.clients[i].socket;
|
||||
if (ws.readyState != 1) {
|
||||
this.log.debug("Remove inactive client:", i, this.clients[i].username);
|
||||
this.sendRoomEvent(new RoomEvent(this.name, ROOM_EVENT_TYPE.LEAVE_ROOM, this.clients[i].username, {}));
|
||||
this.clients.splice(i--, 1);
|
||||
this._dirtyProps.push("users");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// sort queue according to queue mode
|
||||
if (this.queueMode === "vote") {
|
||||
let _oldOrder = _.clone(this.queue);
|
||||
this.queue = _.orderBy(this.queue, [
|
||||
video => video.votes ? video.votes.length : 0,
|
||||
video => video._lastVotesChanged,
|
||||
], [
|
||||
"desc",
|
||||
"asc",
|
||||
]);
|
||||
if (this.queue.length > 0 && !this.queue.every((value, index) => _.isEqual(value, _oldOrder[index]))) {
|
||||
this._dirtyProps.push("queue");
|
||||
}
|
||||
}
|
||||
|
||||
// HACK: sometimes, if we fuck up getting a video, currentSource may become undefined.
|
||||
// So if that happens, log it so we can catch it.
|
||||
if (this.currentSource === undefined) {
|
||||
this.log.error("currentSource is undefined! This is not good.");
|
||||
}
|
||||
|
||||
if (_.isEmpty(this.currentSource)) {
|
||||
if (this.queue.length > 0) {
|
||||
this.currentSource = this.queue.shift();
|
||||
this._dirtyProps.push("queue");
|
||||
}
|
||||
else if (this.isPlaying) {
|
||||
this.isPlaying = false;
|
||||
this.playbackPosition = 0;
|
||||
}
|
||||
}
|
||||
else if (this.playbackStartTime && this.getTruePlaybackPosition(now) > this.currentSource.length) {
|
||||
this.log.debug("Video has ended, playing next video...");
|
||||
if (this.queue.length > 0) {
|
||||
this.currentSource = this.queue.shift();
|
||||
this._dirtyProps.push("queue");
|
||||
this.playbackStartTime = moment();
|
||||
}
|
||||
else {
|
||||
this.currentSource = {};
|
||||
this.isPlaying = false;
|
||||
}
|
||||
this.playbackPosition = 0;
|
||||
}
|
||||
|
||||
// remove empty rooms
|
||||
if (this.clients.length > 0) {
|
||||
this.keepAlivePing = moment();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize all clients in this room by sending a sync message.
|
||||
*/
|
||||
sync() {
|
||||
this._dirtyProps = _.uniq(this._dirtyProps);
|
||||
|
||||
let syncMsg = {
|
||||
action: "sync",
|
||||
name: this.name,
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
isTemporary: this.isTemporary,
|
||||
queueMode: this.queueMode,
|
||||
currentSource: this.currentSource,
|
||||
queue: _.cloneDeep(this.queue),
|
||||
isPlaying: this.isPlaying,
|
||||
playbackPosition: this.playbackPosition,
|
||||
users: [],
|
||||
hasOwner: !!this.owner,
|
||||
playbackStartTime: this.playbackStartTime,
|
||||
};
|
||||
|
||||
for (const client of this.clients) {
|
||||
// make sure the socket is still open
|
||||
if (client.socket.readyState != 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!client.needsFullSync && this._dirtyProps.length == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
syncMsg.users = this.clients.map(c => {
|
||||
return {
|
||||
name: c.username,
|
||||
isYou: client.socket == c.socket,
|
||||
status: c.status,
|
||||
isLoggedIn: c.isLoggedIn,
|
||||
};
|
||||
});
|
||||
|
||||
// include if the user has voted
|
||||
if (this.queueMode === "vote") {
|
||||
syncMsg.queue = this.queue.map(video => {
|
||||
let v = _.cloneDeep(video);
|
||||
v.votes = video.votes ? video.votes.length : 0;
|
||||
v.voted = _.find(video.votes, { userSessionId: client.session.id }) ? true : false;
|
||||
return v;
|
||||
});
|
||||
}
|
||||
|
||||
let dirtySyncMsg = _.pick(syncMsg, _.concat(["action"], this._dirtyProps));
|
||||
if (client.needsFullSync) {
|
||||
this.log.debug("sending full sync message to client");
|
||||
dirtySyncMsg = syncMsg;
|
||||
client.needsFullSync = false;
|
||||
}
|
||||
|
||||
try {
|
||||
client.socket.send(JSON.stringify(dirtySyncMsg));
|
||||
}
|
||||
catch (error) {
|
||||
// ignore errors
|
||||
}
|
||||
}
|
||||
this._dirtyProps = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the room event to all clients.
|
||||
* @param {RoomEvent} event
|
||||
*/
|
||||
sendRoomEvent(event) {
|
||||
this.log.log({ level: "info", roomEvent: event });
|
||||
let msg = {
|
||||
action: "event",
|
||||
event: event,
|
||||
};
|
||||
for (let c of this.clients) {
|
||||
try {
|
||||
c.socket.send(JSON.stringify(msg));
|
||||
}
|
||||
catch (error) {
|
||||
// ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the opposite of the event to undo it.
|
||||
* @param {RoomEvent|Object} event The event to be reverted.
|
||||
*/
|
||||
undoEvent(event, now=moment()) {
|
||||
if (event.eventType === ROOM_EVENT_TYPE.SEEK) {
|
||||
this.playbackPosition = event.parameters.previousPosition;
|
||||
this._dirtyProps.push("playbackPosition");
|
||||
this.playbackStartTime = now.clone();
|
||||
}
|
||||
else if (event.eventType === ROOM_EVENT_TYPE.SKIP) {
|
||||
if (this.currentSource) {
|
||||
this.queue.unshift(this.currentSource); // put current video back onto the top of the queue
|
||||
this._dirtyProps.push("queue");
|
||||
}
|
||||
this.currentSource = event.parameters.video;
|
||||
this.playbackPosition = 0;
|
||||
this._dirtyProps.push("currentSource");
|
||||
this._dirtyProps.push("playbackPosition");
|
||||
}
|
||||
else if (event.eventType === ROOM_EVENT_TYPE.ADD_TO_QUEUE) {
|
||||
if (this.queue.length > 0) {
|
||||
this.removeFromQueue(event.parameters.video);
|
||||
this._dirtyProps.push("queue");
|
||||
}
|
||||
else {
|
||||
this.currentSource = {};
|
||||
this._dirtyProps.push("currentSource");
|
||||
}
|
||||
}
|
||||
else if (event.eventType === ROOM_EVENT_TYPE.REMOVE_FROM_QUEUE) {
|
||||
let newQueue = this.queue.splice(0, event.parameters.queueIdx);
|
||||
newQueue.push(event.parameters.video);
|
||||
newQueue.push(...this.queue);
|
||||
this.queue = newQueue;
|
||||
this._dirtyProps.push("queue");
|
||||
}
|
||||
else {
|
||||
this.log.error(`Can't undo room event with type: ${event.eventType}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an announcement to all clients in this room.
|
||||
* @param {String} text The message to send
|
||||
*/
|
||||
sendAnnouncement(text) {
|
||||
let msg = {
|
||||
action: "announcement",
|
||||
text,
|
||||
};
|
||||
for (let c of this.clients) {
|
||||
try {
|
||||
c.socket.send(JSON.stringify(msg));
|
||||
}
|
||||
catch (error) {
|
||||
// ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a new client connects to this room.
|
||||
* @param {Object} ws Websocket for the client.
|
||||
* @param {Object} req HTTP request used to initiate the connection.
|
||||
*/
|
||||
async onConnectionReceived(ws, req) {
|
||||
if (!(req.session.passport && req.session.passport.user) && !req.session.username) {
|
||||
let username = uniqueNamesGenerator();
|
||||
this.log.debug(`Generated name for new user (on connect): ${username}`);
|
||||
req.session.username = username;
|
||||
req.session.save();
|
||||
}
|
||||
else {
|
||||
log.debug("User is logged in, skipping username generation");
|
||||
}
|
||||
let client = new Client({
|
||||
session: req.session,
|
||||
socket: ws,
|
||||
status: "joined",
|
||||
needsFullSync: true,
|
||||
});
|
||||
if (req.session.passport && req.session.passport.user) {
|
||||
// HACK: for some reason even though we import usermanager at the top of the module, it somehow doesn't exist in this context. But only sometimes? I don't know
|
||||
let usermanager = require("./usermanager.js");
|
||||
client.user = await usermanager.getUser({ id: req.session.passport.user });
|
||||
}
|
||||
this.clients.push(client);
|
||||
this._dirtyProps.push("users");
|
||||
ws.on('message', (message) => {
|
||||
this.onMessageReceived(client, JSON.parse(message));
|
||||
});
|
||||
this.sendRoomEvent(new RoomEvent(this.name, ROOM_EVENT_TYPE.JOIN_ROOM, client.username, {}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when this room receives a message from one of it's users.
|
||||
* @param {Object} client The client that sent the message
|
||||
* @param {Object} message The message that the client sent as a object. Should always have an `action` attribute.
|
||||
*/
|
||||
onMessageReceived(client, msg) {
|
||||
if (msg.action === "play") {
|
||||
this.commitRoomEvent(new RoomEvent(this.name, ROOM_EVENT_TYPE.PLAY, client.username, {}));
|
||||
}
|
||||
else if (msg.action === "pause") {
|
||||
this.commitRoomEvent(new RoomEvent(this.name, ROOM_EVENT_TYPE.PAUSE, client.username, {}));
|
||||
}
|
||||
else if (msg.action === "seek") {
|
||||
if (msg.position === this.playbackPosition) {
|
||||
return;
|
||||
}
|
||||
this.commitRoomEvent(new RoomEvent(this.name, ROOM_EVENT_TYPE.SEEK, client.username, { position: msg.position, previousPosition: this.playbackPosition }));
|
||||
}
|
||||
else if (msg.action === "skip") {
|
||||
this.commitRoomEvent(new RoomEvent(this.name, ROOM_EVENT_TYPE.SKIP, client.username, { video: this.currentSource }));
|
||||
}
|
||||
else if (msg.action === "chat") {
|
||||
let chat = {
|
||||
action: msg.action,
|
||||
from: client.username,
|
||||
text: msg.text,
|
||||
};
|
||||
for (let c of this.clients) {
|
||||
try {
|
||||
c.socket.send(JSON.stringify(chat));
|
||||
}
|
||||
catch (error) {
|
||||
// ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (msg.action === "queue-move") {
|
||||
if (this.queueMode === "vote") {
|
||||
return;
|
||||
}
|
||||
|
||||
let video = this.queue.splice(msg.currentIdx, 1)[0];
|
||||
this.queue.splice(msg.targetIdx, 0, video);
|
||||
this._dirtyProps.push("queue");
|
||||
this.sync();
|
||||
}
|
||||
else if (msg.action === "undo") {
|
||||
if (!msg.event) {
|
||||
this.log.warn("Room event to be undone was not supplied");
|
||||
return;
|
||||
}
|
||||
this.undoEvent(msg.event);
|
||||
this.sync();
|
||||
}
|
||||
else if (msg.action === "status") {
|
||||
this.log.debug(`status: ${client.username} ${msg.status}`);
|
||||
client.status = msg.status;
|
||||
this._dirtyProps.push("users");
|
||||
this.sync();
|
||||
}
|
||||
else {
|
||||
log.warn(`[ws] UNKNOWN ACTION ${msg.action}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ROOM_EVENT_TYPE = {
|
||||
PLAY: "play",
|
||||
PAUSE: "pause",
|
||||
SKIP: "skip",
|
||||
SEEK: "seek",
|
||||
ADD_TO_QUEUE: "addToQueue",
|
||||
REMOVE_FROM_QUEUE: "removeFromQueue",
|
||||
JOIN_ROOM: "joinRoom",
|
||||
LEAVE_ROOM: "leaveRoom",
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents an action that a user performed in a room, like playing, pausing, skipping, adding to the queue, etc.
|
||||
*/
|
||||
class RoomEvent {
|
||||
constructor(roomName, eventType, userName, parameters) {
|
||||
this.roomName = roomName;
|
||||
this.eventType = eventType;
|
||||
this.userName = userName;
|
||||
this.parameters = _.cloneDeep(parameters);
|
||||
}
|
||||
}
|
||||
|
||||
class Client {
|
||||
constructor(args) {
|
||||
this.session = null;
|
||||
this.socket = null;
|
||||
this.status = "?";
|
||||
this.needsFullSync = true;
|
||||
this.user = null;
|
||||
if (args) {
|
||||
Object.assign(this, args);
|
||||
}
|
||||
}
|
||||
|
||||
get username() {
|
||||
return this.user ? this.user.username : this.session.username;
|
||||
}
|
||||
|
||||
get isLoggedIn() {
|
||||
return !!this.user;
|
||||
}
|
||||
}
|
||||
|
||||
class RoomNotFoundException extends Error {
|
||||
constructor(roomName) {
|
||||
super(`The room "${roomName}" could not be found.`);
|
||||
this.name = "RoomNotFoundException";
|
||||
}
|
||||
}
|
||||
|
||||
class RoomAlreadyLoadedException extends Error {
|
||||
constructor(roomName) {
|
||||
super(`The room "${roomName}" is already loaded.`);
|
||||
this.name = "RoomAlreadyLoadedException";
|
||||
}
|
||||
}
|
||||
|
||||
class RoomNameTakenException extends Error {
|
||||
constructor(roomName) {
|
||||
super(`The room "${roomName}" is taken.`);
|
||||
this.name = "RoomNameTakenException";
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
rooms: [],
|
||||
RoomEvent,
|
||||
ROOM_EVENT_TYPE,
|
||||
|
||||
/**
|
||||
* Start the room manager.
|
||||
* @param {Object} httpServer The http server to get websocket connections from.
|
||||
* @param {Object} sessions The session parser that express uses.
|
||||
*/
|
||||
start(httpServer, sessions) {
|
||||
const wss = new WebSocket.Server({ noServer: true });
|
||||
|
||||
httpServer.on('upgrade', (req, socket, head) => {
|
||||
sessions(req, {}, () => {
|
||||
wss.handleUpgrade(req, socket, head, ws => {
|
||||
wss.emit('connection', ws, req);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
log.debug("[ws] CONNECTION ESTABLISHED", ws.protocol, req.url, ws.readyState);
|
||||
|
||||
if (!req.url.startsWith("/api/room/")) {
|
||||
log.error("Closing connection because the connection url was invalid");
|
||||
ws.close(WS_ERROR_INVALID_CONNECTION_URL, "Invalid connection url");
|
||||
return;
|
||||
}
|
||||
let roomName = req.url.replace("/api/room/", "");
|
||||
this.getOrLoadRoom(roomName).then(room => {
|
||||
room.onConnectionReceived(ws, req);
|
||||
}).catch(err => {
|
||||
if (err.name === "RoomNotFoundException") {
|
||||
log.debug("Closing connection because the room doesn't exist");
|
||||
ws.close(WS_ERROR_ROOM_NOT_FOUND, "Room doesn't exist");
|
||||
return;
|
||||
}
|
||||
else {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
redisClient.on("connect", () => {
|
||||
log.info("Connected to redis");
|
||||
});
|
||||
redisClient.on("ready", () => {
|
||||
log.info("Redis client is ready");
|
||||
});
|
||||
redisClient.on('error', err => {
|
||||
log.error(`error event - ${redisClient.host}:${redisClient.port} - ${err}`);
|
||||
});
|
||||
this.getAllLoadedRooms().then(result => {
|
||||
this.rooms = result || [];
|
||||
log.info(`Loaded ${this.rooms.length} rooms from redis`);
|
||||
});
|
||||
|
||||
const nanotimer = new NanoTimer();
|
||||
nanotimer.setInterval(() => {
|
||||
for (const room of this.rooms) {
|
||||
room.update();
|
||||
room.sync();
|
||||
this.unloadIfEmpty(room);
|
||||
}
|
||||
|
||||
this.saveAllLoadedRooms();
|
||||
}, '', '1000m');
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if an empty (no active clients) room has been loaded for longer than a specified time, and unloads it if this is true.
|
||||
* @param {Room} room The room to unload.
|
||||
* @param {Number} time The time in seconds the room must be inactive for it to be unloaded.
|
||||
*/
|
||||
unloadIfEmpty(room, time=240) {
|
||||
if (room.clients.length == 0 &&
|
||||
moment().diff(room.keepAlivePing, 'seconds') > time) {
|
||||
this.unloadRoom(room);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new room using the given name.
|
||||
* @param {string} name The name for the new room
|
||||
* @param {boolean} isTemporary Whether or not the new room is temporary. Temporary rooms do not get stored in the database.
|
||||
* @param {string} visibility Indicates the room's visibility. Only public rooms are shown on the rooms list.
|
||||
*/
|
||||
async createRoom(options, isTemporary=false, visibility="public") {
|
||||
if (typeof options === "string") {
|
||||
let name = options;
|
||||
options = {
|
||||
name,
|
||||
isTemporary,
|
||||
visibility,
|
||||
};
|
||||
}
|
||||
else {
|
||||
options = _.defaults(options, {
|
||||
isTemporary: false,
|
||||
visibility: "public",
|
||||
});
|
||||
if (options.temporary !== undefined) {
|
||||
options.isTemporary = options.temporary;
|
||||
delete options.temporary;
|
||||
}
|
||||
}
|
||||
|
||||
if (_.find(this.rooms, room => room.name === options.name)) {
|
||||
throw new RoomNameTakenException(options.name);
|
||||
}
|
||||
if (await storage.isRoomNameTaken(options.name)) {
|
||||
throw new RoomNameTakenException(options.name);
|
||||
}
|
||||
|
||||
let newRoom = new Room(options);
|
||||
if (options.isTemporary) {
|
||||
// Used to delete temporary rooms after a certain amount of time with no users connected
|
||||
newRoom.keepAlivePing = new Date();
|
||||
}
|
||||
else {
|
||||
await storage.saveRoom(newRoom);
|
||||
}
|
||||
this.rooms.push(newRoom);
|
||||
log.info(`Room created: ${newRoom.name}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all the loaded rooms from redis.
|
||||
* @returns {Promise.<Array.<Room>>}
|
||||
*/
|
||||
getAllLoadedRooms() {
|
||||
return new Promise((resolve, reject) => {
|
||||
redisClient.get("rooms", (err, value) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
let rooms = JSON.parse(value);
|
||||
resolve(rooms.map(room => {
|
||||
delete room.clients;
|
||||
delete room._dirtyProps;
|
||||
room.keepAlivePing = moment();
|
||||
return new Room(room);
|
||||
}));
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Save all the loaded rooms into redis.
|
||||
*/
|
||||
saveAllLoadedRooms() {
|
||||
let rooms = _.cloneDeep(this.rooms).map(room => {
|
||||
delete room.clients;
|
||||
delete room._dirtyProps;
|
||||
delete room.log;
|
||||
return room;
|
||||
});
|
||||
redisClient.set("rooms", JSON.stringify(rooms), err => {
|
||||
if (err) {
|
||||
log.error(`Failed to save rooms to redis: ${err} ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads the Room with the given name from the database. If the
|
||||
* room is already loaded, the promise resolves to the loaded Room.
|
||||
* @param {string} name The name of the room to load.
|
||||
* @returns {Promise} Promise that resolves to a Room.
|
||||
* @throws {RoomNotFoundException}
|
||||
* @throws {RoomAlreadyLoadedException}
|
||||
*/
|
||||
loadRoom(name) {
|
||||
if (_.findIndex(this.rooms, r => r.name === name) >= 0) {
|
||||
throw new RoomAlreadyLoadedException(name);
|
||||
}
|
||||
|
||||
return storage.getRoomByName(name).then(result => {
|
||||
if (!result) {
|
||||
throw new RoomNotFoundException(name);
|
||||
}
|
||||
|
||||
let room = new Room({
|
||||
name: result.name,
|
||||
title: result.title,
|
||||
description: result.description,
|
||||
visibility: result.visibility,
|
||||
isTemporary: false,
|
||||
owner: result.owner,
|
||||
});
|
||||
this.rooms.push(room);
|
||||
return room;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Unloads the room with the given name from memory.
|
||||
* @param {Room} room The room to unload
|
||||
*/
|
||||
unloadRoom(room) {
|
||||
// HACK: for some reason, this became undefined when running unit tests,
|
||||
// even though clients is defined to be an empty list in the constructor.
|
||||
// This is one of the greatest mysteries of our time.
|
||||
if (room.clients) {
|
||||
for (const client of room.clients) {
|
||||
client.socket.send(JSON.stringify({
|
||||
action: "room-unload",
|
||||
}));
|
||||
client.socket.close(WS_ERROR_ROOM_UNLOADED, "Room has been unloaded");
|
||||
}
|
||||
}
|
||||
|
||||
const roomIdx = _.findIndex(this.rooms, r => r.name === (typeof room === "string" ? room : room.name));
|
||||
this.rooms.splice(roomIdx, 1);
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets the Room by name if it's loaded into memory, otherwise returns false.
|
||||
* @param {string} name The name of the room
|
||||
* @returns {(Room|boolean)}
|
||||
*/
|
||||
getLoadedRoom(name) {
|
||||
return new Promise(resolve => {
|
||||
for (const room of this.rooms) {
|
||||
if (room.name === name) {
|
||||
resolve(room);
|
||||
return;
|
||||
}
|
||||
}
|
||||
resolve(false);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets the loaded Room, if its loaded, otherwise grab it from the database.
|
||||
* If the Room can't be found it will throw a RoomNotFoundException.
|
||||
* @param {string} name The name of the room
|
||||
* @throws {RoomNotFoundException}
|
||||
*/
|
||||
getOrLoadRoom(name) {
|
||||
return this.getLoadedRoom(name).then(room => {
|
||||
if (room) {
|
||||
log.debug(`Found room ${room.name} in loaded rooms`);
|
||||
return room;
|
||||
}
|
||||
else {
|
||||
log.debug(`Looking for room ${name} in database`);
|
||||
return this.loadRoom(name);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Sends an announcement to all currently connected clients in all rooms.
|
||||
* @param {String} text The message to send
|
||||
*/
|
||||
sendAnnouncement(text) {
|
||||
log.info(`Sending announcement: ${text}`);
|
||||
for (let room of this.rooms) {
|
||||
room.sendAnnouncement(text);
|
||||
}
|
||||
},
|
||||
};
|
||||
32
opentogethertube/opentogethertube/src/.eslintrc.js
Normal file
32
opentogethertube/opentogethertube/src/.eslintrc.js
Normal file
@@ -0,0 +1,32 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
'extends': [
|
||||
'plugin:vue/base',
|
||||
'plugin:vue/essential',
|
||||
],
|
||||
rules: {
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'vue/attribute-hyphenation': ['error', 'always'],
|
||||
'vue/html-self-closing': ['error', {
|
||||
'html': {
|
||||
'void': 'any',
|
||||
'normal': 'never',
|
||||
'component': 'always',
|
||||
},
|
||||
'svg': 'always',
|
||||
}],
|
||||
'vue/mustache-interpolation-spacing': ['error', 'always'],
|
||||
'vue/no-multi-spaces': ['warn', {
|
||||
'ignoreProperties': false,
|
||||
}],
|
||||
'vue/no-v-html': 'error',
|
||||
'vue/v-bind-style': ['error', 'shorthand'],
|
||||
'vue/v-on-style': ['error', 'shorthand'],
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 6,
|
||||
parser: 'babel-eslint'
|
||||
},
|
||||
};
|
||||
195
opentogethertube/opentogethertube/src/App.vue
Normal file
195
opentogethertube/opentogethertube/src/App.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<v-app id="app">
|
||||
<div class="announcement-container">
|
||||
<v-alert class="announcement" type="warning" border="left" dismissible close-label="Close Announcement" v-model="showAnnouncement" transition="scroll-y-transition">
|
||||
SYSTEM: {{ announcement }}
|
||||
</v-alert>
|
||||
<v-alert class="announcement" type="info" border="left" dismissible close-label="Close Announcement" v-model="shouldAdvertisePermRoom" transition="scroll-y-transition">
|
||||
Come here often? Get a permanent room and bookmark it! Never have to send the room link to your friends ever again!
|
||||
<v-btn text @click="showCreateRoomForm = true">
|
||||
<v-icon>fas fa-plus-square</v-icon> Create Room
|
||||
</v-btn>
|
||||
</v-alert>
|
||||
</div>
|
||||
<v-app-bar app :absolute="!$store.state.fullscreen" :inverted-scroll="$store.state.fullscreen">
|
||||
<v-img :src="require('@/assets/logo.svg')" max-width="32" max-height="32" contain style="margin-right: 8px" />
|
||||
<v-toolbar-title>
|
||||
<router-link class="link-invis" style="margin-right: 10px" to="/">
|
||||
OpenTogetherTube
|
||||
</router-link>
|
||||
</v-toolbar-title>
|
||||
<v-toolbar-items>
|
||||
<v-btn text to="/rooms">Browse</v-btn>
|
||||
<v-btn text to="/faq">FAQ</v-btn>
|
||||
</v-toolbar-items>
|
||||
<v-spacer />
|
||||
<v-toolbar-items>
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ on }">
|
||||
<v-btn text v-on="on">
|
||||
<v-icon>fas fa-plus-square</v-icon> Create Room
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list two-line max-width="400">
|
||||
<v-list-item @click="createTempRoom">
|
||||
<v-list-item-icon>
|
||||
<v-icon>fas fa-plus-square</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Create Temporary Room</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-muted">Start watching videos with your friends ASAP.</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-list-item @click="showCreateRoomForm = true">
|
||||
<v-list-item-icon>
|
||||
<v-icon>fas fa-plus-square</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Create Permanent Room</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-muted">Perfect for frequent visitors.</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn text @click="showLogin = true" v-if="!$store.state.user">
|
||||
Log In
|
||||
</v-btn>
|
||||
<v-menu offset-y v-if="$store.state.user">
|
||||
<template v-slot:activator="{ on }">
|
||||
<v-btn text v-on="on" :key="$store.state.user.username">
|
||||
{{ $store.state.user.username }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list two-line max-width="400">
|
||||
<v-list-item @click="logout">
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Log Out</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-toolbar-items>
|
||||
</v-app-bar>
|
||||
<v-content>
|
||||
<router-view/>
|
||||
</v-content>
|
||||
<v-container>
|
||||
<v-dialog v-model="showCreateRoomForm" persistent max-width="600">
|
||||
<CreateRoomForm @roomCreated="showCreateRoomForm = false" @cancel="showCreateRoomForm = false" />
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
<v-container>
|
||||
<v-dialog v-model="showLogin" max-width="400">
|
||||
<LogInForm @shouldClose="showLogin = false" />
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
<v-overlay :value="isLoadingCreateRoom">
|
||||
<v-container fill-height>
|
||||
<v-row align="center" justify="center">
|
||||
<v-col cols="12" sm="4">
|
||||
<v-progress-circular indeterminate />
|
||||
<v-btn elevation="12" x-large @click="cancelRoom" style="margin-top: 24px">Cancel</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-overlay>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { API } from "@/common-http.js";
|
||||
import CreateRoomForm from "@/components/CreateRoomForm.vue";
|
||||
import LogInForm from "@/components/LogInForm.vue";
|
||||
import RoomUtilsMixin from "@/mixins/RoomUtils.js";
|
||||
|
||||
export default {
|
||||
name: "app",
|
||||
components: {
|
||||
CreateRoomForm,
|
||||
LogInForm,
|
||||
},
|
||||
mixins: [RoomUtilsMixin],
|
||||
data() {
|
||||
return {
|
||||
announcement: null,
|
||||
showAnnouncement: false,
|
||||
showCreateRoomForm: false,
|
||||
shouldAdvertisePermRoom: false,
|
||||
showLogin: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onAnnouncement(text) {
|
||||
this.showAnnouncement = true;
|
||||
this.announcement = text;
|
||||
},
|
||||
logout() {
|
||||
API.post("/user/logout").then(res => {
|
||||
if (res.data.success) {
|
||||
this.$store.commit("LOGOUT");
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
created() {
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
if (document.fullscreenElement) {
|
||||
this.$store.state.fullscreen = true;
|
||||
}
|
||||
else {
|
||||
this.$store.state.fullscreen = false;
|
||||
}
|
||||
});
|
||||
|
||||
this.$events.on("onAnnouncement", this.onAnnouncement);
|
||||
|
||||
if (!window.localStorage.getItem("ackAdvertisePermRoom")) {
|
||||
this.shouldAdvertisePermRoom = true;
|
||||
}
|
||||
console.log("shouldAdvertisePermRoom", this.shouldAdvertisePermRoom);
|
||||
|
||||
// ask the server if we are logged in or not, and update the client to reflect that status.
|
||||
API.get("/user").then(res => {
|
||||
if (res.data.loggedIn) {
|
||||
let user = res.data;
|
||||
delete user.loggedIn;
|
||||
this.$store.commit("LOGIN", user);
|
||||
}
|
||||
});
|
||||
},
|
||||
watch:{
|
||||
shouldAdvertisePermRoom(value) {
|
||||
if (!value) {
|
||||
window.localStorage.setItem("ackAdvertisePermRoom", true);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.link-invis {
|
||||
text-decoration: none;
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.vue-slider-process {
|
||||
background: #ffb300 !important;
|
||||
}
|
||||
|
||||
.announcement-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 10000;
|
||||
|
||||
.announcement {
|
||||
margin: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
8
opentogethertube/opentogethertube/src/assets/logo.svg
Normal file
8
opentogethertube/opentogethertube/src/assets/logo.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg width="128" height="128" version="1" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#">
|
||||
<rect x="49.23" y="45.29" width="78.77" height="70.89" rx="4" ry="4" fill="#626262"/>
|
||||
<rect x="96.49" y="45.29" width="31.50" height="70.89" rx="4" ry="4" fill="#7d4aef"/>
|
||||
<circle cx="112.25" cy="27.56" r="15.75" fill="#7d4aef"/>
|
||||
<rect x="49.23" y="45.29" width="31.51" height="70.89" rx="4" ry="4" fill="#42a5f5"/>
|
||||
<circle cx="64.99" cy="27.56" r="15.75" fill="#42a5f5"/>
|
||||
<path d="m45.42 83.96c-14.641 12.159-27.819 20.389-40.356 30.885-3.2427 2.741-5.0614 0.89044-5.0614-2.0901l0.098499-64.296c-0.004827-3.3706 1.101-4.2348 4.3683-1.6944 14.406 11.201 26.348 19.689 40.754 30.89 2.6849 1.987 2.6903 4.32 0.197 6.3054z" fill="#ffb300"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 786 B |
@@ -0,0 +1,9 @@
|
||||
<svg version="1" viewBox="0 0 320 180" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#">
|
||||
<rect width="320" height="180"/>
|
||||
<rect x="145.23" y="72.08" width="78.77" height="67.92" rx="4" ry="4" fill="#626262"/>
|
||||
<rect x="192.49" y="72.08" width="31.51" height="67.92" rx="4" ry="4" fill="#7d4aef"/>
|
||||
<ellipse cx="208.25" cy="55.09" rx="15.75" ry="15.75" fill="#7d4aef"/>
|
||||
<rect x="145.23" y="72.08" width="31.51" height="67.92" rx="4" ry="4" fill="#42a5f5"/>
|
||||
<ellipse cx="160.99" cy="55.09" rx="15.75" ry="15.75" fill="#42a5f5"/>
|
||||
<path d="m141.42 109.13c-14.641 11.649-27.82 19.534-40.357 29.591-3.2428 2.6261-5.0615 0.85311-5.0615-2.0025l0.0985-61.601c-0.0048-3.2293 1.101-4.0573 4.3684-1.6234 14.406 10.732 26.349 18.864 40.755 29.595 2.685 1.9037 2.6904 4.1389 0.197 6.0411z" fill="#ffb300"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 825 B |
5
opentogethertube/opentogethertube/src/common-http.js
Normal file
5
opentogethertube/opentogethertube/src/common-http.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import axios from "axios";
|
||||
|
||||
export const API = axios.create({
|
||||
baseURL: "/api",
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-form ref="form" @submit="submit" v-model="isValid">
|
||||
<v-card-title>Create a Permanent Room</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field label="Name" hint="Used in the room URL. Can't be changed later." v-model="options.name" required counter="32" :rules="rules.name" @keydown="() => isRoomNameTaken = false" />
|
||||
<v-text-field label="Title" hint="Optional" v-model="options.title" />
|
||||
<v-text-field label="Description" hint="Optional" v-model="options.description" />
|
||||
<v-select label="Visibility" hint="Controls whether or not the room shows up in the room list." :items="[{ text: 'public' }, { text: 'unlisted' }]" v-model="options.visibility" :rules="rules.visibility" />
|
||||
<v-select label="Queue Mode" :items="[{ text: 'manual' }, { text: 'vote' }]" v-model="options.queueMode" :rules="rules.queueMode" />
|
||||
<div :key="error">{{ error }}</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn text @click="submit" role="Submit" :loading="isSubmitting" :disabled="!isValid" color="primary">Create Room</v-btn>
|
||||
<v-btn text @click="$emit('cancel')">Cancel</v-btn>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import RoomUtilsMixin from "@/mixins/RoomUtils.js";
|
||||
|
||||
export default {
|
||||
name: "CreateRoomForm",
|
||||
mixins: [RoomUtilsMixin],
|
||||
data() {
|
||||
return {
|
||||
options: {
|
||||
name: "",
|
||||
title: "",
|
||||
description: "",
|
||||
visibility: "public",
|
||||
queueMode: "manual",
|
||||
},
|
||||
rules: {
|
||||
name: [
|
||||
v => !!v || "Name is required",
|
||||
v => (v && v.length >= 3 && v.length <= 32) || "Name must be between 3 and 32 characters",
|
||||
v => (v && !this.isRoomNameTaken) || "Name is already taken",
|
||||
],
|
||||
// eslint-disable-next-line array-bracket-newline
|
||||
visibility: [
|
||||
// eslint-disable-next-line array-bracket-newline
|
||||
v => (v && ["public", "unlisted"].includes(v)) || "Invalid Visibility",
|
||||
],
|
||||
// eslint-disable-next-line array-bracket-newline
|
||||
queueMode: [
|
||||
// eslint-disable-next-line array-bracket-newline
|
||||
v => (v && ["manual", "vote"].includes(v)) || "Invalid Queue Mode",
|
||||
],
|
||||
},
|
||||
|
||||
isValid: false,
|
||||
isSubmitting: false,
|
||||
isRoomNameTaken: false,
|
||||
error: "",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
this.$refs.form.validate();
|
||||
if (!this.isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.createPermRoom(this.options).then(() => {
|
||||
this.$emit("roomCreated", this.options.name);
|
||||
this.options = {
|
||||
name: "",
|
||||
title: "",
|
||||
description: "",
|
||||
visibility: "public",
|
||||
queueMode: "manual",
|
||||
};
|
||||
}).catch(err => {
|
||||
if (err.response) {
|
||||
if (err.response.status === 400) {
|
||||
if (err.response.data.error.name === "RoomNameTakenException") {
|
||||
this.isRoomNameTaken = true;
|
||||
}
|
||||
this.error = err.response.data.error.message;
|
||||
}
|
||||
else {
|
||||
this.error = "An unknown error occurred. Try again later.";
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.error = err.message;
|
||||
}
|
||||
this.$refs.form.validate();
|
||||
});
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
name() {
|
||||
this.isRoomNameTaken = false;
|
||||
this.error = "";
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="dailymotion">
|
||||
<div id="dailymotion-player"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// import axios from "axios";
|
||||
import { getSdk } from "../util/playerHelper.js";
|
||||
|
||||
const DAILYMOTION_SDK_URL = "https://api.dmcdn.net/all.js";
|
||||
// const DAILYMOTION_OEMBED_API_URL = "http://www.dailymotion.com/services/oembed";
|
||||
|
||||
export default {
|
||||
name: "DailymotionPlayer",
|
||||
props: {
|
||||
videoId: { type: String, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
DM: null,
|
||||
player: null,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
getSdk(DAILYMOTION_SDK_URL, "DM", "dmAsyncInit").then(DM => {
|
||||
this.DM = DM;
|
||||
this.DM.init({
|
||||
status: false,
|
||||
cookie: false,
|
||||
});
|
||||
this.updateIframe();
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
updateIframe() {
|
||||
this.player = new this.DM.player(document.getElementById('dailymotion-player'), {
|
||||
video: this.videoId,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
params: {
|
||||
api: 1,
|
||||
autoplay: false,
|
||||
controls: false,
|
||||
"ui-logo": false,
|
||||
"ui-start-screen-info": false,
|
||||
},
|
||||
events: {
|
||||
apiready: this.onApiReady,
|
||||
video_end: () => this.$emit("end"),
|
||||
playing: () => this.$emit("playing"),
|
||||
pause: () => this.$emit("paused"),
|
||||
waiting: () => this.$emit("buffering"),
|
||||
playback_ready: () => this.$emit("ready"),
|
||||
error: () => this.$emit("error"),
|
||||
},
|
||||
});
|
||||
},
|
||||
play() {
|
||||
return this.player.play();
|
||||
},
|
||||
pause() {
|
||||
return this.player.pause();
|
||||
},
|
||||
getPosition() {
|
||||
return this.player.currentTime;
|
||||
},
|
||||
setPosition(position) {
|
||||
return this.player.seek(position);
|
||||
},
|
||||
setVolume(value) {
|
||||
return this.player.setVolume(value / 100);
|
||||
},
|
||||
onApiReady() {
|
||||
this.$emit("apiready");
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
videoId() {
|
||||
this.updateIframe();
|
||||
this.player.load({ video: this.videoId });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dailymotion {
|
||||
color: #696969;
|
||||
border: 1px solid #666;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="direct">
|
||||
<video id="directplayer" class="video-js vjs-default-skin" :key="videoUrl">
|
||||
</video>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import videojs from "video.js";
|
||||
|
||||
export default {
|
||||
name: "DirectPlayer",
|
||||
props: {
|
||||
videoUrl: { type: String, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
player: null,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.player = videojs(document.getElementById("directplayer"), {
|
||||
controls: false,
|
||||
responsive: true,
|
||||
loop: false,
|
||||
poster: this.$store.state.room.currentSource.thumbnail,
|
||||
});
|
||||
this.player.on("ready", () => this.$emit("ready"));
|
||||
this.player.on("ended", () => this.$emit("end"));
|
||||
this.player.on("playing", () => this.$emit("playing"));
|
||||
this.player.on("pause", () => this.$emit("paused"));
|
||||
this.player.on("play", () => this.$emit("waiting"));
|
||||
this.player.on("stalled", () => this.$emit("buffering"));
|
||||
this.player.on("error", () => this.$emit("error"));
|
||||
this.loadVideoSource();
|
||||
this.player.load();
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.player) {
|
||||
this.player.dispose();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
play() {
|
||||
return this.player.play();
|
||||
},
|
||||
pause() {
|
||||
return this.player.pause();
|
||||
},
|
||||
setVolume(volume) {
|
||||
return this.player.volume(volume / 100);
|
||||
},
|
||||
getPosition() {
|
||||
return this.player.currentTime();
|
||||
},
|
||||
setPosition(position) {
|
||||
return this.player.currentTime(position);
|
||||
},
|
||||
loadVideoSource() {
|
||||
this.player.src({
|
||||
src: this.videoUrl,
|
||||
type: this.$store.state.room.currentSource.mime,
|
||||
});
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
videoUrl() {
|
||||
this.loadVideoSource();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import url("https://vjs.zencdn.net/5.4.6/video-js.min.css");
|
||||
|
||||
.video-js {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div class="googledrive">
|
||||
<video id="gdriveplayer" class="video-js vjs-default-skin" :key="videoId">
|
||||
<!-- <source :src="videoSource" :type="$store.state.room.currentSource.mime" /> -->
|
||||
</video>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import videojs from "video.js";
|
||||
|
||||
export default {
|
||||
name: "GoogleDrivePlayer",
|
||||
props: {
|
||||
videoId: { type: String, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
player: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
videoSource() {
|
||||
// Yes, we send the google drive api key to the client. This is because we need to get the download link, but we can only do that
|
||||
// by authenticating with google, either by api key or by having people sign in with google. This is easier, and not really a problem
|
||||
// because we have 1,000,000,000 google drive api quota and the api methods we use don't cost that much. And this means we don't have
|
||||
// to waste bandwidth streaming video to clients.
|
||||
return `https://www.googleapis.com/drive/v3/files/${this.videoId}?key=${process.env.GOOGLE_DRIVE_API_KEY}&alt=media&aknowledgeAbuse=true`;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.player = videojs(document.getElementById("gdriveplayer"), {
|
||||
controls: false,
|
||||
responsive: true,
|
||||
loop: false,
|
||||
poster: this.$store.state.room.currentSource.thumbnail,
|
||||
});
|
||||
this.player.on("ready", () => this.$emit("ready"));
|
||||
this.player.on("ended", () => this.$emit("end"));
|
||||
this.player.on("playing", () => this.$emit("playing"));
|
||||
this.player.on("pause", () => this.$emit("paused"));
|
||||
this.player.on("play", () => this.$emit("waiting"));
|
||||
this.player.on("stalled", () => this.$emit("buffering"));
|
||||
this.player.on("error", () => this.$emit("error"));
|
||||
this.loadVideoSource();
|
||||
this.player.load();
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.player) {
|
||||
this.player.dispose();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
play() {
|
||||
return this.player.play();
|
||||
},
|
||||
pause() {
|
||||
return this.player.pause();
|
||||
},
|
||||
setVolume(volume) {
|
||||
return this.player.volume(volume / 100);
|
||||
},
|
||||
getPosition() {
|
||||
return this.player.currentTime();
|
||||
},
|
||||
setPosition(position) {
|
||||
return this.player.currentTime(position);
|
||||
},
|
||||
loadVideoSource() {
|
||||
this.player.src({
|
||||
src: this.videoSource,
|
||||
type: this.$store.state.room.currentSource.mime,
|
||||
});
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
videoId() {
|
||||
this.loadVideoSource();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import url("https://vjs.zencdn.net/5.4.6/video-js.min.css");
|
||||
|
||||
.video-js {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
217
opentogethertube/opentogethertube/src/components/LogInForm.vue
Normal file
217
opentogethertube/opentogethertube/src/components/LogInForm.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<v-sheet>
|
||||
<v-tabs v-model="mode">
|
||||
<v-tab key="login">Log In</v-tab>
|
||||
<v-tab key="register">Register</v-tab>
|
||||
</v-tabs>
|
||||
<v-tabs-items v-model="mode">
|
||||
<v-tab-item>
|
||||
<v-card>
|
||||
<v-form ref="loginForm" @submit="login" v-model="loginValid" :lazy-validation="false">
|
||||
<v-card-title>
|
||||
Log in
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-text-field :loading="isLoading" label="Email" required v-model="email" :error-messages="logInFailureMessage" :rules="emailRules" />
|
||||
<v-text-field :loading="isLoading" label="Password" type="password" required v-model="password" :error-messages="logInFailureMessage" />
|
||||
</v-row>
|
||||
<v-row v-if="logInFailureMessage">
|
||||
{{ logInFailureMessage }}
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn color="primary" :loading="isLoading" @click="login" :disabled="!loginValid">Log in</v-btn>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</v-tab-item>
|
||||
<v-tab-item>
|
||||
<v-card>
|
||||
<v-form ref="registerForm" @submit="register" v-model="registerValid" :lazy-validation="false">
|
||||
<v-card-title>
|
||||
Register
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-text-field :loading="isLoading" label="Email" required v-model="email" :error-messages="registerFieldErrors.email" :rules="emailRules" />
|
||||
<v-text-field :loading="isLoading" label="Username" required v-model="username" :error-messages="registerFieldErrors.username" :rules="usernameRules" />
|
||||
<v-text-field :loading="isLoading" label="Password" type="password" required v-model="password" :error-messages="registerFieldErrors.password" :rules="passwordRules" counter />
|
||||
<v-text-field :loading="isLoading" label="Retype Password" type="password" required v-model="password2" :error-messages="registerFieldErrors.password2" :rules="retypePasswordRules" />
|
||||
</v-row>
|
||||
<v-row v-if="registerFailureMessage">
|
||||
{{ registerFailureMessage }}
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn color="primary" :loading="isLoading" @click="register" :disabled="!registerValid">Register</v-btn>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</v-tab-item>
|
||||
</v-tabs-items>
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { API } from "@/common-http.js";
|
||||
import isEmail from 'validator/es/lib/isEmail';
|
||||
|
||||
export default {
|
||||
name: "LogIn",
|
||||
data() {
|
||||
return {
|
||||
email: "",
|
||||
username: "",
|
||||
password: "",
|
||||
password2: "",
|
||||
|
||||
mode: "",
|
||||
isLoading: false,
|
||||
logInFailureMessage: "",
|
||||
registerFailureMessage: "",
|
||||
|
||||
loginValid: false,
|
||||
registerValid: false,
|
||||
registerFieldErrors: {
|
||||
email: "",
|
||||
username: "",
|
||||
password: "",
|
||||
password2: "",
|
||||
},
|
||||
|
||||
emailRules: [
|
||||
v => !!v || "Email is required",
|
||||
v => v && isEmail(v) || "Must be a valid email",
|
||||
],
|
||||
usernameRules: [
|
||||
// eslint-disable-next-line array-bracket-newline
|
||||
v => !!v || "Username is required",
|
||||
],
|
||||
passwordRules: [
|
||||
v => !!v || "Password is required",
|
||||
v => v && v.length >= 10 || process.env.NODE_ENV === "development" && v === "1" || "Password must be at least 10 characters long",
|
||||
],
|
||||
retypePasswordRules: [
|
||||
v => !!v || "Please retype your password",
|
||||
v => v === this.password || "Passwords must match",
|
||||
],
|
||||
};
|
||||
},
|
||||
created() {
|
||||
if (this.$store.state.username) {
|
||||
this.username = this.$store.state.username;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
login() {
|
||||
this.$refs.loginForm.validate();
|
||||
if (!this.loginValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
this.logInFailureMessage = "";
|
||||
API.post("/user/login", { email: this.email, password: this.password }).then(resp => {
|
||||
this.isLoading = false;
|
||||
if (resp.data.success) {
|
||||
console.log("Log in success");
|
||||
this.$store.commit("LOGIN", resp.data.user);
|
||||
this.$emit("shouldClose");
|
||||
this.email = "";
|
||||
this.password = "";
|
||||
}
|
||||
else {
|
||||
console.log("Log in failed");
|
||||
this.logInFailureMessage = "Something weird happened, but you might be logged in? Refresh the page.";
|
||||
}
|
||||
}).catch(err => {
|
||||
this.isLoading = false;
|
||||
if (err.response && !err.response.data.success) {
|
||||
if (err.response.data.error) {
|
||||
this.logInFailureMessage = err.response.data.error.message;
|
||||
}
|
||||
else {
|
||||
this.logInFailureMessage = "Failed to log in, but the server didn't say why. Report this as a bug.";
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.log("could not log in", err, err.response);
|
||||
this.logInFailureMessage = "Failed to log in, and I don't know why. Report this as a bug.";
|
||||
}
|
||||
});
|
||||
},
|
||||
register() {
|
||||
this.$refs.registerForm.validate();
|
||||
if (!this.registerValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
this.registerFailureMessage = "";
|
||||
this.registerFieldErrors = {
|
||||
email: "",
|
||||
username: "",
|
||||
password: "",
|
||||
password2: "",
|
||||
};
|
||||
API.post("/user/register", { email: this.email, username: this.username, password: this.password }).then(resp => {
|
||||
this.isLoading = false;
|
||||
if (resp.data.success) {
|
||||
console.log("Registeration success");
|
||||
this.$store.commit("LOGIN", resp.data.user);
|
||||
this.$emit("shouldClose");
|
||||
this.email = "";
|
||||
this.username = "";
|
||||
this.password = "";
|
||||
this.password2 = "";
|
||||
}
|
||||
else {
|
||||
console.log("Registeration failed");
|
||||
this.registerFailureMessage = "Something weird happened, but you might be logged in? Refresh the page.";
|
||||
}
|
||||
}).catch(err => {
|
||||
this.isLoading = false;
|
||||
if (err.response && !err.response.data.success) {
|
||||
if (err.response.data.error) {
|
||||
if (err.response.data.error.name === "AlreadyInUse") {
|
||||
if (err.response.data.error.fields.includes("email")) {
|
||||
this.registerFieldErrors.email = "Already in use.";
|
||||
}
|
||||
if (err.response.data.error.fields.includes("username")) {
|
||||
this.registerFieldErrors.username = "Already in use.";
|
||||
}
|
||||
}
|
||||
this.registerFailureMessage = err.response.data.error.message;
|
||||
}
|
||||
else {
|
||||
this.registerFailureMessage = "Failed to register, but the server didn't say why. Report this as a bug.";
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.log("could not register", err);
|
||||
this.registerFailureMessage = "Failed to register, and I don't know why. Check the console and report this as a bug.";
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
email() {
|
||||
this.logInFailureMessage = "";
|
||||
},
|
||||
password() {
|
||||
this.logInFailureMessage = "";
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<v-sheet class="mt-2 video" hover>
|
||||
<div class="img-container">
|
||||
<v-img :src="thumbnailSource" :lazy-src="require('@/assets/placeholder.svg')" aspect-ratio="1.8" @error="onThumbnailError">
|
||||
<span class="drag-handle" v-if="!isPreview && $store.state.room.queueMode === 'manual'">
|
||||
<v-icon>fas fa-align-justify</v-icon>
|
||||
</span>
|
||||
<span class="video-length">{{ videoLength }}</span>
|
||||
</v-img>
|
||||
</div>
|
||||
<div class="meta-container">
|
||||
<div>
|
||||
<div class="video-title" no-gutters>{{ item.title }}</div>
|
||||
<div class="description text-truncate" no-gutters>{{ item.description }}</div>
|
||||
<div v-if="item.service === 'googledrive'" class="experimental">Experimental support for this service! Expect it to break a lot.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: center; flex-direction: column">
|
||||
<div class="button-container">
|
||||
<v-btn @click="vote" :loading="isLoadingVote" :color="item.voted ? 'red' : 'green'" v-if="!isPreview && $store.state.room.queueMode === 'vote'">
|
||||
<span>{{ item.votes ? item.votes : 0 }}</span>
|
||||
<v-icon style="font-size: 18px; margin: 0 4px">fas fa-thumbs-up</v-icon>
|
||||
<span class="vote-text">{{ item.voted ? "Unvote" : "Vote" }}</span>
|
||||
</v-btn>
|
||||
<v-btn icon :loading="isLoadingAdd" v-if="isPreview" @click="addToQueue">
|
||||
<v-icon v-if="hasBeenAdded">fas fa-check</v-icon>
|
||||
<v-icon v-else>fas fa-plus</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon :loading="isLoadingAdd" v-else @click="removeFromQueue">
|
||||
<v-icon>fas fa-trash</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { API } from "@/common-http.js";
|
||||
import { secondsToTimestamp } from "@/timestamp.js";
|
||||
|
||||
export default {
|
||||
name: "VideoQueueItem",
|
||||
props: {
|
||||
item: { type: Object, required: true },
|
||||
isPreview: { type: Boolean, default: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLoadingAdd: false,
|
||||
isLoadingVote: false,
|
||||
hasBeenAdded: false,
|
||||
thumbnailHasError: false,
|
||||
};
|
||||
},
|
||||
computed:{
|
||||
videoLength() {
|
||||
return secondsToTimestamp(this.item.length);
|
||||
},
|
||||
thumbnailSource() {
|
||||
return !this.thumbnailHasError && this.item.thumbnail ? this.item.thumbnail : require('@/assets/placeholder.svg');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getPostData() {
|
||||
let data = {
|
||||
service: this.item.service,
|
||||
id: this.item.id,
|
||||
};
|
||||
if (this.item.service === "direct") {
|
||||
data = {
|
||||
service: this.item.service,
|
||||
id: this.item.url,
|
||||
};
|
||||
}
|
||||
console.log(data);
|
||||
return data;
|
||||
},
|
||||
addToQueue() {
|
||||
this.isLoadingAdd = true;
|
||||
API.post(`/room/${this.$route.params.roomId}/queue`, this.getPostData()).then(() => {
|
||||
this.isLoadingAdd = false;
|
||||
this.hasBeenAdded = true;
|
||||
});
|
||||
},
|
||||
removeFromQueue() {
|
||||
this.isLoadingAdd = true;
|
||||
API.delete(`/room/${this.$route.params.roomId}/queue`, {
|
||||
data: this.getPostData(),
|
||||
}).then(() => {
|
||||
this.isLoadingAdd = false;
|
||||
});
|
||||
},
|
||||
vote() {
|
||||
this.isLoadingVote = true;
|
||||
if (!this.item.voted) {
|
||||
API.post(`/room/${this.$route.params.roomId}/vote`, this.getPostData()).then(() => {
|
||||
this.isLoadingVote = false;
|
||||
this.item.voted = true;
|
||||
});
|
||||
}
|
||||
else {
|
||||
API.delete(`/room/${this.$route.params.roomId}/vote`, { data: this.getPostData() }).then(() => {
|
||||
this.isLoadingVote = false;
|
||||
this.item.voted = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
onThumbnailError() {
|
||||
this.thumbnailHasError = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../variables.scss";
|
||||
|
||||
.video {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-height: 111px;
|
||||
|
||||
> * {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.meta-container {
|
||||
flex-grow: 1;
|
||||
margin: 0 10px;
|
||||
|
||||
> div {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
min-width: 20%;
|
||||
width: 30%;
|
||||
|
||||
.video-title, .experimental {
|
||||
font-size: 1.25rem;
|
||||
@media (max-width: $sm-max) {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.description {
|
||||
flex-grow: 1;
|
||||
font-size: 0.9rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@media (max-width: $sm-max) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.img-container {
|
||||
width: 200px;
|
||||
max-width: 200px;
|
||||
@media (max-width: $sm-max) {
|
||||
max-width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.button-container {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
@media (max-width: $sm-max) {
|
||||
.vote-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 40%;
|
||||
height: 100.5%;
|
||||
background: linear-gradient(90deg, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.7) 40%, rgba(0,0,0,0) 100%);
|
||||
|
||||
opacity: 0;
|
||||
|
||||
transition: all 0.4s ease;
|
||||
|
||||
* {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 12px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.drag-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-length {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
padding: 2px 5px;
|
||||
border-top-left-radius: 3px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="vimeo" id="vimeo-player" v-html="iframe"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios";
|
||||
import vimeo from "@vimeo/player";
|
||||
|
||||
const VIMEO_OEMBED_API_URL = "https://vimeo.com/api/oembed.json";
|
||||
|
||||
export default {
|
||||
name: "VimeoPlayer",
|
||||
props: {
|
||||
videoId: { type: String, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
iframe: null,
|
||||
|
||||
isBuffering: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
player() {
|
||||
return new vimeo("vimeo-player");
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.updateIframe();
|
||||
},
|
||||
methods: {
|
||||
updateIframe() {
|
||||
axios.get(`${VIMEO_OEMBED_API_URL}?url=https://vimeo.com/${this.videoId}&responsive=true&portrait=false&controls=false`).then(res => {
|
||||
this.iframe = res.data.html;
|
||||
setTimeout(() => {
|
||||
this.player.on("play", () => this.$emit("playing"));
|
||||
this.player.on("pause", () => this.$emit("paused"));
|
||||
this.player.on("loaded", () => this.$emit("ready"));
|
||||
this.player.on("bufferstart", () => {
|
||||
this.isBuffering = true;
|
||||
this.$emit("buffering");
|
||||
});
|
||||
this.player.on("bufferend", () => {
|
||||
this.isBuffering = false;
|
||||
this.$emit("ready");
|
||||
});
|
||||
this.player.on("error", () => this.$emit("error"));
|
||||
}, 0);
|
||||
});
|
||||
},
|
||||
play() {
|
||||
return this.player.play();
|
||||
},
|
||||
pause() {
|
||||
return this.player.pause();
|
||||
},
|
||||
getPosition() {
|
||||
return this.player.getCurrentTime();
|
||||
},
|
||||
setPosition(position) {
|
||||
return this.player.setCurrentTime(position + (this.$store.state.room.isPlaying && this.isBuffering ? 1 : 0));
|
||||
},
|
||||
setVolume(value) {
|
||||
return this.player.setVolume(value / 100);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
videoId() {
|
||||
this.updateIframe();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vimeo {
|
||||
color: #696969;
|
||||
border: 1px solid #666;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="youtube">
|
||||
<youtube fit-parent resize :video-id="videoId" ref="youtubeplayer" :player-vars="{ controls: 0, disablekb: 1 }" @playing="$emit('playing')" @paused="$emit('paused')" @ready="onReady" @buffering="$emit('buffering')" @cued="$emit('ready')" @error="$emit('error')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "YoutubePlayer",
|
||||
props: {
|
||||
videoId: { type: String, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
play() {
|
||||
this.$refs.youtubeplayer.player.playVideo();
|
||||
},
|
||||
pause() {
|
||||
this.$refs.youtubeplayer.player.pauseVideo();
|
||||
},
|
||||
async getPosition() {
|
||||
return await this.$refs.youtubeplayer.player.getCurrentTime();
|
||||
},
|
||||
setPosition(position) {
|
||||
return this.$refs.youtubeplayer.player.seekTo(position);
|
||||
},
|
||||
setVolume(volume) {
|
||||
this.$refs.youtubeplayer.player.setVolume(volume);
|
||||
},
|
||||
|
||||
onReady() {
|
||||
this.$refs.youtubeplayer.player.loadVideoById(this.$store.state.room.currentSource.id);
|
||||
this.$emit('apiready');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.youtube-player {
|
||||
position: relative;
|
||||
padding-bottom: 56.25%;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
|
||||
iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
38
opentogethertube/opentogethertube/src/main.js
Normal file
38
opentogethertube/opentogethertube/src/main.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import Vue from 'vue';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import store from './store';
|
||||
|
||||
import VueGtag from "vue-gtag";
|
||||
Vue.use(VueGtag, {
|
||||
config: { id: "UA-148983263-2" },
|
||||
}, router);
|
||||
|
||||
import VueEvents from 'vue-events';
|
||||
Vue.use(VueEvents);
|
||||
|
||||
import VueNativeWebsocket from "vue-native-websocket";
|
||||
Vue.use(VueNativeWebsocket, `ws://${window.location.host}/api`, {
|
||||
store: store,
|
||||
format: 'json',
|
||||
reconnection: true,
|
||||
reconnectionDelay: 3000,
|
||||
connectManually: true,
|
||||
});
|
||||
|
||||
import vuetify from '@/plugins/vuetify';
|
||||
|
||||
// TODO: use a different solution that supports code splitting
|
||||
import VueYoutube from 'vue-youtube';
|
||||
Vue.use(VueYoutube);
|
||||
|
||||
import 'vue-slider-component/theme/default.css';
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
|
||||
new Vue({
|
||||
vuetify,
|
||||
store,
|
||||
router,
|
||||
render: h => h(App),
|
||||
}).$mount('#app');
|
||||
50
opentogethertube/opentogethertube/src/mixins/RoomUtils.js
Normal file
50
opentogethertube/opentogethertube/src/mixins/RoomUtils.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { API } from "@/common-http.js";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
isLoadingCreateRoom: false,
|
||||
cancelledRoomCreation: false,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.$events.on("onRoomCreated", this.onRoomCreated);
|
||||
},
|
||||
methods: {
|
||||
createTempRoom() {
|
||||
this.isLoadingCreateRoom = true;
|
||||
this.cancelledRoomCreation = false;
|
||||
return API.post("/room/generate").then(res => {
|
||||
if (!this.cancelledRoomCreation) {
|
||||
this.isLoadingCreateRoom = false;
|
||||
this.cancelledRoomCreation = false;
|
||||
this.$events.fire("onRoomCreated", res.data.room);
|
||||
}
|
||||
});
|
||||
},
|
||||
createPermRoom(options) {
|
||||
this.isLoadingCreateRoom = true;
|
||||
this.cancelledRoomCreation = false;
|
||||
return API.post(`/room/create`, {
|
||||
...options,
|
||||
temporary: false,
|
||||
}).then(() => {
|
||||
if (!this.cancelledRoomCreation) {
|
||||
this.isLoadingCreateRoom = false;
|
||||
this.cancelledRoomCreation = false;
|
||||
this.$events.fire("onRoomCreated", options.name);
|
||||
}
|
||||
}).catch(err => {
|
||||
this.isLoadingCreateRoom = false;
|
||||
throw err;
|
||||
});
|
||||
},
|
||||
cancelRoom() {
|
||||
this.cancelledRoomCreation = true;
|
||||
this.isLoadingCreateRoom = false;
|
||||
},
|
||||
onRoomCreated(roomName) {
|
||||
this.$router.push(`/room/${roomName}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
21
opentogethertube/opentogethertube/src/plugins/vuetify.js
Normal file
21
opentogethertube/opentogethertube/src/plugins/vuetify.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import Vue from 'vue';
|
||||
import Vuetify from 'vuetify/lib';
|
||||
import 'vuetify/dist/vuetify.min.css';
|
||||
import '@mdi/font/css/materialdesignicons.css';
|
||||
import '@fortawesome/fontawesome-free/css/all.css';
|
||||
Vue.use(Vuetify);
|
||||
|
||||
export default new Vuetify({
|
||||
icons: {
|
||||
iconfont: "fa",
|
||||
},
|
||||
theme: {
|
||||
dark: true,
|
||||
themes: {
|
||||
dark: {
|
||||
primary: "#ffb300", // orange
|
||||
secondary: "#42A5F5", // blue
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
44
opentogethertube/opentogethertube/src/router.js
Normal file
44
opentogethertube/opentogethertube/src/router.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import Vue from 'vue';
|
||||
import Router from 'vue-router';
|
||||
|
||||
Vue.use(Router);
|
||||
|
||||
export default new Router({
|
||||
mode: 'history',
|
||||
base: process.env.BASE_URL,
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
// route level code-splitting
|
||||
// this generates a separate chunk (home.[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import(/* webpackChunkName: "home" */ './views/Home.vue'),
|
||||
},
|
||||
{
|
||||
path: '/rooms',
|
||||
name: 'room-list',
|
||||
component: () => import(/* webpackChunkName: "roomlist" */ './views/RoomList.vue'),
|
||||
},
|
||||
{
|
||||
path: '/room/:roomId',
|
||||
name: 'room',
|
||||
component: () => import(/* webpackChunkName: "room" */ './views/Room.vue'),
|
||||
},
|
||||
{
|
||||
path: '/faq',
|
||||
name: 'faq',
|
||||
component: () => import(/* webpackChunkName: "faq" */ './views/Faq.vue'),
|
||||
},
|
||||
{
|
||||
path: '/privacypolicy',
|
||||
name: 'privacypolicy',
|
||||
component: () => import(/* webpackChunkName: "legal" */ './views/Privacy.vue'),
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
name: 'not-found',
|
||||
component: () => import(/* webpackChunkName: "not-found" */ './views/NotFound.vue'),
|
||||
},
|
||||
],
|
||||
});
|
||||
123
opentogethertube/opentogethertube/src/store.js
Normal file
123
opentogethertube/opentogethertube/src/store.js
Normal file
@@ -0,0 +1,123 @@
|
||||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import _ from 'lodash';
|
||||
import { API } from './common-http.js';
|
||||
|
||||
Vue.use(Vuex);
|
||||
|
||||
export default new Vuex.Store({
|
||||
state: {
|
||||
fullscreen: false,
|
||||
socket: {
|
||||
isConnected: false,
|
||||
message: '',
|
||||
reconnectError: false,
|
||||
},
|
||||
joinFailureReason: null,
|
||||
production: process.env.NODE_ENV === 'production',
|
||||
username: null,
|
||||
user: null,
|
||||
room: {
|
||||
name: "",
|
||||
title: "",
|
||||
description: "",
|
||||
isTemporary: false,
|
||||
queueMode: "manual",
|
||||
currentSource: {},
|
||||
queue: [],
|
||||
isPlaying: false,
|
||||
playbackPosition: 0,
|
||||
hasOwner: false,
|
||||
chatMessages: [],
|
||||
events: [],
|
||||
},
|
||||
},
|
||||
mutations:{
|
||||
SOCKET_ONOPEN (state, event) {
|
||||
console.log("socket open");
|
||||
state.joinFailureReason = null;
|
||||
Vue.prototype.$socket = event.currentTarget;
|
||||
state.socket.isConnected = true;
|
||||
state.room.chatMessages = [];
|
||||
state.room.events = [];
|
||||
let username = window.localStorage.getItem("username"); // no longer used, eventually can be removed
|
||||
if (!state.user && username) {
|
||||
window.localStorage.removeItem('username');
|
||||
state.username = username;
|
||||
API.post("/user", { username });
|
||||
}
|
||||
},
|
||||
SOCKET_ONCLOSE (state, event) {
|
||||
console.log("socket close", event);
|
||||
state.socket.isConnected = false;
|
||||
if (event.code == 4002) {
|
||||
state.joinFailureReason = "Room does not exist.";
|
||||
Vue.prototype.$disconnect();
|
||||
Vue.prototype.$events.fire("roomJoinFailure", { reason: "Room does not exist." });
|
||||
}
|
||||
},
|
||||
SOCKET_ONERROR (state, event) {
|
||||
console.error(state, event);
|
||||
},
|
||||
// default handler called for all methods
|
||||
SOCKET_ONMESSAGE (state, message) {
|
||||
console.log("socket message");
|
||||
state.socket.message = message;
|
||||
},
|
||||
// mutations for reconnect methods
|
||||
SOCKET_RECONNECT(state, count) {
|
||||
console.info("reconnect", state, count);
|
||||
},
|
||||
SOCKET_RECONNECT_ERROR(state) {
|
||||
state.socket.reconnectError = true;
|
||||
},
|
||||
PLAYBACK_STATUS(state, message) {
|
||||
Vue.prototype.$socket.sendObj({ action: "status", status: message });
|
||||
},
|
||||
LOGIN(state, user) {
|
||||
state.user = user;
|
||||
},
|
||||
LOGOUT(state) {
|
||||
state.user = null;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
sendMessage(context, message) {
|
||||
Vue.prototype.$socket.send(message);
|
||||
},
|
||||
sync(context, message) {
|
||||
console.debug("SYNC", message);
|
||||
delete message.action;
|
||||
if (message.isPlaying !== undefined && this.state.room.isPlaying != message.isPlaying) {
|
||||
if (message.isPlaying) {
|
||||
Vue.prototype.$events.emit("playVideo");
|
||||
}
|
||||
else {
|
||||
Vue.prototype.$events.emit("pauseVideo");
|
||||
}
|
||||
}
|
||||
// HACK: this lets vue detect the changes and react to them
|
||||
// https://vuejs.org/v2/guide/reactivity.html#Change-Detection-Caveats
|
||||
this.state.room = Object.assign({}, this.state.room, message);
|
||||
|
||||
if (!this.user) {
|
||||
this.state.username = _.find(this.state.room.users, { isYou: true }).name;
|
||||
}
|
||||
|
||||
Vue.prototype.$events.emit('onSync');
|
||||
},
|
||||
chat(context, message) {
|
||||
this.state.room.chatMessages.push(message);
|
||||
},
|
||||
event(context, message) {
|
||||
let event = message.event;
|
||||
event.isVisible = true;
|
||||
event.isUndoable = event.eventType === 'seek' || event.eventType === 'skip' || event.eventType === 'addToQueue' || event.eventType === 'removeFromQueue';
|
||||
this.state.room.events.push(event);
|
||||
Vue.prototype.$events.emit('onRoomEvent', message.event);
|
||||
},
|
||||
announcement(context, message) {
|
||||
Vue.prototype.$events.emit('onAnnouncement', message.text);
|
||||
},
|
||||
},
|
||||
});
|
||||
12
opentogethertube/opentogethertube/src/timestamp.js
Normal file
12
opentogethertube/opentogethertube/src/timestamp.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import moment from 'moment';
|
||||
|
||||
export function secondsToTimestamp(seconds) { //formats seconds into mm:ss if less than an hour, hh:mm:ss if greater than an hour
|
||||
const posSeconds = Math.abs(seconds);
|
||||
const timeString = new Date(posSeconds * 1000).toISOString();
|
||||
const subTimeString = posSeconds >= 3600 ? timeString.substr(11, 8) : timeString.substr(14, 5);
|
||||
return seconds < 0 ? "-" + subTimeString : subTimeString;
|
||||
}
|
||||
|
||||
export function calculateCurrentPosition(start_time, now_time, offset) {
|
||||
return offset + moment(now_time).diff(start_time, "seconds");
|
||||
}
|
||||
54
opentogethertube/opentogethertube/src/util/playerHelper.js
Normal file
54
opentogethertube/opentogethertube/src/util/playerHelper.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import loadScript from "load-script";
|
||||
|
||||
/**
|
||||
* A collection of helper functions to make adding support for new services easier.
|
||||
*/
|
||||
|
||||
const requests = {};
|
||||
/**
|
||||
* Gets a library intended to be embedded normally when in vanilla JS, and makes it so that we can use it inside Vue.
|
||||
*
|
||||
* Shamelessly copied from https://github.com/CookPete/react-player/blob/9be7a9c9d24d08801b1f31f93bdfabf45ea1bf83/src/utils.js#L64
|
||||
*
|
||||
* @param {String} url The url to the JS SDK.
|
||||
* @param {*} sdkGlobal
|
||||
* @param {String} sdkReady Name of the function that is automatically called when the api is ready to be used.
|
||||
*/
|
||||
export function getSdk(url, sdkGlobal, sdkReady=null, isLoaded=() => true, fetchScript=loadScript) {
|
||||
if (window[sdkGlobal] && isLoaded(window[sdkGlobal])) {
|
||||
return Promise.resolve(window[sdkGlobal]);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
// If we are already loading the SDK, add the resolve and reject
|
||||
// functions to the existing array of requests
|
||||
if (requests[url]) {
|
||||
requests[url].push({ resolve, reject });
|
||||
return;
|
||||
}
|
||||
requests[url] = [{ resolve, reject }];
|
||||
const onLoaded = sdk => {
|
||||
// When loaded, resolve all pending request promises
|
||||
requests[url].forEach(request => request.resolve(sdk));
|
||||
};
|
||||
if (sdkReady) {
|
||||
const previousOnReady = window[sdkReady];
|
||||
window[sdkReady] = () => {
|
||||
if (previousOnReady) {
|
||||
previousOnReady();
|
||||
}
|
||||
onLoaded(window[sdkGlobal]);
|
||||
};
|
||||
}
|
||||
fetchScript(url, err => {
|
||||
if (err) {
|
||||
// Loading the SDK failed – reject all requests and
|
||||
// reset the array of requests for this SDK
|
||||
requests[url].forEach(request => request.reject(err));
|
||||
requests[url] = null;
|
||||
}
|
||||
else if (!sdkReady) {
|
||||
onLoaded(window[sdkGlobal]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
10
opentogethertube/opentogethertube/src/variables.scss
Normal file
10
opentogethertube/opentogethertube/src/variables.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
$xs-max: 600px;
|
||||
$sm-min: $xs-max;
|
||||
$sm-max: 960px;
|
||||
$md-min: $sm-max;
|
||||
$md-max: 1264px;
|
||||
$lg-min: $md-max;
|
||||
$lg-max: 1920px;
|
||||
$xl-min: $lg-max;
|
||||
|
||||
$background-color: #121212;
|
||||
64
opentogethertube/opentogethertube/src/views/Faq.vue
Normal file
64
opentogethertube/opentogethertube/src/views/Faq.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<h1>Frequently Asked Questions</h1>
|
||||
<v-row v-for="(item, index) in questions" :key="index">
|
||||
<v-col>
|
||||
<v-sheet>
|
||||
<v-container>
|
||||
<h2>{{ item.question }}</h2>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<p v-html="item.answer"></p>
|
||||
</v-container>
|
||||
</v-sheet>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'faq',
|
||||
data() {
|
||||
return {
|
||||
questions: [
|
||||
{
|
||||
question: "What kind of videos can be played?",
|
||||
answer: "Youtube, Vimeo, and Dailymotion videos.",
|
||||
},
|
||||
{
|
||||
question: "Can you add support X?",
|
||||
answer: "If X has an iframe API, then yes, that is possible.",
|
||||
},
|
||||
{
|
||||
question: "Will you add support for X?",
|
||||
answer: "Maybe, it depends on demand. Add a github issue or upvote an existing one to express your interest.",
|
||||
},
|
||||
{
|
||||
question: "I see a feature listed on the home page but it's not implemented.",
|
||||
answer: "I'm working on it.",
|
||||
},
|
||||
{
|
||||
question: "I want a permanent room with a custom URL.",
|
||||
answer: "Create one by clicking the button in the top right corner.",
|
||||
},
|
||||
{
|
||||
question: "Why do videos sometimes have no title or thumbnail, but they can still be played?",
|
||||
answer: "This probably means that the server was unable to get that information because it ran out of Youtube API quota.",
|
||||
},
|
||||
{
|
||||
question: 'Why does it say "Out of quota" when searching for youtube videos?',
|
||||
answer: "Youtube searches are expensive to perform. Because of this, searches are rate limted. If this happens, just do the search on youtube and copy the link.",
|
||||
},
|
||||
{
|
||||
question: "How do permanent rooms work?",
|
||||
answer: "Right now, permanent rooms just serve to provide custom room URLs, and anybody can access all permanent rooms. When user accounts are implemented, people will be able to claim ownership of permanent rooms. Check the progress of user accounts here: <a href=\"https://github.com/dyc3/opentogethertube/issues/50\">dyc3/opentogethertube#50</a> Then rooms will be able to be set visibility as private, and only allow invited users into the room. This will require all invited users to have accounts, but it will prevent random or unwanted people from entering private rooms.",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
176
opentogethertube/opentogethertube/src/views/Home.vue
Normal file
176
opentogethertube/opentogethertube/src/views/Home.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<v-container class="hero" fluid fill-height grid-list-md text-xs-center>
|
||||
<v-row align="center" justify="center">
|
||||
<div>
|
||||
<h1>Enjoy Together.</h1>
|
||||
<span>
|
||||
Real-time syncronized playback. Optional voting system.<br>
|
||||
Dark theme. No sign up required. All Open Source.<br>
|
||||
It's never been easier to watch videos together.
|
||||
</span>
|
||||
<v-layout :row="$vuetify.breakpoint.smAndUp"
|
||||
:column="$vuetify.breakpoint.xs"
|
||||
:justify-space-between="$vuetify.breakpoint.smAndUp"
|
||||
:justify-space-around="$vuetify.breakpoint.xs">
|
||||
<v-btn elevation="12" x-large @click="createTempRoom">Create Room</v-btn>
|
||||
<v-btn elevation="12" x-large to="/rooms">Browse Rooms</v-btn>
|
||||
<v-btn elevation="12" x-large href="https://github.com/dyc3/opentogethertube">View Source</v-btn>
|
||||
</v-layout>
|
||||
</div>
|
||||
</v-row>
|
||||
</v-container>
|
||||
<v-container class="content">
|
||||
<v-row>
|
||||
<v-col>
|
||||
<h1>Simple and Easy.</h1>
|
||||
<p>
|
||||
The original TogetherTube was loved for it's simple interface,
|
||||
and how easy it was to start watching videos right away.
|
||||
OpenTogetherTube aims to be just as easy, and then improve on
|
||||
top of that to make it even better.
|
||||
</p>
|
||||
<p>
|
||||
Currently, you can watch online videos with your friends from Youtube, Vimeo, Dailymotion and
|
||||
<a href="https://github.com/dyc3/opentogethertube/labels/service%20support%20request">more are on the way</a>.
|
||||
</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<h1>Core Features</h1>
|
||||
<v-row dense class="features">
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-card hover :height="cardHeight">
|
||||
<v-card-title>Syncronized Playback</v-card-title>
|
||||
<v-card-text>
|
||||
You hit play, and the video plays for everybody
|
||||
in the room. Simple as that.
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-card hover :height="cardHeight">
|
||||
<v-card-title>Permanent Rooms</v-card-title>
|
||||
<v-card-text>
|
||||
You and the squad come here often? Avoid the hastle
|
||||
of sending out a new link every time. Permanent
|
||||
rooms get a custom url that doesn't change.
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-card hover :height="cardHeight">
|
||||
<v-card-title>Dark Theme</v-card-title>
|
||||
<v-card-text>
|
||||
Watching Vine compilations late at night?
|
||||
OpenTogetherTube has a dark theme by default so
|
||||
your eyes won't suffer.
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-card hover :height="cardHeight">
|
||||
<v-card-title>Room Permissions (WIP)</v-card-title>
|
||||
<v-card-text>
|
||||
Tired of random goofballs joining your room and
|
||||
adding lots of loud videos to your chill lofi hip-hop
|
||||
listening session? Just block them from adding videos.
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-card hover :height="cardHeight">
|
||||
<v-card-title>Voting System</v-card-title>
|
||||
<v-card-text>
|
||||
Can't decide what to watch next? Switch the queue
|
||||
to the vote system and let democracy do what it
|
||||
does best.
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-card hover :height="cardHeight">
|
||||
<v-card-title>Playlist Copying</v-card-title>
|
||||
<v-card-text>
|
||||
Add entire playlists or channels to the video queue
|
||||
all at once so you don't have to sit there adding
|
||||
each video to the queue one by one. It's the best
|
||||
way to binge watch that new channel with your friends.
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<em style="opacity: 0.5">Disclaimer: The OpenTogetherTube project is not associated with TogetherTube nor Watch2Gether.</em>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-footer>
|
||||
<v-container pa-0>
|
||||
<v-row no-gutters align="center" justify="center">
|
||||
{{ new Date().getFullYear() }} - <a href="https://carsonmcmanus.com/">Carson McManus</a> - Made in America - Special Thanks to <a href="https://softe.club">SEC</a> @ Stevens
|
||||
</v-row>
|
||||
<v-row no-gutters align="center" justify="center">
|
||||
<router-link to="/privacypolicy">Privacy Policy</router-link>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-footer>
|
||||
</v-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import RoomUtilsMixin from "@/mixins/RoomUtils.js";
|
||||
|
||||
export default {
|
||||
name: 'home',
|
||||
mixins: [RoomUtilsMixin],
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
computed: {
|
||||
cardHeight() {
|
||||
return 180;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.home {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 1264px) {
|
||||
.hero {
|
||||
.v-btn {
|
||||
margin-top: 22px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(217deg, rgb(125, 74, 239), rgb(227,141,174) 30%, rgb(247, 208, 109));
|
||||
color: white;
|
||||
font-size: 22px;
|
||||
height: 100vh;
|
||||
min-height: 350px;
|
||||
|
||||
h1 {
|
||||
font-size: 52px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
h1 {
|
||||
font-size: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.v-btn {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
25
opentogethertube/opentogethertube/src/views/NotFound.vue
Normal file
25
opentogethertube/opentogethertube/src/views/NotFound.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<v-container fill-height>
|
||||
<v-row justify="center">
|
||||
<v-col align="center">
|
||||
<h1>Page Not Found</h1>
|
||||
<v-btn elevation="12" x-large to="/">Home</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn elevation="12" x-large to="/rooms">Browse Rooms</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'not-found',
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
33
opentogethertube/opentogethertube/src/views/Privacy.vue
Normal file
33
opentogethertube/opentogethertube/src/views/Privacy.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<h1>Privacy Policy</h1>
|
||||
<p>
|
||||
This site uses cookies.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Your IP is not logged in OpenTogetherTube's logs. However, it is recorded for a short period of time for rate limiting.
|
||||
Chats are not recorded.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
General site usage, like creating a room, adding videos, etc., are logged to monitor and debug performace.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This site uses the Youtube Data API, and it's usage must comply with the <a href="https://developers.google.com/youtube/terms/api-services-terms-of-service">YouTube API Terms of Service</a>.
|
||||
No personally identifiable information is sent to Youtube. Watching Youtube videos requires you to agree to the <a href="https://www.youtube.com/t/terms">Youtube Terms of Service</a>,
|
||||
and <a href="https://policies.google.com/privacy">Google's privacy policy</a>.
|
||||
</p>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "privacypolicy",
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
937
opentogethertube/opentogethertube/src/views/Room.vue
Normal file
937
opentogethertube/opentogethertube/src/views/Room.vue
Normal file
@@ -0,0 +1,937 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-container fluid :class="{ room: true, fullscreen: $store.state.fullscreen }" v-if="!showJoinFailOverlay">
|
||||
<v-col v-if="!$store.state.fullscreen">
|
||||
<h1>{{ $store.state.room.title != "" ? $store.state.room.title : ($store.state.room.isTemporary ? "Temporary Room" : $store.state.room.name) }}</h1>
|
||||
<span id="connectStatus">{{ connectionStatus }}</span>
|
||||
</v-col>
|
||||
<v-col :style="{ padding: ($store.state.fullscreen ? 0 : 'inherit') }">
|
||||
<v-row no-gutters class="video-container">
|
||||
<div class="video-subcontainer" cols="12" :xl="$store.state.fullscreen ? 9 : 7" md="8" :style="{ padding: ($store.state.fullscreen ? 0 : 'inherit') }">
|
||||
<v-responsive :aspect-ratio="16/9" class="player-container" :key="currentSource.service">
|
||||
<YoutubePlayer v-if="currentSource.service == 'youtube'" class="player" ref="youtube" :video-id="currentSource.id" @playing="onPlaybackChange(true)" @paused="onPlaybackChange(false)" @ready="onPlayerReady" @buffering="onVideoBuffer" @error="onVideoError" />
|
||||
<VimeoPlayer v-else-if="currentSource.service == 'vimeo'" class="player" ref="vimeo" :video-id="currentSource.id" @playing="onPlaybackChange(true)" @paused="onPlaybackChange(false)" @ready="onPlayerReady" @buffering="onVideoBuffer" @error="onVideoError" />
|
||||
<DailymotionPlayer v-else-if="currentSource.service == 'dailymotion'" class="player" ref="dailymotion" :video-id="currentSource.id" @playing="onPlaybackChange(true)" @paused="onPlaybackChange(false)" @ready="onPlayerReady" @buffering="onVideoBuffer" @error="onVideoError" />
|
||||
<GoogleDrivePlayer v-else-if="currentSource.service == 'googledrive'" class="player" ref="googledrive" :video-id="currentSource.id" @playing="onPlaybackChange(true)" @paused="onPlaybackChange(false)" @ready="onPlayerReady" @buffering="onVideoBuffer" @error="onVideoError" />
|
||||
<DirectPlayer v-else-if="currentSource.service == 'direct'" class="player" ref="direct" :video-url="currentSource.url" @playing="onPlaybackChange(true)" @paused="onPlaybackChange(false)" @ready="onPlayerReady" @buffering="onVideoBuffer" @error="onVideoError" />
|
||||
<v-container fluid fill-height class="player no-video" v-else>
|
||||
<v-row justify="center" align="center">
|
||||
<div>
|
||||
<h1>No video is playing.</h1>
|
||||
<span>Click "Add" below to add a video.</span>
|
||||
</div>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-responsive>
|
||||
<v-col class="video-controls">
|
||||
<vue-slider id="videoSlider" v-model="sliderPosition" @change="sliderChange" :max="$store.state.room.currentSource.length" :tooltip-formatter="sliderTooltipFormatter" :disabled="currentSource.length == null"/>
|
||||
<v-row no-gutters align="center">
|
||||
<v-btn @click="togglePlayback()">
|
||||
<v-icon v-if="$store.state.room.isPlaying">fas fa-pause</v-icon>
|
||||
<v-icon v-else>fas fa-play</v-icon>
|
||||
</v-btn>
|
||||
<v-btn @click="skipVideo()">
|
||||
<v-icon>fas fa-fast-forward</v-icon>
|
||||
</v-btn>
|
||||
<vue-slider v-model="volume" style="width: 150px; margin-left: 10px"/>
|
||||
<div style="margin-left: 20px" class="timestamp">
|
||||
{{ timestampDisplay }}
|
||||
</div>
|
||||
<v-btn @click="toggleFullscreen()" style="margin-left: 10px">
|
||||
<v-icon>fas fa-compress</v-icon>
|
||||
</v-btn>
|
||||
</v-row>
|
||||
</v-col>
|
||||
</div>
|
||||
<div cols="12" :xl="$store.state.fullscreen ? 3 : 5" md="4" class="chat-container">
|
||||
<div class="d-flex flex-column" style="height: 100%">
|
||||
<h4>Chat</h4>
|
||||
<div class="messages d-flex flex-column flex-grow-1 mt-2">
|
||||
<v-card class="msg d-flex mr-2 mb-2" v-for="(msg, index) in $store.state.room.chatMessages" :key="index">
|
||||
<div class="from">{{ msg.from }}</div>
|
||||
<div class="text">{{ msg.text }}</div>
|
||||
</v-card>
|
||||
</div>
|
||||
<div class="d-flex justify-end">
|
||||
<v-text-field placeholder="Type your message here..." @keydown="onChatMessageKeyDown" v-model="inputChatMsgText" autocomplete="off"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-row>
|
||||
<v-row no-gutters>
|
||||
<v-col cols="12" md="8" sm="12">
|
||||
<v-tabs grow v-model="queueTab" @change="onTabChange">
|
||||
<v-tab>
|
||||
Queue
|
||||
<span class="bubble">{{ $store.state.room.queue.length <= 99 ? $store.state.room.queue.length : "99+" }}</span>
|
||||
</v-tab>
|
||||
<v-tab>Add</v-tab>
|
||||
<v-tab>Settings</v-tab>
|
||||
</v-tabs>
|
||||
<v-tabs-items v-model="queueTab" class="queue-tab-content">
|
||||
<v-tab-item>
|
||||
<div class="video-queue">
|
||||
<draggable v-model="$store.state.room.queue" @end="onQueueDragDrop" handle=".drag-handle">
|
||||
<VideoQueueItem v-for="(itemdata, index) in $store.state.room.queue" :key="index" :item="itemdata"/>
|
||||
</draggable>
|
||||
</div>
|
||||
</v-tab-item>
|
||||
<v-tab-item>
|
||||
<div class="video-add">
|
||||
<div>
|
||||
<v-text-field clearable placeholder="Type to search YouTube or enter a Video URL to add to the queue" v-model="inputAddPreview" @keydown="onInputAddPreviewKeyDown" @focus="onInputAddPreviewFocus" :loading="isLoadingAddPreview" />
|
||||
<v-btn v-if="!production" @click="postTestVideo(0)">Add test youtube 0</v-btn>
|
||||
<v-btn v-if="!production" @click="postTestVideo(1)">Add test youtube 1</v-btn>
|
||||
<v-btn v-if="!production" @click="postTestVideo(2)">Add test vimeo 2</v-btn>
|
||||
<v-btn v-if="!production" @click="postTestVideo(3)">Add test vimeo 3</v-btn>
|
||||
<v-btn v-if="!production" @click="postTestVideo(4)">Add test dailymotion 4</v-btn>
|
||||
<v-btn v-if="addPreview.length > 1" @click="addAllToQueue()">Add All</v-btn>
|
||||
</div>
|
||||
<v-row v-if="isLoadingAddPreview" justify="center">
|
||||
<v-progress-circular indeterminate/>
|
||||
</v-row>
|
||||
<div v-if="!isLoadingAddPreview">
|
||||
<v-row justify="center">
|
||||
<div v-if="hasAddPreviewFailed">
|
||||
{{ addPreviewLoadFailureText }}
|
||||
</div>
|
||||
<v-container fill-height v-if="addPreview.length == 0 && inputAddPreview.length > 0 && !hasAddPreviewFailed && !isAddPreviewInputUrl">
|
||||
<v-row justify="center" align="center">
|
||||
<v-col cols="12">
|
||||
Search YouTube for "{{ inputAddPreview }}" by pressing enter, or by clicking search.<br>
|
||||
<v-btn @click="requestAddPreviewExplicit">Search</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-row>
|
||||
<div v-if="highlightedAddPreviewItem">
|
||||
<VideoQueueItem :item="highlightedAddPreviewItem" is-preview style="margin-bottom: 20px"/>
|
||||
<h4>Playlist</h4>
|
||||
</div>
|
||||
<VideoQueueItem v-for="(itemdata, index) in addPreview" :key="index" :item="itemdata" is-preview/>
|
||||
</div>
|
||||
</div>
|
||||
</v-tab-item>
|
||||
<v-tab-item>
|
||||
<div class="room-settings">
|
||||
<v-form @submit="submitRoomSettings">
|
||||
<v-text-field label="Title" v-model="inputRoomSettingsTitle" :loading="isLoadingRoomSettings" />
|
||||
<v-text-field label="Description" v-model="inputRoomSettingsDescription" :loading="isLoadingRoomSettings" />
|
||||
<v-select label="Visibility" :items="[{ text: 'public' }, { text: 'unlisted' }]" v-model="inputRoomSettingsVisibility" :loading="isLoadingRoomSettings" />
|
||||
<v-select label="Queue Mode" :items="[{ text: 'manual' }, { text: 'vote' }]" v-model="inputRoomSettingsQueueMode" :loading="isLoadingRoomSettings" />
|
||||
<v-btn @click="submitRoomSettings" role="submit" :loading="isLoadingRoomSettings">Save</v-btn>
|
||||
</v-form>
|
||||
<v-btn v-if="!$store.state.room.isTemporary && $store.state.user && !$store.state.room.hasOwner" role="submit" @click="claimOwnership">Claim Room</v-btn>
|
||||
</div>
|
||||
</v-tab-item>
|
||||
</v-tabs-items>
|
||||
</v-col>
|
||||
<v-col col="4" md="4" sm="12">
|
||||
<div class="user-list">
|
||||
<v-card>
|
||||
<v-subheader>
|
||||
Users
|
||||
<v-btn icon x-small @click="openEditName"><v-icon>fas fa-cog</v-icon></v-btn>
|
||||
</v-subheader>
|
||||
<v-list-item v-if="showEditName">
|
||||
<v-text-field @change="onEditNameChange" placeholder="Set your name" v-model="username" :loading="setUsernameLoading" :error-messages="setUsernameFailureText"/>
|
||||
</v-list-item>
|
||||
<v-list-item v-for="(user, index) in $store.state.room.users" :key="index" :class="user.isLoggedIn ? 'user registered' : 'user'">
|
||||
<span class="name">{{ user.name }}</span>
|
||||
<span v-if="user.isYou" class="is-you">You</span>
|
||||
<v-icon class="player-status" v-if="user.status === 'buffering'">fas fa-spinner</v-icon>
|
||||
<v-icon class="player-status" v-else-if="user.status === 'ready'">fas fa-check</v-icon>
|
||||
<v-icon class="player-status" v-else-if="user.status === 'error'">fas fa-exclamation</v-icon>
|
||||
</v-list-item>
|
||||
</v-card>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-col>
|
||||
</v-container>
|
||||
<v-footer>
|
||||
<v-container pa-0>
|
||||
<v-row no-gutters align="center" justify="center">
|
||||
<router-link to="/privacypolicy">Privacy Policy</router-link>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-footer>
|
||||
<v-overlay :value="showJoinFailOverlay">
|
||||
<v-layout column>
|
||||
<h1>Failed to join room</h1>
|
||||
<span>{{ joinFailReason }}</span>
|
||||
<v-btn to="/rooms">Find Another Room</v-btn>
|
||||
</v-layout>
|
||||
</v-overlay>
|
||||
<v-snackbar v-for="(event, index) in $store.state.room.events" :key="index" v-model="event.isVisible">
|
||||
{{ snackbarText }}
|
||||
<v-btn @click="undoEvent(event, index)" v-if="event.isUndoable">Undo</v-btn>
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { API } from "@/common-http.js";
|
||||
import VideoQueueItem from "@/components/VideoQueueItem.vue";
|
||||
import { secondsToTimestamp, calculateCurrentPosition } from "@/timestamp.js";
|
||||
import _ from "lodash";
|
||||
import draggable from 'vuedraggable';
|
||||
import VueSlider from 'vue-slider-component';
|
||||
|
||||
export default {
|
||||
name: 'room',
|
||||
components: {
|
||||
draggable,
|
||||
VideoQueueItem,
|
||||
VueSlider,
|
||||
YoutubePlayer: () => import(/* webpackChunkName: "youtube" */"@/components/YoutubePlayer.vue"),
|
||||
VimeoPlayer: () => import(/* webpackChunkName: "vimeo" */"@/components/VimeoPlayer.vue"),
|
||||
DailymotionPlayer: () => import(/* webpackChunkName: "dailymotion" */"@/components/DailymotionPlayer.vue"),
|
||||
GoogleDrivePlayer: () => import(/* webpackChunkName: "googledrive" */"@/components/GoogleDrivePlayer.vue"),
|
||||
DirectPlayer: () => import(/* webpackChunkName: "direct" */"@/components/DirectPlayer.vue"),
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
sliderPosition: 0,
|
||||
sliderTooltipFormatter: secondsToTimestamp,
|
||||
volume: 100,
|
||||
addPreview: [],
|
||||
|
||||
username: "", // refers to the local user's username
|
||||
|
||||
showEditName: false,
|
||||
queueTab: 0,
|
||||
isLoadingAddPreview: false,
|
||||
hasAddPreviewFailed: false,
|
||||
addPreviewLoadFailureText: "",
|
||||
inputAddPreview: "",
|
||||
inputChatMsgText: "",
|
||||
shouldChatStickToBottom: true,
|
||||
isLoadingRoomSettings: false,
|
||||
inputRoomSettingsTitle: "",
|
||||
inputRoomSettingsDescription: "",
|
||||
inputRoomSettingsVisibility: "",
|
||||
inputRoomSettingsQueueMode: "",
|
||||
setUsernameLoading: false,
|
||||
setUsernameFailureText: "",
|
||||
|
||||
showJoinFailOverlay: false,
|
||||
joinFailReason: "",
|
||||
snackbarActive: false,
|
||||
snackbarText: "",
|
||||
|
||||
timestampDisplay: "",
|
||||
i_timestampUpdater: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
connectionStatus() {
|
||||
if (this.$store.state.socket.isConnected) {
|
||||
return "Connected";
|
||||
}
|
||||
else {
|
||||
return "Connecting...";
|
||||
}
|
||||
},
|
||||
currentSource() {
|
||||
return this.$store.state.room.currentSource;
|
||||
},
|
||||
playbackPosition() {
|
||||
return this.$store.state.room.playbackPosition;
|
||||
},
|
||||
playbackPercentage() {
|
||||
if (!this.$store.state.room.currentSource) {
|
||||
return 0;
|
||||
}
|
||||
if (this.$store.state.room.currentSource.length == 0) {
|
||||
return 0;
|
||||
}
|
||||
return this.$store.state.room.playbackPosition / this.$store.state.room.currentSource.length;
|
||||
},
|
||||
production() {
|
||||
return this.$store.state.production;
|
||||
},
|
||||
isAddPreviewInputUrl() {
|
||||
try {
|
||||
if (new URL(this.inputAddPreview).host) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
highlightedAddPreviewItem() {
|
||||
return _.find(this.addPreview, { highlight: true });
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
this.$events.on("onRoomEvent", event => {
|
||||
if (event.eventType === "play") {
|
||||
this.snackbarText = `${event.userName} played the video`;
|
||||
}
|
||||
else if (event.eventType === "pause") {
|
||||
this.snackbarText = `${event.userName} paused the video`;
|
||||
}
|
||||
else if (event.eventType === "skip") {
|
||||
this.snackbarText = `${event.userName} skipped ${event.parameters.video.title}`;
|
||||
}
|
||||
else if (event.eventType === "seek") {
|
||||
this.snackbarText = `${event.userName} seeked to ${secondsToTimestamp(event.parameters.position)}`;
|
||||
}
|
||||
else if (event.eventType === "joinRoom") {
|
||||
this.snackbarText = `${event.userName} joined the room`;
|
||||
}
|
||||
else if (event.eventType === "leaveRoom") {
|
||||
this.snackbarText = `${event.userName} left the room`;
|
||||
}
|
||||
else if (event.eventType === "addToQueue") {
|
||||
this.snackbarText = `${event.userName} added ${event.parameters.video.title}`;
|
||||
}
|
||||
else if (event.eventType === "removeFromQueue") {
|
||||
this.snackbarText = `${event.userName} removed ${event.parameters.video.title}`;
|
||||
}
|
||||
else {
|
||||
this.snackbarText = `${event.userName} triggered event ${event.eventType}`;
|
||||
}
|
||||
this.snackbarActive = true;
|
||||
});
|
||||
|
||||
this.$events.on("onRoomCreated", () => {
|
||||
if (this.$store.state.socket.isConnected) {
|
||||
this.$disconnect();
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (!this.$store.state.socket.isConnected) {
|
||||
this.$connect(`${window.location.protocol.startsWith("https") ? "wss" : "ws"}://${window.location.host}/api/room/${this.$route.params.roomId}`);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
window.removeEventListener('keydown', this.onKeyDown);
|
||||
window.addEventListener('keydown', this.onKeyDown);
|
||||
|
||||
if (!this.$store.state.socket.isConnected) {
|
||||
// This check prevents the client from connecting multiple times,
|
||||
// caused by hot reloading in the dev environment.
|
||||
this.$connect(`${window.location.protocol.startsWith("https") ? "wss" : "ws"}://${window.location.host}/api/room/${this.$route.params.roomId}`);
|
||||
}
|
||||
|
||||
this.i_timestampUpdater = setInterval(() => {
|
||||
this.sliderPosition = this.$store.state.room.isPlaying ? calculateCurrentPosition(this.$store.state.room.playbackStartTime, new Date(), this.$store.state.room.playbackPosition) : this.$store.state.room.playbackPosition;
|
||||
this.sliderPosition = _.clamp(this.sliderPosition, 0, this.$store.state.room.currentSource.length);
|
||||
const position = secondsToTimestamp(this.sliderPosition);
|
||||
const duration = secondsToTimestamp(this.$store.state.room.currentSource.length || 0);
|
||||
this.timestampDisplay = `${position} / ${duration}`;
|
||||
}, 1000);
|
||||
},
|
||||
destroyed() {
|
||||
clearInterval(this.i_timestampUpdater);
|
||||
this.$disconnect();
|
||||
},
|
||||
methods: {
|
||||
postTestVideo(v) {
|
||||
let videos = [
|
||||
"https://www.youtube.com/watch?v=WC66l5tPIF4",
|
||||
"https://www.youtube.com/watch?v=aI67KDJRnvQ",
|
||||
"https://vimeo.com/94338566",
|
||||
"https://vimeo.com/239423699",
|
||||
"https://www.dailymotion.com/video/x6hkywd",
|
||||
];
|
||||
API.post(`/room/${this.$route.params.roomId}/queue`, {
|
||||
url: videos[v],
|
||||
});
|
||||
},
|
||||
togglePlayback() {
|
||||
if (this.$store.state.room.isPlaying) {
|
||||
this.$socket.sendObj({ action: "pause" });
|
||||
}
|
||||
else {
|
||||
this.$socket.sendObj({ action: "play" });
|
||||
}
|
||||
},
|
||||
skipVideo() {
|
||||
this.$socket.sendObj({ action: "skip" });
|
||||
},
|
||||
sliderChange() {
|
||||
this.$socket.sendObj({ action: "seek", position: this.sliderPosition });
|
||||
},
|
||||
addToQueue() {
|
||||
API.post(`/room/${this.$route.params.roomId}/queue`, {
|
||||
url: this.inputAddPreview,
|
||||
});
|
||||
},
|
||||
addAllToQueue() {
|
||||
for (let video of this.addPreview) {
|
||||
API.post(`/room/${this.$route.params.roomId}/queue`, video);
|
||||
}
|
||||
},
|
||||
openEditName() {
|
||||
this.username = this.$store.state.user ? this.$store.state.user.username : this.$store.state.username;
|
||||
this.showEditName = !this.showEditName;
|
||||
},
|
||||
play() {
|
||||
if (this.currentSource.service == "youtube") {
|
||||
this.$refs.youtube.play();
|
||||
}
|
||||
else if (this.currentSource.service === "vimeo") {
|
||||
this.$refs.vimeo.play();
|
||||
}
|
||||
else if (this.currentSource.service === "dailymotion") {
|
||||
this.$refs.dailymotion.play();
|
||||
}
|
||||
else if (this.currentSource.service === "googledrive") {
|
||||
this.$refs.googledrive.play();
|
||||
}
|
||||
else if (this.currentSource.service === "direct") {
|
||||
this.$refs.direct.play();
|
||||
}
|
||||
},
|
||||
pause() {
|
||||
if (this.currentSource.service == "youtube") {
|
||||
this.$refs.youtube.pause();
|
||||
}
|
||||
else if (this.currentSource.service === "vimeo") {
|
||||
this.$refs.vimeo.pause();
|
||||
}
|
||||
else if (this.currentSource.service === "dailymotion") {
|
||||
this.$refs.dailymotion.pause();
|
||||
}
|
||||
else if (this.currentSource.service === "googledrive") {
|
||||
this.$refs.googledrive.pause();
|
||||
}
|
||||
else if (this.currentSource.service === "direct") {
|
||||
this.$refs.direct.pause();
|
||||
}
|
||||
},
|
||||
updateVolume() {
|
||||
if (this.currentSource.service == "youtube") {
|
||||
this.$refs.youtube.setVolume(this.volume);
|
||||
}
|
||||
else if (this.currentSource.service === "vimeo") {
|
||||
this.$refs.vimeo.setVolume(this.volume);
|
||||
}
|
||||
else if (this.currentSource.service === "dailymotion") {
|
||||
this.$refs.dailymotion.setVolume(this.volume);
|
||||
}
|
||||
else if (this.currentSource.service === "googledrive") {
|
||||
this.$refs.googledrive.setVolume(this.volume);
|
||||
}
|
||||
else if (this.currentSource.service === "direct") {
|
||||
this.$refs.direct.setVolume(this.volume);
|
||||
}
|
||||
},
|
||||
requestAddPreview() {
|
||||
API.get(`/data/previewAdd?input=${encodeURIComponent(this.inputAddPreview)}`, { validateStatus: status => status < 500 }).then(res => {
|
||||
this.isLoadingAddPreview = false;
|
||||
if (res.status === 200) {
|
||||
this.hasAddPreviewFailed = false;
|
||||
this.addPreview = res.data;
|
||||
console.log(`Got add preview with ${this.addPreview.length}`);
|
||||
}
|
||||
else if (res.status === 400) {
|
||||
this.hasAddPreviewFailed = true;
|
||||
this.addPreviewLoadFailureText = res.data.error.message;
|
||||
if (res.data.error.name === "FeatureDisabledException" && !this.isAddPreviewInputUrl) {
|
||||
window.open(`https://www.youtube.com/results?search_query=${encodeURIComponent(this.inputAddPreview)}`, "_blank");
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.warn("Unknown status for add preview response:", res.status);
|
||||
}
|
||||
}).catch(err => {
|
||||
this.isLoadingAddPreview = false;
|
||||
this.hasAddPreviewFailed = true;
|
||||
this.addPreviewLoadFailureText = "An unknown error occurred when getting add preview. Try again later.";
|
||||
console.error("Failed to get add preview", err);
|
||||
});
|
||||
},
|
||||
requestAddPreviewDebounced: _.debounce(function() {
|
||||
// HACK: can't use an arrow function here because it will make `this` undefined
|
||||
this.requestAddPreview();
|
||||
}, 500),
|
||||
/**
|
||||
* Request an add preview regardless of the current input.
|
||||
*/
|
||||
requestAddPreviewExplicit() {
|
||||
this.isLoadingAddPreview = true;
|
||||
this.hasAddPreviewFailed = false;
|
||||
this.addPreview = [];
|
||||
this.requestAddPreview();
|
||||
},
|
||||
onEditNameChange() {
|
||||
this.setUsernameLoading = true;
|
||||
API.post("/user", { username: this.username }).then(() => {
|
||||
this.showEditName = false;
|
||||
this.setUsernameLoading = false;
|
||||
this.setUsernameFailureText = "";
|
||||
}).catch(err => {
|
||||
this.setUsernameLoading = false;
|
||||
this.setUsernameFailureText = err.response ? err.response.data.error.message : err.message;
|
||||
});
|
||||
},
|
||||
onPlaybackChange(changeTo) {
|
||||
if (this.currentSource.service === "youtube" || this.currentSource.service === "dailymotion") {
|
||||
this.$store.commit("PLAYBACK_STATUS", "ready");
|
||||
}
|
||||
this.updateVolume();
|
||||
if (changeTo == this.$store.state.room.isPlaying) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.$store.state.room.isPlaying) {
|
||||
this.play();
|
||||
}
|
||||
else {
|
||||
this.pause();
|
||||
}
|
||||
},
|
||||
onInputAddPreviewChange() {
|
||||
this.isLoadingAddPreview = true;
|
||||
this.hasAddPreviewFailed = false;
|
||||
if (_.trim(this.inputAddPreview).length == 0) {
|
||||
this.addPreview = [];
|
||||
this.isLoadingAddPreview = false;
|
||||
return;
|
||||
}
|
||||
if (!this.isAddPreviewInputUrl) {
|
||||
this.addPreview = [];
|
||||
this.isLoadingAddPreview = false;
|
||||
// Don't send API requests for non URL inputs without the user's explicit input to do so.
|
||||
// This is to help conserve youtube API quota.
|
||||
return;
|
||||
}
|
||||
this.requestAddPreviewDebounced();
|
||||
},
|
||||
onInputAddPreviewKeyDown(e) {
|
||||
if (_.trim(this.inputAddPreview).length == 0 || this.isAddPreviewInputUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.keyCode === 13 && this.addPreview.length == 0) {
|
||||
this.requestAddPreviewExplicit();
|
||||
}
|
||||
},
|
||||
onInputAddPreviewFocus(e) {
|
||||
e.target.select();
|
||||
},
|
||||
onPlayerReady() {
|
||||
this.$store.commit("PLAYBACK_STATUS", "ready");
|
||||
|
||||
if (this.currentSource.service === "vimeo") {
|
||||
this.onPlayerReady_Vimeo();
|
||||
}
|
||||
},
|
||||
onPlayerReady_Vimeo() {
|
||||
if (this.$store.state.room.isPlaying) {
|
||||
this.play();
|
||||
}
|
||||
else {
|
||||
this.pause();
|
||||
}
|
||||
},
|
||||
onKeyDown(e) {
|
||||
if (e.target.nodeName === "INPUT") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.code === "Space" || e.code === "k") {
|
||||
this.togglePlayback();
|
||||
e.preventDefault();
|
||||
}
|
||||
else if (e.code === "Home") {
|
||||
this.$socket.sendObj({ action: "seek", position: 0 });
|
||||
e.preventDefault();
|
||||
}
|
||||
else if (e.code === "End") {
|
||||
this.$socket.sendObj({ action: "skip" });
|
||||
e.preventDefault();
|
||||
}
|
||||
else if (e.code === "KeyF") {
|
||||
this.toggleFullscreen();
|
||||
}
|
||||
else if (e.code === "ArrowLeft" || e.code === "ArrowRight" || e.code === "KeyJ" || e.code === "KeyL") {
|
||||
let seekIncrement = 5;
|
||||
if (e.ctrlKey || e.code === "KeyJ" || e.code === "KeyL") {
|
||||
seekIncrement = 10;
|
||||
}
|
||||
if (e.code === "ArrowLeft" || e.code === "KeyJ") {
|
||||
seekIncrement *= -1;
|
||||
}
|
||||
|
||||
this.$socket.sendObj({
|
||||
action: "seek",
|
||||
position: _.clamp(this.$store.state.room.playbackPosition + seekIncrement, 0, this.$store.state.room.currentSource.length),
|
||||
});
|
||||
e.preventDefault();
|
||||
}
|
||||
else if (e.code === "ArrowUp" || e.code === "ArrowDown") {
|
||||
this.volume = _.clamp(this.volume + 5 * (e.code === "ArrowDown" ? -1 : 1), 0, 100);
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
toggleFullscreen() {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
else {
|
||||
document.documentElement.requestFullscreen();
|
||||
}
|
||||
},
|
||||
onChatMessageKeyDown(e) {
|
||||
if (e.keyCode === 13 && this.inputChatMsgText.length > 0) {
|
||||
this.$socket.sendObj({ action: "chat", text: this.inputChatMsgText });
|
||||
this.inputChatMsgText = "";
|
||||
this.shouldChatStickToBottom = true;
|
||||
}
|
||||
},
|
||||
onChatScroll() {
|
||||
let msgsDiv = document.getElementsByClassName("messages");
|
||||
if (msgsDiv.length) {
|
||||
msgsDiv = msgsDiv[0];
|
||||
let distToBottom = msgsDiv.scrollHeight - msgsDiv.clientHeight - msgsDiv.scrollTop;
|
||||
this.shouldChatStickToBottom = distToBottom == 0;
|
||||
}
|
||||
},
|
||||
onQueueDragDrop(e) {
|
||||
this.$socket.sendObj({
|
||||
action: "queue-move",
|
||||
currentIdx: e.oldIndex,
|
||||
targetIdx: e.newIndex,
|
||||
});
|
||||
},
|
||||
undoEvent(event, idx) {
|
||||
this.$socket.sendObj({
|
||||
action: "undo",
|
||||
event,
|
||||
});
|
||||
this.$store.state.room.events.splice(idx, 1);
|
||||
},
|
||||
onTabChange() {
|
||||
if (this.queueTab === 2) {
|
||||
// FIXME: we have to make an API request becuase visibility is not sent in sync messages.
|
||||
this.isLoadingRoomSettings = true;
|
||||
API.get(`/room/${this.$route.params.roomId}`).then(res => {
|
||||
this.isLoadingRoomSettings = false;
|
||||
this.inputRoomSettingsTitle = res.data.title;
|
||||
this.inputRoomSettingsDescription = res.data.description;
|
||||
this.inputRoomSettingsVisibility = res.data.visibility;
|
||||
this.inputRoomSettingsQueueMode = res.data.queueMode;
|
||||
});
|
||||
}
|
||||
},
|
||||
submitRoomSettings() {
|
||||
this.isLoadingRoomSettings = true;
|
||||
API.patch(`/room/${this.$route.params.roomId}`, {
|
||||
title: this.inputRoomSettingsTitle,
|
||||
description: this.inputRoomSettingsDescription,
|
||||
visibility: this.inputRoomSettingsVisibility,
|
||||
queueMode: this.inputRoomSettingsQueueMode,
|
||||
}).then(() => {
|
||||
this.isLoadingRoomSettings = false;
|
||||
});
|
||||
},
|
||||
onVideoBuffer() {
|
||||
this.$store.commit("PLAYBACK_STATUS", "buffering");
|
||||
},
|
||||
onVideoError() {
|
||||
this.$store.commit("PLAYBACK_STATUS", "error");
|
||||
},
|
||||
hideVideoControls: _.debounce(() => {
|
||||
let controlsDiv = document.getElementsByClassName("video-controls");
|
||||
if (controlsDiv.length) {
|
||||
controlsDiv = controlsDiv[0];
|
||||
controlsDiv.classList.add("hide");
|
||||
}
|
||||
}, 3000),
|
||||
claimOwnership() {
|
||||
this.isLoadingRoomSettings = true;
|
||||
API.patch(`/room/${this.$route.params.roomId}`, {
|
||||
claim: true,
|
||||
}).then(() => {
|
||||
this.isLoadingRoomSettings = false;
|
||||
}).catch(err => {
|
||||
console.log(err);
|
||||
this.isLoadingRoomSettings = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$events.on("playVideo", () => {
|
||||
this.play();
|
||||
});
|
||||
this.$events.on("pauseVideo", () => {
|
||||
this.pause();
|
||||
});
|
||||
this.$events.on("roomJoinFailure", eventData => {
|
||||
this.showJoinFailOverlay = true;
|
||||
this.joinFailReason = eventData.reason;
|
||||
});
|
||||
|
||||
let msgsDiv = document.getElementsByClassName("messages");
|
||||
if (msgsDiv.length) {
|
||||
msgsDiv = msgsDiv[0];
|
||||
msgsDiv.onscroll = this.onChatScroll;
|
||||
}
|
||||
else {
|
||||
console.error("Couldn't find chat messages div");
|
||||
}
|
||||
|
||||
document.onmousemove = () => {
|
||||
let controlsDiv = document.getElementsByClassName("video-controls");
|
||||
if (controlsDiv.length) {
|
||||
controlsDiv = controlsDiv[0];
|
||||
controlsDiv.classList.remove("hide");
|
||||
}
|
||||
this.hideVideoControls();
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
// username(newValue) {
|
||||
// if (newValue != null) {
|
||||
// // window.localStorage.setItem("username", newValue);
|
||||
// }
|
||||
// },
|
||||
volume() {
|
||||
this.updateVolume();
|
||||
},
|
||||
async sliderPosition(newPosition) {
|
||||
let currentTime = null;
|
||||
if (this.currentSource.service === "youtube") {
|
||||
currentTime = await this.$refs.youtube.getPosition();
|
||||
}
|
||||
else if (this.currentSource.service === "vimeo") {
|
||||
currentTime = await this.$refs.vimeo.getPosition();
|
||||
}
|
||||
else if (this.currentSource.service === "dailymotion") {
|
||||
currentTime = await this.$refs.dailymotion.getPosition();
|
||||
}
|
||||
else if (this.currentSource.service === "googledrive") {
|
||||
currentTime = await this.$refs.googledrive.getPosition();
|
||||
}
|
||||
else if (this.currentSource.service === "direct") {
|
||||
currentTime = await this.$refs.direct.getPosition();
|
||||
}
|
||||
if (Math.abs(newPosition - currentTime) > 1) {
|
||||
if (this.currentSource.service === "youtube") {
|
||||
this.$refs.youtube.setPosition(newPosition);
|
||||
}
|
||||
else if (this.currentSource.service === "vimeo") {
|
||||
this.$refs.vimeo.setPosition(newPosition);
|
||||
}
|
||||
else if (this.currentSource.service === "dailymotion") {
|
||||
this.$refs.dailymotion.setPosition(newPosition);
|
||||
}
|
||||
else if (this.currentSource.service === "googledrive") {
|
||||
this.$refs.googledrive.setPosition(newPosition);
|
||||
}
|
||||
else if (this.currentSource.service === "direct") {
|
||||
this.$refs.direct.setPosition(newPosition);
|
||||
}
|
||||
}
|
||||
},
|
||||
inputAddPreview() {
|
||||
// HACK: The @change event only triggers when the text field is defocused.
|
||||
// This ensures that onInputAddPreviewChange() runs everytime the text field's value changes.
|
||||
this.onInputAddPreviewChange();
|
||||
},
|
||||
},
|
||||
updated() {
|
||||
// scroll the messages to the bottom
|
||||
if (this.shouldChatStickToBottom) {
|
||||
let msgsDiv = document.getElementsByClassName("messages");
|
||||
if (msgsDiv.length) {
|
||||
msgsDiv = msgsDiv[0];
|
||||
msgsDiv.scrollTop = msgsDiv.scrollHeight;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../variables.scss";
|
||||
|
||||
.video-container {
|
||||
margin: 10px;
|
||||
|
||||
.player {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.no-video {
|
||||
height: 100%;
|
||||
color: #696969;
|
||||
border: 1px solid #666;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.video-subcontainer {
|
||||
width: calc(100% / 12 * 8);
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
width: calc(100% / 12 * 4);
|
||||
}
|
||||
|
||||
@media (max-width: $md-max) {
|
||||
.video-subcontainer, .chat-container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: $xl-min) {
|
||||
.video-subcontainer {
|
||||
width: calc(100% / 12 * 7);
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
width: calc(100% / 12 * 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
.video-queue, .video-add, .user-list {
|
||||
margin: 0 10px;
|
||||
min-height: 500px;
|
||||
}
|
||||
.queue-tab-content {
|
||||
background: transparent !important;
|
||||
}
|
||||
.is-you {
|
||||
color: #ffb300;
|
||||
border: 1px #ffb300 solid;
|
||||
border-radius: 10px;
|
||||
margin: 5px;
|
||||
padding: 0 5px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.player-status {
|
||||
margin: 0 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.bubble{
|
||||
height: 25px;
|
||||
width: 25px;
|
||||
margin-left: 10px;
|
||||
background-color: #3f3838;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
|
||||
font-weight: bold;
|
||||
color:#fff;
|
||||
text-align: center;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.chat-container {
|
||||
padding: 5px 10px;
|
||||
|
||||
h4 {
|
||||
border-bottom: 1px solid #666;
|
||||
}
|
||||
|
||||
.messages {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
// makes flex-grow work (for some reason)
|
||||
// the value is the height this element will take on md size screens and smaller
|
||||
height: 200px;
|
||||
|
||||
// push the messages to the bottom of the container
|
||||
> .msg:first-child {
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.msg {
|
||||
background-color: #444;
|
||||
|
||||
.from, .text {
|
||||
margin: 3px 5px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.from {
|
||||
font-weight: bold;
|
||||
max-width: 20%;
|
||||
}
|
||||
|
||||
.text {
|
||||
min-width: 80%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.flip-list-move {
|
||||
transition: transform 0.5s;
|
||||
}
|
||||
.no-move {
|
||||
transition: transform 0s;
|
||||
}
|
||||
|
||||
.fullscreen {
|
||||
padding: 0;
|
||||
|
||||
.video-container {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.video-subcontainer {
|
||||
width: calc(100% / 12 * 9);
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
width: calc(100% / 12 * 3);
|
||||
}
|
||||
|
||||
.player-container {
|
||||
height: 100vh;
|
||||
|
||||
.player {
|
||||
border: none;
|
||||
border-right: 1px solid #666;
|
||||
}
|
||||
}
|
||||
|
||||
.video-controls {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: $background-color;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&.hide {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-aspect-ratio: 16/9) {
|
||||
.video-subcontainer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.user {
|
||||
.name {
|
||||
opacity: 0.5;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&.registered {
|
||||
.name {
|
||||
opacity: 1;
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
93
opentogethertube/opentogethertube/src/views/RoomList.vue
Normal file
93
opentogethertube/opentogethertube/src/views/RoomList.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<v-container class="room-list" fill-height style="align-items: inherit">
|
||||
<v-row align="center" justify="center" v-if="isLoading" style="width: 100%">
|
||||
<v-col cols="12">
|
||||
<v-progress-circular indeterminate/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="rooms.length == 0 && !isLoading" align="center" justify="center" style="width: 100%">
|
||||
<div>
|
||||
<h1>No rooms right now...</h1>
|
||||
<v-btn elevation="12" x-large @click="createRoom">Create Room</v-btn>
|
||||
</div>
|
||||
</v-row>
|
||||
<v-row v-if="!isLoading">
|
||||
<v-col cols="6" sm="4" md="3" v-for="(room, index) in rooms" :key="index">
|
||||
<v-card hover class="room" :to="`/room/${room.name}`">
|
||||
<v-img :src="room.currentSource && room.currentSource.thumbnail ? room.currentSource.thumbnail : require('@/assets/placeholder.svg')" aspect-ratio="1.8">
|
||||
<span class="subtitle-2 users">{{ room.users }} <v-icon small>fas fa-user-friends</v-icon></span>
|
||||
</v-img>
|
||||
<v-card-title v-text="room.isTemporary ? 'Temporary Room' : room.name" />
|
||||
<v-card-text>
|
||||
<div class="description" v-if="room.description">{{ room.description }}</div>
|
||||
<div class="description empty" v-else>No description.</div>
|
||||
|
||||
<div class="video-title" v-if="room.currentSource && room.currentSource.title">{{ room.currentSource.title }}</div>
|
||||
<div class="video-title empty" v-else>Nothing playing.</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { API } from "@/common-http.js";
|
||||
|
||||
export default {
|
||||
name: 'room-list',
|
||||
components: {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
rooms: [],
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.isLoading = true;
|
||||
API.get("/room/list").then(res => {
|
||||
this.isLoading = false;
|
||||
this.rooms = res.data;
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
createRoom() {
|
||||
this.isLoading = true;
|
||||
this.cancelledCreation = false;
|
||||
API.post("/room/generate").then(res => {
|
||||
if (!this.cancelledCreation) {
|
||||
this.isLoading = false;
|
||||
this.cancelledCreation = false;
|
||||
this.$router.push(`/room/${res.data.room}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
cancelRoom() {
|
||||
this.cancelledCreation = true;
|
||||
this.isLoading = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.description, .video-title {
|
||||
height: 25px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.empty {
|
||||
font-style: italic;
|
||||
}
|
||||
.users {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
padding: 2px 5px;
|
||||
border-top-left-radius: 3px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
</style>
|
||||
290
opentogethertube/opentogethertube/storage.js
Normal file
290
opentogethertube/opentogethertube/storage.js
Normal file
@@ -0,0 +1,290 @@
|
||||
const _ = require("lodash");
|
||||
const moment = require("moment");
|
||||
const { Room, CachedVideo } = require("./models");
|
||||
const Sequelize = require("sequelize");
|
||||
const { getLogger } = require("./logger.js");
|
||||
|
||||
const log = getLogger("storage");
|
||||
|
||||
module.exports = {
|
||||
getRoomByName(roomName) {
|
||||
return Room.findOne({
|
||||
where: { name: roomName },
|
||||
}).then(room => {
|
||||
if (!room) {
|
||||
log.debug(`Room ${roomName} does not exist in db.`);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: room.name,
|
||||
title: room.title,
|
||||
description: room.description,
|
||||
visibility: room.visibility,
|
||||
owner: room.owner,
|
||||
};
|
||||
}).catch(err => {
|
||||
log.error(`Failed to get room by name: ${err}`);
|
||||
});
|
||||
},
|
||||
saveRoom(room) {
|
||||
let options = {
|
||||
name: room.name,
|
||||
title: room.title,
|
||||
description: room.description,
|
||||
visibility: room.visibility,
|
||||
};
|
||||
if (room.owner) {
|
||||
options.ownerId = room.owner.id;
|
||||
}
|
||||
return Room.create(options).then(result => {
|
||||
log.info(`Saved room to db: id ${result.dataValues.id}`);
|
||||
return true;
|
||||
}).catch(err => {
|
||||
log.error(`Failed to save room to storage: ${err}`);
|
||||
return false;
|
||||
});
|
||||
},
|
||||
async isRoomNameTaken(roomName) {
|
||||
return await Room.findOne({ where: { name: roomName } }).then(room => room ? true : false).catch(() => false);
|
||||
},
|
||||
updateRoom(room) {
|
||||
return Room.findOne({
|
||||
where: { name: room.name },
|
||||
}).then(dbRoom => {
|
||||
if (!dbRoom) {
|
||||
return false;
|
||||
}
|
||||
let options = {
|
||||
name: room.name,
|
||||
title: room.title,
|
||||
description: room.description,
|
||||
visibility: room.visibility,
|
||||
};
|
||||
if (room.owner) {
|
||||
options.ownerId = room.owner.id;
|
||||
}
|
||||
return dbRoom.update(options).then(() => true);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Gets cached video information from the database. If cached information
|
||||
* is invalid, it will be omitted from the returned video object.
|
||||
* @param {string} service The service that hosts the source video.
|
||||
* @param {string} id The id of the video on the given service.
|
||||
* @return {Object} Video object, but it may contain missing properties.
|
||||
*/
|
||||
getVideoInfo(service, id) {
|
||||
return CachedVideo.findOne({ where: { service: service, serviceId: id } }).then(cachedVideo => {
|
||||
if (cachedVideo === null) {
|
||||
log.info(`Cache missed: ${service} ${id}`);
|
||||
return { service, id };
|
||||
}
|
||||
const origCreatedAt = moment(cachedVideo.createdAt);
|
||||
const lastUpdatedAt = moment(cachedVideo.updatedAt);
|
||||
const today = moment();
|
||||
// We check for changes every at an interval of 30 days, unless the original cache date was
|
||||
// less than 7 days ago, then the interval is 7 days. The reason for this is that the uploader
|
||||
// is unlikely to change the video info after a week of the original upload. Since we don't store
|
||||
// the upload date, we pretend the original cache date is the upload date. This is potentially an
|
||||
// over optimization.
|
||||
const isCachedInfoValid = lastUpdatedAt.diff(today, "days") <= (origCreatedAt.diff(today, "days") <= 7) ? 7 : 30;
|
||||
let video = {
|
||||
service: cachedVideo.service,
|
||||
id: cachedVideo.serviceId,
|
||||
};
|
||||
// We only invalidate the title and description because those are the only ones that can change.
|
||||
if (cachedVideo.title !== null && isCachedInfoValid) {
|
||||
video.title = cachedVideo.title;
|
||||
}
|
||||
if (cachedVideo.description !== null && isCachedInfoValid) {
|
||||
video.description = cachedVideo.description;
|
||||
}
|
||||
if (cachedVideo.thumbnail !== null && (video.service !== "googledrive" && isCachedInfoValid || (video.service === "googledrive" && lastUpdatedAt.diff(today, "hour") <= 12))) {
|
||||
video.thumbnail = cachedVideo.thumbnail;
|
||||
}
|
||||
if (cachedVideo.length !== null && isCachedInfoValid) {
|
||||
video.length = cachedVideo.length;
|
||||
}
|
||||
if (cachedVideo.mime !== null) {
|
||||
video.mime = cachedVideo.mime;
|
||||
}
|
||||
return video;
|
||||
}).catch(err => {
|
||||
log.warn(`Cache failure ${err}`);
|
||||
return { service, id };
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Gets cached video information from the database. If cached information
|
||||
* is invalid, it will be omitted from the returned video object.
|
||||
* Does not guarantee order will be maintained.
|
||||
* @param {Array.<Video|Object>} videos The videos to find in the cache.
|
||||
* @return {Promise.<Object>} Video object, but it may contain missing properties.
|
||||
*/
|
||||
getManyVideoInfo(videos) {
|
||||
const { or, and } = Sequelize.Op;
|
||||
|
||||
videos = videos.map(video => {
|
||||
video = _.cloneDeep(video);
|
||||
video.serviceId = video.id;
|
||||
delete video.id;
|
||||
return video;
|
||||
});
|
||||
|
||||
return CachedVideo.findAll({
|
||||
where: {
|
||||
[or]: videos.map(video => {
|
||||
return {
|
||||
[and]: [
|
||||
{ service: video.service },
|
||||
{ serviceId: video.serviceId },
|
||||
],
|
||||
};
|
||||
}),
|
||||
},
|
||||
}).then(foundVideos => {
|
||||
if (videos.length !== foundVideos.length) {
|
||||
for (let video of videos) {
|
||||
if (!_.find(foundVideos, video)) {
|
||||
foundVideos.push(video);
|
||||
}
|
||||
}
|
||||
}
|
||||
return foundVideos.map(cachedVideo => {
|
||||
const origCreatedAt = moment(cachedVideo.createdAt);
|
||||
const lastUpdatedAt = moment(cachedVideo.updatedAt);
|
||||
const today = moment();
|
||||
// We check for changes every at an interval of 30 days, unless the original cache date was
|
||||
// less than 7 days ago, then the interval is 7 days. The reason for this is that the uploader
|
||||
// is unlikely to change the video info after a week of the original upload. Since we don't store
|
||||
// the upload date, we pretend the original cache date is the upload date. This is potentially an
|
||||
// over optimization.
|
||||
const isCachedInfoValid = lastUpdatedAt.diff(today, "days") <= (origCreatedAt.diff(today, "days") <= 7) ? 7 : 30;
|
||||
let video = {
|
||||
service: cachedVideo.service,
|
||||
id: cachedVideo.serviceId,
|
||||
};
|
||||
// We only invalidate the title and description because those are the only ones that can change.
|
||||
if (cachedVideo.title && isCachedInfoValid) {
|
||||
video.title = cachedVideo.title;
|
||||
}
|
||||
if (cachedVideo.description && isCachedInfoValid) {
|
||||
video.description = cachedVideo.description;
|
||||
}
|
||||
if (cachedVideo.thumbnail && isCachedInfoValid) {
|
||||
video.thumbnail = cachedVideo.thumbnail;
|
||||
}
|
||||
if (cachedVideo.length && isCachedInfoValid) {
|
||||
video.length = cachedVideo.length;
|
||||
}
|
||||
if (cachedVideo.mime) {
|
||||
video.mime = cachedVideo.mime;
|
||||
}
|
||||
return video;
|
||||
});
|
||||
}).catch(err => {
|
||||
log.warn(`Cache failure ${err}`);
|
||||
return videos;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Updates the database with the given video. If the video exists in
|
||||
* the database, it is overwritten. Omitted properties will not be
|
||||
* overwritten. If the video does not exist in the database, it will be
|
||||
* created.
|
||||
* @param {Video|Object} video Video object to store
|
||||
*/
|
||||
updateVideoInfo(video, shouldLog=true) {
|
||||
video = _.cloneDeep(video);
|
||||
if (!video.serviceId) {
|
||||
video.serviceId = video.id;
|
||||
delete video.id;
|
||||
}
|
||||
|
||||
return CachedVideo.findOne({ where: { service: video.service, serviceId: video.serviceId } }).then(cachedVideo => {
|
||||
if (shouldLog) {
|
||||
log.info(`Found video ${video.service}:${video.serviceId} in cache`);
|
||||
}
|
||||
return CachedVideo.update(video, { where: { id: cachedVideo.id } }).then(rowsUpdated => {
|
||||
if (shouldLog) {
|
||||
log.info(`Updated database records, updated ${rowsUpdated[0]} rows`);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}).catch(() => {
|
||||
return CachedVideo.create(video).then(() => {
|
||||
if (shouldLog) {
|
||||
log.info(`Stored video info for ${video.service}:${video.serviceId} in cache`);
|
||||
}
|
||||
return true;
|
||||
}).catch(err => {
|
||||
log.error(`Failed to cache video info ${err}`);
|
||||
return false;
|
||||
});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Updates the database for all the videos in the given list. If a video
|
||||
* exists in the database, it is overwritten. Omitted properties will not
|
||||
* be overwritten. If the video does not exist in the database, it will be
|
||||
* created.
|
||||
*
|
||||
* This also minimizes the number of database queries made by doing bulk
|
||||
* queries instead of a query for each video.
|
||||
* @param {Array} videos List of videos to store.
|
||||
*/
|
||||
updateManyVideoInfo(videos) {
|
||||
const { or, and } = Sequelize.Op;
|
||||
|
||||
videos = videos.map(video => {
|
||||
video = _.cloneDeep(video);
|
||||
video.serviceId = video.id;
|
||||
delete video.id;
|
||||
return video;
|
||||
});
|
||||
|
||||
return CachedVideo.findAll({
|
||||
where: {
|
||||
[or]: videos.map(video => {
|
||||
return {
|
||||
[and]: [
|
||||
{ service: video.service },
|
||||
{ serviceId: video.serviceId },
|
||||
],
|
||||
};
|
||||
}),
|
||||
},
|
||||
}).then(async foundVideos => {
|
||||
let [
|
||||
toUpdate,
|
||||
toCreate,
|
||||
] = _.partition(videos, video => _.find(foundVideos, { service: video.service, serviceId: video.serviceId }));
|
||||
log.debug(`bulk cache: should update ${toUpdate.length} rows, create ${toCreate.length} rows`);
|
||||
let promises = toUpdate.map(video => this.updateVideoInfo(video, false));
|
||||
if (toCreate.length) {
|
||||
promises.push(CachedVideo.bulkCreate(toCreate));
|
||||
}
|
||||
return Promise.all(promises).then(() => {
|
||||
log.info(`bulk cache: created ${toCreate.length} rows, updated ${toUpdate.length} rows`);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
},
|
||||
getVideoInfoFields(service=undefined) {
|
||||
let fields = [];
|
||||
for (let column in CachedVideo.rawAttributes) {
|
||||
if (column === "id" || column === "createdAt" || column === "updatedAt" || column === "serviceId") {
|
||||
continue;
|
||||
}
|
||||
// eslint-disable-next-line array-bracket-newline
|
||||
if (["youtube", "vimeo", "dailymotion"].includes(service) && column === "mime") {
|
||||
continue;
|
||||
}
|
||||
if (service === "googledrive" && column === "description") {
|
||||
continue;
|
||||
}
|
||||
fields.push(column);
|
||||
}
|
||||
return fields;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
jest: true
|
||||
},
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import Vue from 'vue';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import Vuetify from 'vuetify';
|
||||
import Faq from "@/views/Faq.vue";
|
||||
|
||||
// HACK: import globally to prevent it from yelling at us
|
||||
// https://github.com/vuetifyjs/vuetify/issues/4964
|
||||
Vue.use(Vuetify);
|
||||
|
||||
describe("FAQ view", () => {
|
||||
it("should render without failing", () => {
|
||||
shallowMount(Faq);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import Vue from 'vue';
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import Vuetify from 'vuetify';
|
||||
import Home from "@/views/Home.vue";
|
||||
import VueEvents from 'vue-events';
|
||||
|
||||
// HACK: import globally to prevent it from yelling at us
|
||||
// https://github.com/vuetifyjs/vuetify/issues/4964
|
||||
Vue.use(Vuetify);
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VueEvents);
|
||||
|
||||
describe("Home view", () => {
|
||||
let vuetify;
|
||||
|
||||
beforeEach(() => {
|
||||
vuetify = new Vuetify();
|
||||
});
|
||||
|
||||
it("should render without failing", () => {
|
||||
shallowMount(Home, {
|
||||
localVue,
|
||||
vuetify,
|
||||
stubs: ['router-link'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import { shallowMount, mount, createLocalVue } from '@vue/test-utils';
|
||||
import Vuetify from 'vuetify';
|
||||
import LogInForm from '@/components/LogInForm.vue';
|
||||
|
||||
// HACK: import globally to prevent it from yelling at us
|
||||
// https://github.com/vuetifyjs/vuetify/issues/4964
|
||||
Vue.use(Vuetify);
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
|
||||
describe("Login form", () => {
|
||||
let vuetify;
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
vuetify = new Vuetify();
|
||||
store = new Vuex.Store({
|
||||
state: {
|
||||
username: "stored username",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should render without failing", () => {
|
||||
shallowMount(LogInForm, {
|
||||
store,
|
||||
localVue,
|
||||
vuetify,
|
||||
stubs: ['router-link'],
|
||||
});
|
||||
});
|
||||
|
||||
it("should have all password fields be type=password", () => {
|
||||
const wrapper = mount(LogInForm, {
|
||||
store,
|
||||
localVue,
|
||||
vuetify,
|
||||
stubs: ['router-link'],
|
||||
});
|
||||
|
||||
let passwordFields = wrapper.findAll(".v-input").filter(e => e.find("label").text.lower().includes("password"));
|
||||
for (let i = 0; i < passwordFields.length; i++) {
|
||||
expect(passwordFields.at(i).find("input").type).toEqual("password");
|
||||
}
|
||||
});
|
||||
|
||||
it("should have all fields be required", () => {
|
||||
const wrapper = mount(LogInForm, {
|
||||
store,
|
||||
localVue,
|
||||
vuetify,
|
||||
stubs: ['router-link'],
|
||||
});
|
||||
|
||||
let fields = wrapper.findAll(".v-input");
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
expect(fields.at(i).find("input").required).toEqual("required");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import Vue from 'vue';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import Vuetify from 'vuetify';
|
||||
import NotFound from "@/views/NotFound.vue";
|
||||
|
||||
// HACK: import globally to prevent it from yelling at us
|
||||
// https://github.com/vuetifyjs/vuetify/issues/4964
|
||||
Vue.use(Vuetify);
|
||||
|
||||
describe("NotFound view", () => {
|
||||
it("should render without failing", () => {
|
||||
shallowMount(NotFound);
|
||||
});
|
||||
});
|
||||
181
opentogethertube/opentogethertube/tests/unit/client/Room.spec.js
Normal file
181
opentogethertube/opentogethertube/tests/unit/client/Room.spec.js
Normal file
@@ -0,0 +1,181 @@
|
||||
import Vue from 'vue';
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import Vuex from 'vuex';
|
||||
import VueEvents from 'vue-events';
|
||||
import Vuetify from 'vuetify';
|
||||
import VueSlider from 'vue-slider-component';
|
||||
import Room from '@/views/Room';
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
// HACK: import globally to prevent it from yelling at us
|
||||
// https://github.com/vuetifyjs/vuetify/issues/4964
|
||||
Vue.use(Vuetify);
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
localVue.use(VueEvents);
|
||||
localVue.component('VueSlider', VueSlider);
|
||||
|
||||
const $route = {
|
||||
path: 'http://localhost:8080/room/example',
|
||||
params: {
|
||||
roomId: 'example',
|
||||
},
|
||||
};
|
||||
|
||||
describe('Room UI spec', () => {
|
||||
let wrapper;
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new Vuex.Store({
|
||||
state: {
|
||||
socket: {
|
||||
isConnected: false,
|
||||
message: '',
|
||||
reconnectError: false,
|
||||
},
|
||||
joinFailureReason: null,
|
||||
production: true,
|
||||
room: {
|
||||
name: "example",
|
||||
title: "",
|
||||
description: "",
|
||||
isTemporary: false,
|
||||
currentSource: {},
|
||||
queue: [],
|
||||
isPlaying: false,
|
||||
playbackPosition: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
wrapper = shallowMount(Room, {
|
||||
store,
|
||||
localVue,
|
||||
mocks: {
|
||||
$route,
|
||||
$connect: jest.fn(),
|
||||
$disconnect: jest.fn(),
|
||||
},
|
||||
stubs: [
|
||||
'youtube',
|
||||
'router-link',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await wrapper.destroy();
|
||||
});
|
||||
|
||||
it('should render room title in permanent rooms, if the room has one', () => {
|
||||
store.state.room.title = 'Example Room';
|
||||
store.state.room.isTemporary = false;
|
||||
const roomTitle = wrapper.find('h1');
|
||||
expect(roomTitle.text()).toEqual('Example Room');
|
||||
});
|
||||
|
||||
it('should render room name in permanent rooms, if the room has no title', () => {
|
||||
store.state.room.title = '';
|
||||
store.state.room.isTemporary = false;
|
||||
const roomTitle = wrapper.find('h1');
|
||||
expect(roomTitle.text()).toEqual('example');
|
||||
});
|
||||
|
||||
it('should render "Temporary Room" in temporary rooms, if the room has no title', done => {
|
||||
store.state.room.title = '';
|
||||
store.state.room.isTemporary = true;
|
||||
const roomTitle = wrapper.find('h1');
|
||||
wrapper.vm.$nextTick(() => {
|
||||
expect(roomTitle.text()).toEqual('Temporary Room');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render timestamps as 00:00 if there is nothing playing', () => {
|
||||
store.state.room.currentSource = {};
|
||||
jest.advanceTimersByTime(1000);
|
||||
const timestamp = wrapper.find('.video-controls .timestamp');
|
||||
expect(timestamp.exists()).toBe(true);
|
||||
expect(timestamp.text()).toEqual('00:00 / 00:00');
|
||||
});
|
||||
|
||||
it('should render timestamps if there is something playing', () => {
|
||||
store.state.room.currentSource = { service: "youtube", id: "I3O9J02G67I", length: 10 };
|
||||
store.state.room.playbackPosition = 3;
|
||||
jest.advanceTimersByTime(1000);
|
||||
const timestamp = wrapper.find('.video-controls .timestamp');
|
||||
expect(timestamp.exists()).toBe(true);
|
||||
expect(timestamp.text()).toEqual('00:03 / 00:10');
|
||||
});
|
||||
|
||||
it('should render a disabled video slider if there is nothing playing', () => {
|
||||
store.state.room.currentSource = {};
|
||||
const videoSlider = wrapper.find('#videoSlider');
|
||||
expect(videoSlider.exists()).toBe(true);
|
||||
expect(videoSlider.attributes('disabled')).toBe("true");
|
||||
});
|
||||
|
||||
it('should render an enabled video slider if there is something playing', () => {
|
||||
store.state.room.currentSource = { service: "youtube", id: "I3O9J02G67I", length: 10 };
|
||||
const videoSlider = wrapper.find('#videoSlider');
|
||||
expect(videoSlider.exists()).toBe(true);
|
||||
expect(videoSlider.attributes('disabled')).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should render "Connected" if connected', () => {
|
||||
store.state.socket.isConnected = true;
|
||||
const connectStatusElement = wrapper.find('#connectStatus');
|
||||
expect(connectStatusElement.exists()).toBe(true);
|
||||
expect(connectStatusElement.text()).toEqual('Connected');
|
||||
});
|
||||
|
||||
it('should render "Connecting.." if not connected', () => {
|
||||
store.state.socket.isConnected = false;
|
||||
const connectStatusElement = wrapper.find('#connectStatus');
|
||||
expect(connectStatusElement.exists()).toBe(true);
|
||||
expect(connectStatusElement.text()).toEqual('Connecting...');
|
||||
});
|
||||
|
||||
it('should render the number of videos queued', () => {
|
||||
store.state.room.queue = [];
|
||||
const queueCount = wrapper.find('.bubble');
|
||||
expect(queueCount.exists()).toBe(true);
|
||||
expect(queueCount.text()).toEqual('0');
|
||||
|
||||
store.state.room.queue = [
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
];
|
||||
expect(queueCount.text()).toEqual('3');
|
||||
});
|
||||
|
||||
it('should render test buttons when in dev environment', () => {
|
||||
store.state.production = false;
|
||||
const testVideoButtons = wrapper.find('.video-add').findAll({ name: 'v-btn' });
|
||||
expect(testVideoButtons.length).toBeGreaterThanOrEqual(1);
|
||||
expect(testVideoButtons.at(0).text()).toEqual('Add test youtube 0');
|
||||
expect(testVideoButtons.at(1).text()).toEqual('Add test youtube 1');
|
||||
expect(testVideoButtons.at(2).text()).toEqual('Add test vimeo 2');
|
||||
});
|
||||
|
||||
it('should NOT render test buttons when in production environment', () => {
|
||||
store.state.production = true;
|
||||
const testVideoButtons = wrapper.find('.video-add').findAll({ name: 'v-btn' });
|
||||
expect(testVideoButtons.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should render join failure overlay', () => {
|
||||
wrapper.setData({
|
||||
showJoinFailOverlay: true,
|
||||
joinFailReason: 'Room does not exist',
|
||||
});
|
||||
const overlay = wrapper.find({ name: 'v-overlay' });
|
||||
expect(overlay.exists()).toBe(true);
|
||||
expect(overlay.isVisible()).toBe(true);
|
||||
expect(overlay.find('span').text()).toEqual('Room does not exist');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import moment from 'moment';
|
||||
import { secondsToTimestamp, calculateCurrentPosition } from '../../../src/timestamp';
|
||||
|
||||
describe('secondsToTimestamp spec', () => {
|
||||
it('handles positive values', () => {
|
||||
let t = secondsToTimestamp(120);
|
||||
expect(t).toMatch("02:00");
|
||||
});
|
||||
|
||||
it('handles negative values', () => {
|
||||
let t = secondsToTimestamp(-120);
|
||||
expect(t).toMatch("-02:00");
|
||||
});
|
||||
|
||||
it('handles time spans over an hour', () => {
|
||||
let t = secondsToTimestamp(5400);
|
||||
expect(t).toMatch("01:30:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateCurrentPosition spec', () => {
|
||||
it("should calculate the correct playback position", () => {
|
||||
expect(calculateCurrentPosition(moment(), moment(), 0)).toEqual(0);
|
||||
expect(calculateCurrentPosition(moment(), moment(), 1)).toEqual(1);
|
||||
expect(calculateCurrentPosition(moment("8 Mar 2020 05:00:00 GMT"), moment("8 Mar 2020 05:00:03 GMT"), 0)).toEqual(3);
|
||||
expect(calculateCurrentPosition(moment("8 Mar 2020 05:00:00 GMT"), moment("8 Mar 2020 05:01:00 GMT"), 0)).toEqual(60);
|
||||
expect(calculateCurrentPosition(moment("8 Mar 2020 05:00:00 EST"), moment("8 Mar 2020 05:00:03 EST").utcOffset("+0200"), 0)).toEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
describe('Example spec', () => {
|
||||
it('checks string concatination', () => {
|
||||
let s = "Hello ";
|
||||
s += "World!";
|
||||
expect(s).toMatch("Hello World!");
|
||||
});
|
||||
});
|
||||
610
opentogethertube/opentogethertube/tests/unit/server/api.spec.js
Normal file
610
opentogethertube/opentogethertube/tests/unit/server/api.spec.js
Normal file
@@ -0,0 +1,610 @@
|
||||
const request = require('supertest');
|
||||
const roommanager = require('../../../roommanager.js');
|
||||
jest.spyOn(roommanager, "getAllLoadedRooms").mockReturnValue(Promise.resolve([]));
|
||||
const app = require('../../../app.js').app;
|
||||
const InfoExtract = require('../../../infoextract.js');
|
||||
const { Room } = require("../../../models");
|
||||
|
||||
const TEST_API_KEY = "TESTAPIKEY";
|
||||
|
||||
describe("Room API", () => {
|
||||
beforeEach(() => {
|
||||
roommanager.unloadRoom("test");
|
||||
roommanager.unloadRoom("test1");
|
||||
roommanager.unloadRoom("test2");
|
||||
roommanager.unloadRoom("test3");
|
||||
});
|
||||
|
||||
describe("GET /room/list", () => {
|
||||
beforeAll(() => {
|
||||
process.env.OPENTOGETHERTUBE_API_KEY = TEST_API_KEY;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
roommanager.unloadRoom("test");
|
||||
roommanager.unloadRoom("test1");
|
||||
roommanager.unloadRoom("test2");
|
||||
roommanager.unloadRoom("test3");
|
||||
});
|
||||
|
||||
it("should get 0 rooms", async () => {
|
||||
await request(app)
|
||||
.get("/api/room/list")
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get 3 public rooms", async () => {
|
||||
await roommanager.createRoom("test1", true);
|
||||
await roommanager.createRoom("test2", true);
|
||||
await roommanager.createRoom("test3", true);
|
||||
roommanager.rooms[0].clients = [{}];
|
||||
|
||||
await request(app)
|
||||
.get("/api/room/list")
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body).toHaveLength(3);
|
||||
expect(resp.body[0]).toEqual({
|
||||
name: "test1",
|
||||
title: "",
|
||||
description: "",
|
||||
isTemporary: true,
|
||||
visibility: "public",
|
||||
currentSource: {},
|
||||
users: 1,
|
||||
});
|
||||
expect(resp.body[1]).toEqual({
|
||||
name: "test2",
|
||||
title: "",
|
||||
description: "",
|
||||
isTemporary: true,
|
||||
visibility: "public",
|
||||
currentSource: {},
|
||||
users: 0,
|
||||
});
|
||||
expect(resp.body[2]).toEqual({
|
||||
name: "test3",
|
||||
title: "",
|
||||
description: "",
|
||||
isTemporary: true,
|
||||
visibility: "public",
|
||||
currentSource: {},
|
||||
users: 0,
|
||||
});
|
||||
});
|
||||
|
||||
roommanager.unloadRoom("test1");
|
||||
roommanager.unloadRoom("test2");
|
||||
roommanager.unloadRoom("test3");
|
||||
});
|
||||
|
||||
it("should get 1 public room and exclude unlisted and private rooms", async () => {
|
||||
await roommanager.createRoom("test1", true, "public");
|
||||
await roommanager.createRoom("test2", true, "unlisted");
|
||||
await roommanager.createRoom("test3", true, "private");
|
||||
|
||||
await request(app)
|
||||
.get("/api/room/list")
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body).toHaveLength(1);
|
||||
expect(resp.body[0]).toEqual({
|
||||
name: "test1",
|
||||
title: "",
|
||||
description: "",
|
||||
isTemporary: true,
|
||||
visibility: "public",
|
||||
currentSource: {},
|
||||
users: 0,
|
||||
});
|
||||
});
|
||||
|
||||
roommanager.unloadRoom("test1");
|
||||
roommanager.unloadRoom("test2");
|
||||
roommanager.unloadRoom("test3");
|
||||
});
|
||||
|
||||
it("should get all room if valid api key is provided", async () => {
|
||||
await roommanager.createRoom("test1", true, "public");
|
||||
await roommanager.createRoom("test2", true, "unlisted");
|
||||
await roommanager.createRoom("test3", true, "private");
|
||||
|
||||
await request(app)
|
||||
.get("/api/room/list")
|
||||
.set("apikey", TEST_API_KEY)
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body).toHaveLength(3);
|
||||
expect(resp.body[0]).toEqual({
|
||||
name: "test1",
|
||||
title: "",
|
||||
description: "",
|
||||
isTemporary: true,
|
||||
visibility: "public",
|
||||
currentSource: {},
|
||||
users: 0,
|
||||
});
|
||||
expect(resp.body[1]).toEqual({
|
||||
name: "test2",
|
||||
title: "",
|
||||
description: "",
|
||||
isTemporary: true,
|
||||
visibility: "unlisted",
|
||||
currentSource: {},
|
||||
users: 0,
|
||||
});
|
||||
expect(resp.body[2]).toEqual({
|
||||
name: "test3",
|
||||
title: "",
|
||||
description: "",
|
||||
isTemporary: true,
|
||||
visibility: "private",
|
||||
currentSource: {},
|
||||
users: 0,
|
||||
});
|
||||
});
|
||||
|
||||
roommanager.unloadRoom("test1");
|
||||
roommanager.unloadRoom("test2");
|
||||
roommanager.unloadRoom("test3");
|
||||
});
|
||||
});
|
||||
|
||||
it("GET /room/:name", async done => {
|
||||
await roommanager.createRoom("test1", true);
|
||||
|
||||
await request(app)
|
||||
.get("/api/room/test1")
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body.name).toBe("test1");
|
||||
expect(resp.body.title).toBe("");
|
||||
expect(resp.body.description).toBe("");
|
||||
expect(resp.body.queueMode).toBe("manual");
|
||||
expect(resp.body.visibility).toBe("public");
|
||||
});
|
||||
|
||||
roommanager.unloadRoom("test1");
|
||||
|
||||
await roommanager.createRoom("test1", true, "unlisted");
|
||||
|
||||
await request(app)
|
||||
.get("/api/room/test1")
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body.name).toBe("test1");
|
||||
expect(resp.body.title).toBe("");
|
||||
expect(resp.body.description).toBe("");
|
||||
expect(resp.body.queueMode).toBe("manual");
|
||||
expect(resp.body.visibility).toBe("unlisted");
|
||||
});
|
||||
|
||||
roommanager.unloadRoom("test1", true);
|
||||
|
||||
await request(app)
|
||||
.get("/api/room/test1")
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(404)
|
||||
.then(resp => {
|
||||
expect(resp.body).toEqual({
|
||||
success: false,
|
||||
error: "Room not found",
|
||||
});
|
||||
});
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
it("POST /room/create", async done => {
|
||||
await request(app)
|
||||
.post("/api/room/create")
|
||||
.send({ name: "test1", temporary: true, visibility: "public" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBe(true);
|
||||
expect(roommanager.rooms[0].name).toBe("test1");
|
||||
expect(roommanager.rooms[0].isTemporary).toBe(true);
|
||||
expect(roommanager.rooms[0].visibility).toBe("public");
|
||||
});
|
||||
|
||||
roommanager.unloadRoom("test1", true);
|
||||
|
||||
await request(app)
|
||||
.post("/api/room/create")
|
||||
.send({ name: "test1", temporary: true, visibility: "unlisted" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBe(true);
|
||||
expect(roommanager.rooms[0].name).toBe("test1");
|
||||
expect(roommanager.rooms[0].isTemporary).toBe(true);
|
||||
expect(roommanager.rooms[0].visibility).toBe("unlisted");
|
||||
});
|
||||
|
||||
roommanager.unloadRoom("test1", true);
|
||||
|
||||
await request(app)
|
||||
.post("/api/room/create")
|
||||
.send({ name: "test1", temporary: true, visibility: "invalid" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBe(false);
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post("/api/room/create")
|
||||
.send({ temporary: true })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBe(false);
|
||||
expect(resp.body.error.message).toContain("Missing argument");
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post("/api/room/create")
|
||||
.send({ name: "a", temporary: true })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBe(false);
|
||||
expect(resp.body.error.message).toContain("not allowed");
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post("/api/room/create")
|
||||
.send({ name: "list", temporary: true })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBe(false);
|
||||
expect(resp.body.error.message).toContain("not allowed");
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post("/api/room/create")
|
||||
.send({ name: "create", temporary: true })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBe(false);
|
||||
expect(resp.body.error.message).toContain("not allowed");
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post("/api/room/create")
|
||||
.send({ name: "generate", temporary: true })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBe(false);
|
||||
expect(resp.body.error.message).toContain("not allowed");
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post("/api/room/create")
|
||||
.send({ name: "?><>J", temporary: true })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBe(false);
|
||||
expect(resp.body.error.message).toContain("not allowed");
|
||||
});
|
||||
|
||||
// should create permanent room without owner
|
||||
await request(app)
|
||||
.post("/api/room/create")
|
||||
.send({ name: "testnoowner" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBeTruthy();
|
||||
expect(roommanager.rooms[0].name).toBe("testnoowner");
|
||||
expect(roommanager.rooms[0].owner).toBeNull();
|
||||
});
|
||||
await Room.destroy({ where: { name: "testnoowner" } });
|
||||
roommanager.unloadRoom("testnoowner");
|
||||
|
||||
// should create permanent room with owner
|
||||
let cookies;
|
||||
await request(app)
|
||||
.get("/api/user/test/forceLogin")
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
cookies = resp.header["set-cookie"];
|
||||
});
|
||||
await request(app)
|
||||
.post("/api/room/create")
|
||||
.set("Cookie", cookies)
|
||||
.send({ name: "testowner" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBeTruthy();
|
||||
expect(roommanager.rooms[0].name).toBe("testowner");
|
||||
expect(roommanager.rooms[0].owner.email).toBe("forced@localhost");
|
||||
});
|
||||
await Room.destroy({ where: { name: "testowner" } });
|
||||
roommanager.unloadRoom("testowner");
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
it("POST /room/generate", done => {
|
||||
request(app)
|
||||
.post("/api/room/generate")
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBe(true);
|
||||
expect(resp.body.room).toBeDefined();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("PATCH /room/:name", async done => {
|
||||
await roommanager.createRoom("test1", true);
|
||||
|
||||
await request(app)
|
||||
.patch("/api/room/test1")
|
||||
.send({ title: "Test" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBe(true);
|
||||
});
|
||||
|
||||
roommanager.unloadRoom("test1");
|
||||
|
||||
await roommanager.createRoom("test1", true);
|
||||
|
||||
await request(app)
|
||||
.patch("/api/room/test1")
|
||||
.send({ visibility: "unlisted" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBe(true);
|
||||
});
|
||||
await request(app)
|
||||
.patch("/api/room/test1")
|
||||
.send({ visibility: "invalid" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBe(false);
|
||||
});
|
||||
|
||||
roommanager.unloadRoom("test1");
|
||||
|
||||
await roommanager.createRoom("test1", true);
|
||||
|
||||
await request(app)
|
||||
.patch("/api/room/test1")
|
||||
.send({ queueMode: "vote" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBe(true);
|
||||
});
|
||||
await request(app)
|
||||
.patch("/api/room/test1")
|
||||
.send({ queueMode: "invalid" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBe(false);
|
||||
});
|
||||
|
||||
roommanager.unloadRoom("test1");
|
||||
|
||||
await request(app)
|
||||
.patch("/api/room/test1")
|
||||
.send({ title: "Test" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(404)
|
||||
.then(resp => {
|
||||
expect(resp.body).toEqual({
|
||||
success: false,
|
||||
error: "Room not found",
|
||||
});
|
||||
});
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
it("DELETE /room/:name", async done => {
|
||||
await roommanager.createRoom("test1", true);
|
||||
|
||||
await request(app)
|
||||
.delete("/api/room/test1")
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBe(true);
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.delete("/api/room/test1")
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(404)
|
||||
.then(resp => {
|
||||
expect(resp.body).toEqual({
|
||||
success: false,
|
||||
error: "Room not found",
|
||||
});
|
||||
});
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Data API", () => {
|
||||
it("GET /data/previewAdd", async done => {
|
||||
let getAddPreviewSpy = jest.spyOn(InfoExtract, "getAddPreview").mockReturnValue(Promise.resolve([]));
|
||||
|
||||
await request(app)
|
||||
.get("/api/data/previewAdd")
|
||||
.query({ input: "test search query" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body).toHaveLength(0);
|
||||
expect(getAddPreviewSpy).toBeCalled();
|
||||
});
|
||||
|
||||
getAddPreviewSpy.mockRestore();
|
||||
getAddPreviewSpy = jest.spyOn(InfoExtract, "getAddPreview").mockImplementation(() => new Promise((resolve, reject) => reject({ name: "UnsupportedServiceException", message: "error message" })));
|
||||
|
||||
await request(app)
|
||||
.get("/api/data/previewAdd")
|
||||
.query({ input: "test search query" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBe(false);
|
||||
expect(resp.body.error).toBeDefined();
|
||||
expect(getAddPreviewSpy).toBeCalled();
|
||||
});
|
||||
|
||||
getAddPreviewSpy.mockRestore();
|
||||
getAddPreviewSpy = jest.spyOn(InfoExtract, "getAddPreview").mockImplementation(() => new Promise((resolve, reject) => reject({ name: "InvalidAddPreviewInputException", message: "error message" })));
|
||||
|
||||
await request(app)
|
||||
.get("/api/data/previewAdd")
|
||||
.query({ input: "test search query" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBe(false);
|
||||
expect(resp.body.error).toBeDefined();
|
||||
expect(getAddPreviewSpy).toBeCalled();
|
||||
});
|
||||
|
||||
getAddPreviewSpy.mockRestore();
|
||||
getAddPreviewSpy = jest.spyOn(InfoExtract, "getAddPreview").mockImplementation(() => new Promise((resolve, reject) => reject({ name: "OutOfQuotaException", message: "error message" })));
|
||||
|
||||
await request(app)
|
||||
.get("/api/data/previewAdd")
|
||||
.query({ input: "test search query" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBe(false);
|
||||
expect(resp.body.error).toBeDefined();
|
||||
expect(getAddPreviewSpy).toBeCalled();
|
||||
});
|
||||
|
||||
getAddPreviewSpy.mockRestore();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Announcements API", () => {
|
||||
beforeAll(() => {
|
||||
process.env.OPENTOGETHERTUBE_API_KEY = TEST_API_KEY;
|
||||
});
|
||||
|
||||
it("should send an announcement", async () => {
|
||||
let sendAnnouncementSpy = jest.spyOn(roommanager, "sendAnnouncement").mockImplementation(() => {});
|
||||
|
||||
await request(app)
|
||||
.post("/api/announce")
|
||||
.send({ apikey: TEST_API_KEY, text: "test announcement" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body).toEqual({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
expect(sendAnnouncementSpy).toHaveBeenCalledWith("test announcement");
|
||||
|
||||
sendAnnouncementSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not send announcement if the api key does not match", async () => {
|
||||
let sendAnnouncementSpy = jest.spyOn(roommanager, "sendAnnouncement").mockImplementation(() => {});
|
||||
|
||||
await request(app)
|
||||
.post("/api/announce")
|
||||
.send({ text: "test announcement" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
.then(resp => {
|
||||
expect(resp.body).toEqual({
|
||||
success: false,
|
||||
error: "apikey was not supplied",
|
||||
});
|
||||
});
|
||||
expect(sendAnnouncementSpy).not.toHaveBeenCalled();
|
||||
|
||||
sendAnnouncementSpy.mockReset();
|
||||
|
||||
await request(app)
|
||||
.post("/api/announce")
|
||||
.send({ apikey: "wrong key", text: "test announcement" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
.then(resp => {
|
||||
expect(resp.body).toEqual({
|
||||
success: false,
|
||||
error: "apikey is invalid",
|
||||
});
|
||||
});
|
||||
expect(sendAnnouncementSpy).not.toHaveBeenCalled();
|
||||
|
||||
sendAnnouncementSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not send an announcement if no text is provided", async () => {
|
||||
let sendAnnouncementSpy = jest.spyOn(roommanager, "sendAnnouncement").mockImplementation(() => {});
|
||||
|
||||
await request(app)
|
||||
.post("/api/announce")
|
||||
.send({ apikey: TEST_API_KEY })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
.then(resp => {
|
||||
expect(resp.body).toEqual({
|
||||
success: false,
|
||||
error: "text was not supplied",
|
||||
});
|
||||
});
|
||||
expect(sendAnnouncementSpy).not.toHaveBeenCalled();
|
||||
|
||||
sendAnnouncementSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should fail if an unknown error occurrs", async () => {
|
||||
let sendAnnouncementSpy = jest.spyOn(roommanager, "sendAnnouncement").mockImplementation(() => {
|
||||
throw new Error("fake error");
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post("/api/announce")
|
||||
.send({ apikey: TEST_API_KEY, text: "test announcement" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(500)
|
||||
.then(resp => {
|
||||
expect(resp.body).toEqual({
|
||||
success: false,
|
||||
error: "Unknown, check logs",
|
||||
});
|
||||
});
|
||||
expect(sendAnnouncementSpy).toHaveBeenCalledWith("test announcement");
|
||||
|
||||
sendAnnouncementSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
const request = require('supertest');
|
||||
const roommanager = require('../../../roommanager.js');
|
||||
jest.spyOn(roommanager, "getAllLoadedRooms").mockReturnValue(Promise.resolve([]));
|
||||
const app = require('../../../app.js').app;
|
||||
const usermanager = require('../../../usermanager.js');
|
||||
const { User } = require("../../../models");
|
||||
const Sequelize = require("sequelize");
|
||||
|
||||
const { or, not } = Sequelize.Op;
|
||||
|
||||
describe("User API", () => {
|
||||
describe("GET /user", () => {
|
||||
it("should not fail be default", done => {
|
||||
request(app)
|
||||
.get("/api/user")
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body.username).toBeDefined();
|
||||
expect(resp.body.loggedIn).toBeFalsy();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should have the forced test user logged in", async done => {
|
||||
await User.update({ username: "forced test user" }, { where: { email: "forced@localhost" } });
|
||||
|
||||
let cookies;
|
||||
await request(app)
|
||||
.get("/api/user/test/forceLogin")
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
cookies = resp.header["set-cookie"];
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.get("/api/user")
|
||||
.set("Cookie", cookies)
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body.username).toBeDefined();
|
||||
expect(resp.body.loggedIn).toBeTruthy();
|
||||
expect(resp.body).toEqual({
|
||||
username: "forced test user",
|
||||
loggedIn: true,
|
||||
});
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /user", () => {
|
||||
let onUserModifiedSpy;
|
||||
|
||||
beforeAll(() => {
|
||||
onUserModifiedSpy = jest.spyOn(usermanager, "onUserModified").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
onUserModifiedSpy.mockClear();
|
||||
await User.update({ username: "forced test user" }, { where: { email: "forced@localhost" } });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
onUserModifiedSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should change the unregistered user's name without failing", done => {
|
||||
request(app)
|
||||
.post("/api/user")
|
||||
.send({ username: "new username" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBeTruthy();
|
||||
expect(onUserModifiedSpy).toBeCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should change the registered user's name without failing", async done => {
|
||||
let cookies;
|
||||
await request(app)
|
||||
.get("/api/user/test/forceLogin")
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
cookies = resp.header["set-cookie"];
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post("/api/user")
|
||||
.set("Cookie", cookies)
|
||||
.send({ username: "new username" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBeTruthy();
|
||||
expect(onUserModifiedSpy).toBeCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not change the registered user's name if it's already in use", async done => {
|
||||
let cookies;
|
||||
await request(app)
|
||||
.get("/api/user/test/forceLogin")
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
cookies = resp.header["set-cookie"];
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post("/api/user")
|
||||
.set("Cookie", cookies)
|
||||
.send({ username: "test user" })
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBeFalsy();
|
||||
expect(resp.body.error).toBeDefined();
|
||||
expect(resp.body.error.name).toEqual("UsernameTaken");
|
||||
expect(onUserModifiedSpy).not.toBeCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("User login and registration", () => {
|
||||
describe("POST /user/login", () => {
|
||||
let onUserLogInSpy;
|
||||
|
||||
beforeAll(() => {
|
||||
onUserLogInSpy = jest.spyOn(usermanager, "onUserLogIn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
onUserLogInSpy.mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
onUserLogInSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should log in the test user", async () => {
|
||||
await request(app)
|
||||
.post("/api/user/login")
|
||||
.send({ email: "test@localhost", password: "test1234" })
|
||||
.then(resp => {
|
||||
expect(resp.body).toEqual({
|
||||
success: true,
|
||||
user: {
|
||||
username: "test user",
|
||||
email: "test@localhost",
|
||||
},
|
||||
});
|
||||
expect(onUserLogInSpy).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not log in the test user with wrong credentials", async () => {
|
||||
await request(app)
|
||||
.post("/api/user/login")
|
||||
.send({ email: "notreal@localhost", password: "test1234" })
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBeFalsy();
|
||||
expect(onUserLogInSpy).not.toBeCalled();
|
||||
});
|
||||
await request(app)
|
||||
.post("/api/user/login")
|
||||
.send({ email: "test@localhost", password: "wrong" })
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBeFalsy();
|
||||
expect(onUserLogInSpy).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /user/logout", () => {
|
||||
let onUserLogOutSpy;
|
||||
|
||||
beforeAll(() => {
|
||||
onUserLogOutSpy = jest.spyOn(usermanager, "onUserLogOut").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
onUserLogOutSpy.mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
onUserLogOutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should log out the test uesr", async () => {
|
||||
let cookies;
|
||||
await request(app)
|
||||
.get("/api/user/test/forceLogin")
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
cookies = resp.header["set-cookie"];
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post("/api/user/logout")
|
||||
.set("Cookie", cookies)
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail if the user is not logged in", async () => {
|
||||
await request(app)
|
||||
.post("/api/user/logout")
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /user/register", () => {
|
||||
let onUserLogInSpy;
|
||||
|
||||
beforeAll(() => {
|
||||
onUserLogInSpy = jest.spyOn(usermanager, "onUserLogIn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
onUserLogInSpy.mockClear();
|
||||
await User.destroy({ where: { [not]: { [or]: [
|
||||
{ email: "forced@localhost" },
|
||||
{ email: "test@localhost" },
|
||||
] } } });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
onUserLogInSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should register user", async () => {
|
||||
await request(app)
|
||||
.post("/api/user/register")
|
||||
.send({ email: "register@localhost", username: "registered", password: "test1234" })
|
||||
.expect(200)
|
||||
.expect("Content-Type", /json/)
|
||||
.then(resp => {
|
||||
expect(resp.body).toEqual({
|
||||
success: true,
|
||||
user: {
|
||||
username: "registered",
|
||||
email: "register@localhost",
|
||||
},
|
||||
});
|
||||
expect(onUserLogInSpy).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not register user if email is already in use", async () => {
|
||||
await request(app)
|
||||
.post("/api/user/register")
|
||||
.send({ email: "test@localhost", username: "registered", password: "test1234" })
|
||||
.expect(400)
|
||||
.expect("Content-Type", /json/)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBeFalsy();
|
||||
expect(resp.body.error).toBeDefined();
|
||||
expect(resp.body.error.name).toEqual("AlreadyInUse");
|
||||
expect(onUserLogInSpy).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not register user if username is already in use", async () => {
|
||||
await request(app)
|
||||
.post("/api/user/register")
|
||||
.send({ email: "register@localhost", username: "test user", password: "test1234" })
|
||||
.expect(400)
|
||||
.expect("Content-Type", /json/)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBeFalsy();
|
||||
expect(resp.body.error).toBeDefined();
|
||||
expect(resp.body.error.name).toEqual("AlreadyInUse");
|
||||
expect(onUserLogInSpy).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not register user if email is invalid", async () => {
|
||||
await request(app)
|
||||
.post("/api/user/register")
|
||||
.send({ email: "bad", username: "bad email user", password: "test1234" })
|
||||
.expect(400)
|
||||
.expect("Content-Type", /json/)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBeFalsy();
|
||||
expect(resp.body.error).toBeDefined();
|
||||
expect(resp.body.error.name).toEqual("ValidationError");
|
||||
expect(onUserLogInSpy).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not register user if username is invalid", async () => {
|
||||
await request(app)
|
||||
.post("/api/user/register")
|
||||
.send({ email: "badusername@localhost", username: "", password: "test1234" })
|
||||
.expect(400)
|
||||
.expect("Content-Type", /json/)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBeFalsy();
|
||||
expect(resp.body.error).toBeDefined();
|
||||
expect(resp.body.error.name).toEqual("ValidationError");
|
||||
expect(onUserLogInSpy).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not register user if password is not good enough", async () => {
|
||||
await request(app)
|
||||
.post("/api/user/register")
|
||||
.send({ email: "badpassword@localhost", username: "bad password", password: "a" })
|
||||
.expect(400)
|
||||
.expect("Content-Type", /json/)
|
||||
.then(resp => {
|
||||
expect(resp.body.success).toBeFalsy();
|
||||
expect(resp.body.error).toBeDefined();
|
||||
expect(resp.body.error.name).toEqual("ValidationError");
|
||||
expect(onUserLogInSpy).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,539 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { RoomEvent, ROOM_EVENT_TYPE, ...roommanager } = require("../../../roommanager");
|
||||
const InfoExtract = require("../../../infoextract");
|
||||
const storage = require("../../../storage");
|
||||
const moment = require("moment");
|
||||
const Video = require("../../../common/video.js");
|
||||
const { Room } = require("../../../models");
|
||||
|
||||
const configPath = path.resolve(process.cwd(), `env/${process.env.NODE_ENV}.env`);
|
||||
if (!fs.existsSync(configPath)) {
|
||||
console.error("No config found! Things will break!", configPath);
|
||||
}
|
||||
require('dotenv').config({ path: configPath });
|
||||
|
||||
describe('Room manager: Room tests', () => {
|
||||
beforeEach(async done => {
|
||||
roommanager.rooms = [];
|
||||
await Room.destroy({ where: {} });
|
||||
await roommanager.createRoom("test", true);
|
||||
roommanager.getLoadedRoom("test").then(room => {
|
||||
room.title = "Test Room";
|
||||
room.description = "This is a test room.";
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await roommanager.unloadRoom("test");
|
||||
await Room.destroy({ where: {} });
|
||||
});
|
||||
|
||||
it('should dequeue the next video in the queue, when there is no video playing', done => {
|
||||
roommanager.getLoadedRoom("test").then(room => {
|
||||
room.queue = [{ service: "youtube", id: "I3O9J02G67I", length: 10 }];
|
||||
room.update();
|
||||
|
||||
expect(room.queue.length).toEqual(0);
|
||||
expect(room.currentSource).toEqual({ service: "youtube", id: "I3O9J02G67I", length: 10 });
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should dequeue the next video in the queue, when the current video is done playing', async () => {
|
||||
let room = await roommanager.getLoadedRoom("test");
|
||||
room.queue = [{ service: "youtube", id: "I3O9J02G67I", length: 10 }];
|
||||
room.currentSource = { service: "youtube", id: "BTZ5KVRUy1Q", length: 10 };
|
||||
room.playbackPosition = 11;
|
||||
room.playbackStartTime = moment();
|
||||
room.update();
|
||||
|
||||
expect(room.queue.length).toEqual(0);
|
||||
expect(room.currentSource).toEqual({ service: "youtube", id: "I3O9J02G67I", length: 10 });
|
||||
expect(room.playbackPosition).toEqual(0);
|
||||
});
|
||||
|
||||
it('should stop playing, when the current video is done playing and the queue is empty', async () => {
|
||||
let room = await roommanager.getLoadedRoom("test");
|
||||
room.queue = [];
|
||||
room.currentSource = { service: "youtube", id: "BTZ5KVRUy1Q", length: 10 };
|
||||
room.playbackPosition = 11;
|
||||
room.isPlaying = true;
|
||||
room.playbackStartTime = moment();
|
||||
room.update();
|
||||
|
||||
expect(room.queue.length).toEqual(0);
|
||||
expect(room.currentSource).toEqual({});
|
||||
expect(room.playbackPosition).toEqual(0);
|
||||
expect(room.isPlaying).toEqual(false);
|
||||
});
|
||||
|
||||
it('should add a video to the queue with url provided, and because no video is playing, move it into currentSource', done => {
|
||||
jest.spyOn(InfoExtract, 'getVideoInfo').mockImplementation().mockResolvedValue({ service: "youtube", id: "I3O9J02G67I", length: 10 });
|
||||
roommanager.getLoadedRoom("test").then(room => {
|
||||
room.queue = [];
|
||||
|
||||
expect(room.name).toBeDefined();
|
||||
expect(room.name.length).toBeGreaterThan(0);
|
||||
expect(room.queue.length).toEqual(0);
|
||||
expect(room.currentSource).toEqual({});
|
||||
|
||||
expect(room.addToQueue({ url: "http://youtube.com/watch?v=I3O9J02G67I" })).resolves.toBe(true);
|
||||
|
||||
expect(InfoExtract.getVideoInfo).toBeCalledWith("youtube", "I3O9J02G67I");
|
||||
expect(room.queue.length).toEqual(0);
|
||||
// Make sure that any async functions waiting have finished before checking currentSource
|
||||
setImmediate(() => {
|
||||
expect(room.currentSource).toEqual({ service: "youtube", id: "I3O9J02G67I", length: 10 });
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should add a video to the queue with service and id provided, and because no video is playing, move it into currentSource', done => {
|
||||
jest.spyOn(InfoExtract, 'getVideoInfo').mockImplementation().mockResolvedValue({ service: "youtube", id: "I3O9J02G67I", length: 10 });
|
||||
roommanager.getLoadedRoom("test").then(room => {
|
||||
room.queue = [];
|
||||
|
||||
expect(room.name).toBeDefined();
|
||||
expect(room.name.length).toBeGreaterThan(0);
|
||||
expect(room.queue.length).toEqual(0);
|
||||
expect(room.currentSource).toEqual({});
|
||||
|
||||
expect(room.addToQueue({ service: "youtube", id: "I3O9J02G67I" })).resolves.toBe(true);
|
||||
|
||||
expect(InfoExtract.getVideoInfo).toBeCalledWith("youtube", "I3O9J02G67I");
|
||||
expect(room.queue.length).toEqual(0);
|
||||
// Make sure that any async functions waiting have finished before checking currentSource
|
||||
setImmediate(() => {
|
||||
expect(room.currentSource).toEqual({ service: "youtube", id: "I3O9J02G67I", length: 10 });
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should add a video to the queue with service and id provided, and because a video is playing, leave it in the queue', done => {
|
||||
jest.spyOn(InfoExtract, 'getVideoInfo').mockImplementation().mockResolvedValue({ service: "youtube", id: "I3O9J02G67I", length: 10 });
|
||||
roommanager.getLoadedRoom("test").then(room => {
|
||||
room.queue = [];
|
||||
room.currentSource = { service: "youtube", id: "BTZ5KVRUy1Q", length: 10 };
|
||||
|
||||
expect(room.name).toBeDefined();
|
||||
expect(room.name.length).toBeGreaterThan(0);
|
||||
expect(room.queue.length).toEqual(0);
|
||||
|
||||
expect(room.addToQueue({ service: "youtube", id: "I3O9J02G67I" })).resolves.toBe(true);
|
||||
|
||||
expect(InfoExtract.getVideoInfo).toBeCalledWith("youtube", "I3O9J02G67I");
|
||||
// Make sure that any async functions waiting have finished before checking currentSource
|
||||
setImmediate(() => {
|
||||
expect(room.queue.length).toEqual(1);
|
||||
expect(room.queue[0]).toEqual({ service: "youtube", id: "I3O9J02G67I", length: 10 });
|
||||
expect(room.currentSource).toEqual({ service: "youtube", id: "BTZ5KVRUy1Q", length: 10 });
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should calculate correct playback position based on commitRoomEvent', async () => {
|
||||
let room = await roommanager.getLoadedRoom("test");
|
||||
jest.spyOn(room, "sync").mockImplementation();
|
||||
room.currentSource = { service: "fakeservice", id: "abc123", length: 200 };
|
||||
room.playbackPosition = 0;
|
||||
let now = moment("2020-01-01T06:00:00");
|
||||
|
||||
room.commitRoomEvent(new RoomEvent(room.name, ROOM_EVENT_TYPE.PLAY, "user", {}), now);
|
||||
|
||||
expect(room.isPlaying).toEqual(true);
|
||||
expect(room.playbackStartTime).toEqual(now);
|
||||
expect(room.playbackPosition).toEqual(0);
|
||||
|
||||
now.add(5, "seconds");
|
||||
room.commitRoomEvent(new RoomEvent(room.name, ROOM_EVENT_TYPE.PAUSE, "user", {}), now);
|
||||
|
||||
expect(room.isPlaying).toEqual(false);
|
||||
expect(room.playbackStartTime).not.toEqual(now);
|
||||
expect(room.playbackPosition).toEqual(5);
|
||||
|
||||
now.add(10, "seconds");
|
||||
room.commitRoomEvent(new RoomEvent(room.name, ROOM_EVENT_TYPE.SEEK, "user", { position: 50 }), now);
|
||||
|
||||
expect(room.isPlaying).toEqual(false);
|
||||
expect(room.playbackStartTime).toEqual(now);
|
||||
expect(room.playbackPosition).toEqual(50);
|
||||
|
||||
// undo seek forwards
|
||||
let undoable = new RoomEvent(room.name, ROOM_EVENT_TYPE.SEEK, "user", { position: 100 });
|
||||
room.commitRoomEvent(undoable, now);
|
||||
expect(room.playbackPosition).toEqual(100);
|
||||
expect(undoable.parameters.previousPosition).toEqual(50);
|
||||
now.add(5, "seconds");
|
||||
room.undoEvent(undoable, now);
|
||||
|
||||
expect(room.isPlaying).toEqual(false);
|
||||
expect(room.playbackStartTime).toEqual(now);
|
||||
expect(room.playbackPosition).toEqual(50);
|
||||
|
||||
// undo seek backwards
|
||||
undoable = new RoomEvent(room.name, ROOM_EVENT_TYPE.SEEK, "user", { position: 20 });
|
||||
room.commitRoomEvent(undoable, now);
|
||||
expect(room.playbackPosition).toEqual(20);
|
||||
expect(undoable.parameters.previousPosition).toEqual(50);
|
||||
now.add(5, "seconds");
|
||||
room.undoEvent(undoable, now);
|
||||
|
||||
expect(room.isPlaying).toEqual(false);
|
||||
expect(room.playbackStartTime).toEqual(now);
|
||||
expect(room.playbackPosition).toEqual(50);
|
||||
|
||||
room.sync.mockReset();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Room manager: Manager tests', () => {
|
||||
beforeEach(async done => {
|
||||
await Room.destroy({ where: {} });
|
||||
roommanager.rooms = [];
|
||||
done();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Room.destroy({ where: {} });
|
||||
});
|
||||
|
||||
it('should create a temporary room with the name "tmp"', async done => {
|
||||
await roommanager.createRoom('tmp', true);
|
||||
roommanager.getLoadedRoom('tmp').then(room => {
|
||||
expect(room).toBeDefined();
|
||||
expect(room.name).toBeDefined();
|
||||
expect(room.name).toEqual('tmp');
|
||||
expect(room.isTemporary).toEqual(true);
|
||||
expect(room.keepAlivePing).toBeDefined();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a permanent room with the name "perm"', async done => {
|
||||
jest.spyOn(storage, 'saveRoom').mockImplementation();
|
||||
await roommanager.createRoom('perm', false);
|
||||
roommanager.getLoadedRoom('perm').then(room => {
|
||||
expect(room).toBeDefined();
|
||||
expect(room.name).toBeDefined();
|
||||
expect(room.name).toEqual('perm');
|
||||
expect(room.isTemporary).toEqual(false);
|
||||
expect(room.keepAlivePing).toBe(null);
|
||||
expect(storage.saveRoom).toBeCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should load the room from the database', done => {
|
||||
jest.spyOn(storage, 'getRoomByName').mockImplementation().mockResolvedValue({ name: "test", title: "Test Room", description: "This is a Test Room." });
|
||||
expect(roommanager.rooms.length).toEqual(0);
|
||||
roommanager.loadRoom("test").then(room => {
|
||||
expect(storage.getRoomByName).toBeCalled();
|
||||
expect(room).toBeDefined();
|
||||
expect(room.name).toBeDefined();
|
||||
expect(room.name).toEqual("test");
|
||||
expect(room.title).toBeDefined();
|
||||
expect(room.title).toEqual("Test Room");
|
||||
expect(room.description).toBeDefined();
|
||||
expect(room.description).toEqual("This is a Test Room.");
|
||||
expect(roommanager.rooms.length).toEqual(1);
|
||||
done();
|
||||
}).catch(err => done.fail(err));
|
||||
});
|
||||
|
||||
it('should unload the room from memory', done => {
|
||||
jest.spyOn(storage, 'getRoomByName').mockImplementation().mockResolvedValue({ name: "test", title: "Test Room", description: "This is a Test Room." });
|
||||
roommanager.loadRoom("test").then(room => {
|
||||
expect(roommanager.rooms.length).toEqual(1);
|
||||
roommanager.unloadRoom(room);
|
||||
expect(roommanager.rooms.length).toEqual(0);
|
||||
done();
|
||||
}).catch(err => done.fail(err));
|
||||
});
|
||||
|
||||
it('should unload a room with no active clients after 240 seconds', done => {
|
||||
jest.spyOn(storage, 'getRoomByName').mockImplementation().mockResolvedValue({ name: "test", title: "Test Room", description: "This is a Test Room." });
|
||||
roommanager.loadRoom("test").then((room) => {
|
||||
expect(roommanager.rooms.length).toEqual(1);
|
||||
room.keepAlivePing = moment().subtract(241, 'seconds');
|
||||
roommanager.unloadIfEmpty(room);
|
||||
expect(roommanager.rooms.length).toEqual(0);
|
||||
});
|
||||
done();
|
||||
});
|
||||
|
||||
it('should not unload a room with no active clients after 9 seconds', done => {
|
||||
jest.spyOn(storage, 'getRoomByName').mockImplementation().mockResolvedValue({ name: "test", title: "Test Room", description: "This is a Test Room." });
|
||||
roommanager.loadRoom("test").then((room) => {
|
||||
expect(roommanager.rooms.length).toEqual(1);
|
||||
room.keepAlivePing = moment().subtract(9, 'seconds');
|
||||
roommanager.unloadIfEmpty(room);
|
||||
expect(roommanager.rooms.length).toEqual(1);
|
||||
});
|
||||
done();
|
||||
});
|
||||
|
||||
it('should throw RoomNotFoundException when attempting to load a room that does not exist', done => {
|
||||
jest.spyOn(storage, 'getRoomByName').mockImplementation().mockResolvedValue(null);
|
||||
roommanager.loadRoom("test").then(() => {
|
||||
done.fail();
|
||||
}).catch(err => {
|
||||
expect(err.name).toEqual('RoomNotFoundException');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw RoomAlreadyLoadedException when attempting to load a room that is already loaded', async done => {
|
||||
jest.spyOn(storage, 'getRoomByName').mockImplementation().mockResolvedValue({ name: "test", title: "Test Room", description: "This is a Test Room." });
|
||||
await roommanager.loadRoom("test");
|
||||
try {
|
||||
roommanager.loadRoom("test").then(() => {
|
||||
done.fail();
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
expect(err.name).toEqual("RoomAlreadyLoadedException");
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw RoomNameTakenException if a room with a given name already exists', async () => {
|
||||
await roommanager.createRoom("test", true);
|
||||
expect(roommanager.rooms).toHaveLength(1);
|
||||
try {
|
||||
await roommanager.createRoom("test", true);
|
||||
}
|
||||
catch (err) {
|
||||
expect(err.name).toEqual("RoomNameTakenException");
|
||||
}
|
||||
expect(roommanager.rooms).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Room manager: Undoable Events', () => {
|
||||
beforeEach(async () => {
|
||||
roommanager.rooms = [];
|
||||
await Room.destroy({ where: {} });
|
||||
});
|
||||
|
||||
it('should revert seek event', async () => {
|
||||
await roommanager.createRoom("test", true);
|
||||
let testRoom = roommanager.rooms[0];
|
||||
testRoom.currentSource = new Video({
|
||||
service: "fakeservice",
|
||||
id: "abc123",
|
||||
title: "test video",
|
||||
length: 30,
|
||||
});
|
||||
testRoom.playbackPosition = 20;
|
||||
|
||||
testRoom.undoEvent({
|
||||
eventType: "seek",
|
||||
parameters: {
|
||||
position: 20,
|
||||
previousPosition: 10,
|
||||
},
|
||||
});
|
||||
|
||||
expect(testRoom.playbackPosition).toEqual(10);
|
||||
});
|
||||
|
||||
it('should revert skip event with no videos in the queue and no video playing', async () => {
|
||||
await roommanager.createRoom("test", true);
|
||||
let testRoom = roommanager.rooms[0];
|
||||
|
||||
testRoom.undoEvent({
|
||||
eventType: "skip",
|
||||
parameters: {
|
||||
video: new Video({
|
||||
service: "fakeservice",
|
||||
id: "abc123",
|
||||
title: "test video",
|
||||
length: 30,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(testRoom.currentSource).toEqual(new Video({
|
||||
service: "fakeservice",
|
||||
id: "abc123",
|
||||
title: "test video",
|
||||
length: 30,
|
||||
}));
|
||||
expect(testRoom.playbackPosition).toEqual(0);
|
||||
});
|
||||
|
||||
it('should revert skip event with no videos in the queue and with a video playing', async () => {
|
||||
await roommanager.createRoom("test", true);
|
||||
let testRoom = roommanager.rooms[0];
|
||||
testRoom.currentSource = new Video({
|
||||
service: "fakeservice",
|
||||
id: "abc123",
|
||||
title: "test video",
|
||||
length: 30,
|
||||
});
|
||||
testRoom.playbackPosition = 10;
|
||||
|
||||
testRoom.undoEvent({
|
||||
eventType: "skip",
|
||||
parameters: {
|
||||
video: new Video({
|
||||
service: "fakeservice",
|
||||
id: "skipped",
|
||||
title: "skipped video",
|
||||
length: 30,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(testRoom.currentSource).toEqual(new Video({
|
||||
service: "fakeservice",
|
||||
id: "skipped",
|
||||
title: "skipped video",
|
||||
length: 30,
|
||||
}));
|
||||
expect(testRoom.queue[0]).toEqual(new Video({
|
||||
service: "fakeservice",
|
||||
id: "abc123",
|
||||
title: "test video",
|
||||
length: 30,
|
||||
}));
|
||||
expect(testRoom.playbackPosition).toEqual(0);
|
||||
});
|
||||
|
||||
it('should revert skip event with one video in the queue and with a video playing', async () => {
|
||||
await roommanager.createRoom("test", true);
|
||||
let testRoom = roommanager.rooms[0];
|
||||
testRoom.currentSource = new Video({
|
||||
service: "fakeservice",
|
||||
id: "abc123",
|
||||
title: "test video",
|
||||
length: 30,
|
||||
});
|
||||
testRoom.playbackPosition = 10;
|
||||
testRoom.queue = [
|
||||
new Video({
|
||||
service: "fakeservice",
|
||||
id: "abc456",
|
||||
title: "test video 2",
|
||||
length: 30,
|
||||
}),
|
||||
];
|
||||
|
||||
testRoom.undoEvent({
|
||||
eventType: "skip",
|
||||
parameters: {
|
||||
video: new Video({
|
||||
service: "fakeservice",
|
||||
id: "skipped",
|
||||
title: "skipped video",
|
||||
length: 30,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(testRoom.currentSource).toEqual(new Video({
|
||||
service: "fakeservice",
|
||||
id: "skipped",
|
||||
title: "skipped video",
|
||||
length: 30,
|
||||
}));
|
||||
expect(testRoom.queue).toHaveLength(2);
|
||||
expect(testRoom.queue[0]).toEqual(new Video({
|
||||
service: "fakeservice",
|
||||
id: "abc123",
|
||||
title: "test video",
|
||||
length: 30,
|
||||
}));
|
||||
expect(testRoom.playbackPosition).toEqual(0);
|
||||
});
|
||||
|
||||
it('should revert removeFromQueue event with videos in the queue and with a video playing', async () => {
|
||||
await roommanager.createRoom("test", true);
|
||||
let testRoom = roommanager.rooms[0];
|
||||
testRoom.currentSource = new Video({
|
||||
service: "fakeservice",
|
||||
id: "abc123",
|
||||
title: "test video",
|
||||
length: 30,
|
||||
});
|
||||
testRoom.playbackPosition = 10;
|
||||
testRoom.queue = [
|
||||
new Video({
|
||||
service: "fakeservice",
|
||||
id: "abc456",
|
||||
title: "test video 2",
|
||||
length: 30,
|
||||
}),
|
||||
new Video({
|
||||
service: "fakeservice",
|
||||
id: "abc456",
|
||||
title: "test video 2",
|
||||
length: 30,
|
||||
}),
|
||||
new Video({
|
||||
service: "fakeservice",
|
||||
id: "abc456",
|
||||
title: "test video 2",
|
||||
length: 30,
|
||||
}),
|
||||
new Video({
|
||||
service: "fakeservice",
|
||||
id: "abc456",
|
||||
title: "test video 2",
|
||||
length: 30,
|
||||
}),
|
||||
new Video({
|
||||
service: "fakeservice",
|
||||
id: "abc456",
|
||||
title: "test video 2",
|
||||
length: 30,
|
||||
}),
|
||||
new Video({
|
||||
service: "fakeservice",
|
||||
id: "abc456",
|
||||
title: "test video 2",
|
||||
length: 30,
|
||||
}),
|
||||
];
|
||||
|
||||
testRoom.undoEvent({
|
||||
eventType: "removeFromQueue",
|
||||
parameters: {
|
||||
video: new Video({
|
||||
service: "fakeservice",
|
||||
id: "removed",
|
||||
title: "removed video",
|
||||
length: 30,
|
||||
}),
|
||||
queueIdx: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(testRoom.currentSource).toEqual(new Video({
|
||||
service: "fakeservice",
|
||||
id: "abc123",
|
||||
title: "test video",
|
||||
length: 30,
|
||||
}));
|
||||
expect(testRoom.queue).toHaveLength(7);
|
||||
expect(testRoom.queue[2]).toEqual(new Video({
|
||||
service: "fakeservice",
|
||||
id: "removed",
|
||||
title: "removed video",
|
||||
length: 30,
|
||||
}));
|
||||
expect(testRoom.queue[3]).not.toBeInstanceOf(Array);
|
||||
expect(testRoom.queue[6]).toEqual(new Video({
|
||||
service: "fakeservice",
|
||||
id: "abc456",
|
||||
title: "test video 2",
|
||||
length: 30,
|
||||
}));
|
||||
expect(testRoom.playbackPosition).toEqual(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
const _ = require("lodash");
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { CachedVideo } = require("../../../models");
|
||||
const storage = require("../../../storage");
|
||||
const { Room } = require("../../../models");
|
||||
|
||||
const configPath = path.resolve(process.cwd(), `env/${process.env.NODE_ENV}.env`);
|
||||
if (!fs.existsSync(configPath)) {
|
||||
console.error("No config found! Things will break!", configPath);
|
||||
}
|
||||
require('dotenv').config({ path: configPath });
|
||||
|
||||
describe('Storage: Room Spec', () => {
|
||||
beforeEach(async () => {
|
||||
await Room.destroy({ where: {} });
|
||||
}),
|
||||
|
||||
it('should return room object without extra properties', async done => {
|
||||
await storage.saveRoom({ name: "example", title: "Example Room", description: "This is an example room.", visibility: "public" });
|
||||
|
||||
storage.getRoomByName("example").then(room => {
|
||||
expect(room).not.toBeNull();
|
||||
expect(room).toBeDefined();
|
||||
expect(typeof room).toEqual("object");
|
||||
expect(room).not.toBeInstanceOf(Room);
|
||||
expect(room.id).toBeUndefined();
|
||||
expect(room.createdAt).toBeUndefined();
|
||||
expect(room.updatedAt).toBeUndefined();
|
||||
expect(room.name).toBeDefined();
|
||||
expect(room.name).toEqual("example");
|
||||
expect(room.title).toBeDefined();
|
||||
expect(room.title).toEqual("Example Room");
|
||||
expect(room.description).toBeDefined();
|
||||
expect(room.description).toEqual("This is an example room.");
|
||||
expect(room.visibility).toEqual("public");
|
||||
done();
|
||||
}).catch(err => {
|
||||
done.fail(err);
|
||||
});
|
||||
});
|
||||
|
||||
it('should create room in database', async done => {
|
||||
await expect(Room.findOne({ where: { name: "example" }})).resolves.toBeNull();
|
||||
|
||||
await expect(storage.saveRoom({ name: "example", title: "Example Room", description: "This is an example room.", visibility: "public" })).resolves.toBe(true);
|
||||
|
||||
Room.findOne({ where: { name: "example" }}).then(room => {
|
||||
expect(room).toBeInstanceOf(Room);
|
||||
expect(room.id).toBeDefined();
|
||||
expect(room.name).toBeDefined();
|
||||
expect(room.name).toEqual("example");
|
||||
expect(room.title).toBeDefined();
|
||||
expect(room.title).toEqual("Example Room");
|
||||
expect(room.description).toBeDefined();
|
||||
expect(room.description).toEqual("This is an example room.");
|
||||
expect(room.visibility).toEqual("public");
|
||||
done();
|
||||
}).catch(err => {
|
||||
done.fail(err);
|
||||
});
|
||||
});
|
||||
|
||||
it('should update the matching room in the database with the provided properties', async done => {
|
||||
await expect(Room.findOne({ where: { name: "example" }})).resolves.toBeNull();
|
||||
await expect(storage.saveRoom({ name: "example" })).resolves.toBe(true);
|
||||
|
||||
await expect(storage.updateRoom({ name: "example", title: "Example Room", description: "This is an example room.", visibility: "unlisted" })).resolves.toBe(true);
|
||||
|
||||
Room.findOne({ where: { name: "example" }}).then(room => {
|
||||
expect(room).toBeInstanceOf(Room);
|
||||
expect(room.id).toBeDefined();
|
||||
expect(room.name).toBeDefined();
|
||||
expect(room.name).toEqual("example");
|
||||
expect(room.title).toBeDefined();
|
||||
expect(room.title).toEqual("Example Room");
|
||||
expect(room.description).toBeDefined();
|
||||
expect(room.description).toEqual("This is an example room.");
|
||||
expect(room.visibility).toEqual("unlisted");
|
||||
done();
|
||||
}).catch(err => {
|
||||
done.fail(err);
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail to update if provided properties does not include name', () => {
|
||||
return expect(storage.updateRoom({ title: "Example Room", description: "This is an example room.", visibility: "unlisted" })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should return true if room name is taken', async () => {
|
||||
await expect(Room.findOne({ where: { name: "example" }})).resolves.toBeNull();
|
||||
await expect(storage.isRoomNameTaken("example")).resolves.toBe(false);
|
||||
await expect(storage.saveRoom({ name: "example" })).resolves.toBe(true);
|
||||
await expect(storage.isRoomNameTaken("example")).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Storage: CachedVideos Spec', () => {
|
||||
afterEach(async () => {
|
||||
await CachedVideo.destroy({ where: {} });
|
||||
});
|
||||
|
||||
it('should create or update cached video without failing', async () => {
|
||||
let video = {
|
||||
service: "youtube",
|
||||
id: "-29I-VbvPLQ",
|
||||
title: "tmp181mfK",
|
||||
description: "tmp181mfK",
|
||||
thumbnail: "https://i.ytimg.com/vi/-29I-VbvPLQ/mqdefault.jpg",
|
||||
length: 10,
|
||||
};
|
||||
expect(await storage.updateVideoInfo(video)).toBe(true);
|
||||
});
|
||||
|
||||
it('should fail validation, no null allowed for service', async () => {
|
||||
let video = {
|
||||
service: null,
|
||||
id: "-29I-VbvPLQ",
|
||||
title: "tmp181mfK",
|
||||
description: "tmp181mfK",
|
||||
thumbnail: "https://i.ytimg.com/vi/-29I-VbvPLQ/mqdefault.jpg",
|
||||
length: 10,
|
||||
};
|
||||
expect(await storage.updateVideoInfo(video, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('should fail validation, no null allowed for serviceId', async () => {
|
||||
let video = {
|
||||
service: "youtube",
|
||||
id: null,
|
||||
title: "tmp181mfK",
|
||||
description: "tmp181mfK",
|
||||
thumbnail: "https://i.ytimg.com/vi/-29I-VbvPLQ/mqdefault.jpg",
|
||||
length: 10,
|
||||
};
|
||||
expect(await storage.updateVideoInfo(video, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return the attributes that a video object should have', () => {
|
||||
let attributes = storage.getVideoInfoFields();
|
||||
expect(attributes.length).toBeGreaterThan(0);
|
||||
expect(attributes).not.toContain("id");
|
||||
expect(attributes).not.toContain("serviceId");
|
||||
expect(attributes).not.toContain("createdAt");
|
||||
expect(attributes).not.toContain("updatedAt");
|
||||
|
||||
attributes = storage.getVideoInfoFields("youtube");
|
||||
expect(attributes).not.toContain("mime");
|
||||
attributes = storage.getVideoInfoFields("googledrive");
|
||||
expect(attributes).not.toContain("description");
|
||||
});
|
||||
|
||||
it('should create or update multiple videos without failing', async () => {
|
||||
let videos = [
|
||||
{
|
||||
service: "fakeservice",
|
||||
id: "abc123",
|
||||
title: "test video 1",
|
||||
},
|
||||
{
|
||||
service: "fakeservice",
|
||||
id: "abc456",
|
||||
title: "test video 2",
|
||||
},
|
||||
{
|
||||
service: "fakeservice",
|
||||
id: "abc789",
|
||||
title: "test video 3",
|
||||
},
|
||||
{
|
||||
service: "fakeservice",
|
||||
id: "def123",
|
||||
title: "test video 4",
|
||||
},
|
||||
{
|
||||
service: "fakeservice",
|
||||
id: "def456",
|
||||
title: "test video 5",
|
||||
},
|
||||
];
|
||||
expect(await storage.updateManyVideoInfo(videos)).toBe(true);
|
||||
});
|
||||
|
||||
it('should get multiple videos without failing', async () => {
|
||||
let videos = [
|
||||
{
|
||||
service: "fakeservice",
|
||||
id: "abc123",
|
||||
title: "test video 1",
|
||||
},
|
||||
{
|
||||
service: "fakeservice",
|
||||
id: "abc456",
|
||||
title: "test video 2",
|
||||
},
|
||||
{
|
||||
service: "fakeservice",
|
||||
id: "abc789",
|
||||
title: "test video 3",
|
||||
},
|
||||
{
|
||||
service: "fakeservice",
|
||||
id: "def123",
|
||||
title: "test video 4",
|
||||
},
|
||||
{
|
||||
service: "fakeservice",
|
||||
id: "def456",
|
||||
title: "test video 5",
|
||||
},
|
||||
];
|
||||
await CachedVideo.bulkCreate(_.cloneDeep(videos).map(video => {
|
||||
video.serviceId = video.id;
|
||||
delete video.id;
|
||||
return video;
|
||||
}));
|
||||
expect(await storage.getManyVideoInfo(videos)).toEqual(videos);
|
||||
});
|
||||
|
||||
it('should return the same number of videos as requested even when some are not in the database', async () => {
|
||||
let videos = [
|
||||
{
|
||||
service: "fakeservice",
|
||||
id: "abc123",
|
||||
title: "test video 1",
|
||||
},
|
||||
{
|
||||
service: "fakeservice",
|
||||
id: "abc456",
|
||||
title: "test video 2",
|
||||
},
|
||||
{
|
||||
service: "fakeservice",
|
||||
id: "abc789",
|
||||
title: "test video 3",
|
||||
},
|
||||
{
|
||||
service: "fakeservice",
|
||||
id: "def123",
|
||||
},
|
||||
{
|
||||
service: "fakeservice",
|
||||
id: "def456",
|
||||
},
|
||||
];
|
||||
await CachedVideo.bulkCreate(_.cloneDeep(videos).splice(0, 3).map(video => {
|
||||
video.serviceId = video.id;
|
||||
delete video.id;
|
||||
return video;
|
||||
}));
|
||||
expect(await storage.getManyVideoInfo(videos)).toEqual(videos);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Storage: CachedVideos: bulk inserts/updates', () => {
|
||||
beforeEach(async () => {
|
||||
await CachedVideo.destroy({ where: {} });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await CachedVideo.destroy({ where: {} });
|
||||
});
|
||||
|
||||
it('should create 2 entries and update 3 entries', async done => {
|
||||
// setup
|
||||
let existingVideos = [
|
||||
{
|
||||
service: "fakeservice",
|
||||
serviceId: "abc123",
|
||||
title: "existing video 1",
|
||||
},
|
||||
{
|
||||
service: "fakeservice",
|
||||
serviceId: "abc456",
|
||||
title: "existing video 2",
|
||||
},
|
||||
{
|
||||
service: "fakeservice",
|
||||
serviceId: "abc789",
|
||||
title: "existing video 3",
|
||||
},
|
||||
];
|
||||
for (let video of existingVideos) {
|
||||
try {
|
||||
await CachedVideo.create(video);
|
||||
}
|
||||
catch (err) {
|
||||
done.fail(err);
|
||||
}
|
||||
}
|
||||
|
||||
// test
|
||||
let videos = [
|
||||
{
|
||||
service: "fakeservice",
|
||||
id: "abc123",
|
||||
title: "test video 1",
|
||||
},
|
||||
{
|
||||
service: "fakeservice",
|
||||
id: "abc456",
|
||||
title: "test video 2",
|
||||
},
|
||||
{
|
||||
service: "fakeservice",
|
||||
id: "abc789",
|
||||
title: "test video 3",
|
||||
},
|
||||
{
|
||||
service: "fakeservice",
|
||||
id: "def123",
|
||||
title: "test video 4",
|
||||
},
|
||||
{
|
||||
service: "fakeservice",
|
||||
id: "def456",
|
||||
title: "test video 5",
|
||||
},
|
||||
];
|
||||
expect(storage.updateManyVideoInfo(videos)).resolves.toBe(true);
|
||||
|
||||
expect(storage.getVideoInfo("fakeservice", "abc123")).resolves.toEqual(videos[0]);
|
||||
expect(storage.getVideoInfo("fakeservice", "abc456")).resolves.toEqual(videos[1]);
|
||||
expect(storage.getVideoInfo("fakeservice", "abc789")).resolves.toEqual(videos[2]);
|
||||
expect(storage.getVideoInfo("fakeservice", "def123")).resolves.toEqual(videos[3]);
|
||||
expect(storage.getVideoInfo("fakeservice", "def456")).resolves.toEqual(videos[4]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
const usermanager = require('../../../usermanager.js');
|
||||
|
||||
describe('Usermanager spec', () => {
|
||||
it("should check passwords correctly", () => {
|
||||
expect(usermanager.isPasswordValid("aaaa1234")).toBe(true);
|
||||
expect(usermanager.isPasswordValid("AAAA1111")).toBe(true);
|
||||
expect(usermanager.isPasswordValid("AAAA$$$$")).toBe(true);
|
||||
expect(usermanager.isPasswordValid("1111$$$$")).toBe(true);
|
||||
expect(usermanager.isPasswordValid("aaaa$$$$")).toBe(true);
|
||||
expect(usermanager.isPasswordValid("aaaaAAAA")).toBe(true);
|
||||
|
||||
expect(usermanager.isPasswordValid("aaa$$$")).toBe(false);
|
||||
expect(usermanager.isPasswordValid("")).toBe(false);
|
||||
expect(usermanager.isPasswordValid("aaaaaaaaaaaaaa")).toBe(false);
|
||||
expect(usermanager.isPasswordValid("AAAAAAAAAAAAAA")).toBe(false);
|
||||
expect(usermanager.isPasswordValid("$$$$$$$$$$$$$$")).toBe(false);
|
||||
expect(usermanager.isPasswordValid("11111111111111")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
const Video = require("../../../common/video.js");
|
||||
|
||||
describe('Video spec', () => {
|
||||
it('should merge videos without failing', () => {
|
||||
let a = new Video({ service: "fake", id: "123", title: "fake title" });
|
||||
let b = new Video({ service: "fake", id: "123", title: "fake title", length: 10 });
|
||||
|
||||
expect(Video.merge(a, b)).toEqual(new Video({
|
||||
service: "fake",
|
||||
id: "123",
|
||||
title: "fake title",
|
||||
length: 10,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should fail to merge videos because service does not match', () => {
|
||||
let a = new Video({ service: "fake", id: "123", title: "fake title" });
|
||||
let b = new Video({ service: "fake2", id: "123", length: 10 });
|
||||
|
||||
expect(() => {
|
||||
Video.merge(a, b);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should fail to merge videos because id does not match', () => {
|
||||
let a = new Video({ service: "fake", id: "123", title: "fake title" });
|
||||
let b = new Video({ service: "fake", id: "456", length: 10 });
|
||||
|
||||
expect(() => {
|
||||
Video.merge(a, b);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should favor video B information', () => {
|
||||
let a = new Video({ service: "fake", id: "123", length: 10 });
|
||||
let b = new Video({ service: "fake", id: "123", length: 14 });
|
||||
|
||||
expect(Video.merge(a, b)).toEqual(new Video({
|
||||
service: "fake",
|
||||
id: "123",
|
||||
length: 14,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should not override with null values', () => {
|
||||
let a = new Video({ service: "fake", id: "123", title: "fake" });
|
||||
let b = new Video({ service: "fake", id: "123" });
|
||||
|
||||
expect(Video.merge(a, b)).toEqual(new Video({
|
||||
service: "fake",
|
||||
id: "123",
|
||||
title: "fake",
|
||||
}));
|
||||
});
|
||||
});
|
||||
437
opentogethertube/opentogethertube/usermanager.js
Normal file
437
opentogethertube/opentogethertube/usermanager.js
Normal file
@@ -0,0 +1,437 @@
|
||||
const { getLogger } = require('./logger.js');
|
||||
const _ = require("lodash");
|
||||
const securePassword = require('secure-password');
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const crypto = require('crypto');
|
||||
const rateLimit = require("express-rate-limit");
|
||||
const RateLimitStore = require('rate-limit-redis');
|
||||
const { User } = require("./models");
|
||||
const roommanager = require("./roommanager");
|
||||
const { redisClient } = require('./redisclient.js');
|
||||
|
||||
const pwd = securePassword();
|
||||
const log = getLogger("usermanager");
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
if (req.user) {
|
||||
let user = {
|
||||
username: req.user.username,
|
||||
loggedIn: true,
|
||||
};
|
||||
res.json(user);
|
||||
}
|
||||
else {
|
||||
res.json({
|
||||
username: req.session.username,
|
||||
loggedIn: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/", async (req, res) => {
|
||||
if (!req.body.username) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: "Missing argument (username)",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
let oldUsername;
|
||||
if (req.user) {
|
||||
oldUsername = req.user.username;
|
||||
req.user.username = req.body.username;
|
||||
try {
|
||||
await req.user.save();
|
||||
}
|
||||
catch (err) {
|
||||
if (err.name === "SequelizeUniqueConstraintError") {
|
||||
await req.user.reload();
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
name: "UsernameTaken",
|
||||
message: "Somebody else is already using that username.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
else {
|
||||
log.error(`Unknown error occurred when saving user to database ${err.message}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: "An unknown error occurred.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
else {
|
||||
oldUsername = req.session.username;
|
||||
req.session.username = req.body.username;
|
||||
req.session.save();
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
log.info(`${oldUsername} changed username to ${req.body.username}`);
|
||||
usermanager.onUserModified(req.session);
|
||||
});
|
||||
|
||||
let logInLimiter = rateLimit({ store: new RateLimitStore({ client: redisClient, resetExpiryOnChange: true, prefix: "rl:UserRegister" }), windowMs: 10 * 1000, max: 5, message: "You are doing that too much." });
|
||||
router.post("/login", process.env.NODE_ENV === "production" ? logInLimiter : (req, res, next) => next(), (req, res, next) => {
|
||||
passport.authenticate("local", (err, user) => {
|
||||
if (err) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (user) {
|
||||
req.login(user, (err) => {
|
||||
if (err) {
|
||||
log.error("Unknown error when logging in");
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: "An unknown error occurred when logging in.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
req.session.save();
|
||||
try {
|
||||
usermanager.onUserLogIn(user, req.session);
|
||||
}
|
||||
catch (err) {
|
||||
log.error(`An unknown error occurred when running onUserLogIn: ${err} ${err.message}`);
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
user: _.pick(user, [
|
||||
"email",
|
||||
"username",
|
||||
]),
|
||||
});
|
||||
});
|
||||
}
|
||||
else {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: "Either the email or password was not provided.",
|
||||
},
|
||||
});
|
||||
}
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
router.post("/logout", (req, res) => {
|
||||
if (req.user) {
|
||||
let user = req.user;
|
||||
req.logout();
|
||||
usermanager.onUserLogOut(user, req.session);
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
else {
|
||||
res.json({
|
||||
success: false,
|
||||
error: {
|
||||
message: "Not logged in.",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let registerLimiter = rateLimit({ store: new RateLimitStore({ client: redisClient, resetExpiryOnChange: true, prefix: "rl:UserRegister" }), windowMs: 60 * 60 * 1000, max: 4, message: "You are doing that too much." });
|
||||
router.post("/register", process.env.NODE_ENV === "production" ? registerLimiter : (req, res, next) => next(), (req, res) => {
|
||||
usermanager.registerUser(req.body).then(result => {
|
||||
req.login(result, () => {
|
||||
try {
|
||||
usermanager.onUserLogIn(result, req.session);
|
||||
}
|
||||
catch (err) {
|
||||
log.error(`An unknown error occurred when running onUserLogIn: ${err} ${err.message}`);
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
user: _.pick(result, [
|
||||
"email",
|
||||
"username",
|
||||
]),
|
||||
});
|
||||
});
|
||||
}).catch(err => {
|
||||
log.error(`Unable to register user ${err} ${err.message}`);
|
||||
if (err.name === "SequelizeUniqueConstraintError") {
|
||||
let fields = err.fields.join(", ");
|
||||
fields = fields.charAt(0).toUpperCase() + fields.slice(1);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
name: "AlreadyInUse",
|
||||
fields: err.fields,
|
||||
message: `${fields} ${err.fields.length > 1 ? "are" : "is"} already in use.`,
|
||||
},
|
||||
});
|
||||
}
|
||||
else if (err.name === "SequelizeValidationError" || err.name === "BadPasswordError") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
name: "ValidationError",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
name: "Unknown",
|
||||
message: "An unknown error occurred. Try again later.",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
class BadPasswordError extends Error {
|
||||
constructor() {
|
||||
super("Password does not meet minimum requirements. Must be at least 8 characters long, and contain 2 of the following categories of characters: lowercase letters, uppercase letters, numbers, special characters.");
|
||||
this.name = "BadPasswordError";
|
||||
}
|
||||
}
|
||||
|
||||
let usermanager = {
|
||||
router,
|
||||
|
||||
/**
|
||||
* Callback used by passport LocalStrategy to authenticate Users.
|
||||
*/
|
||||
async authCallback(email, password, done) {
|
||||
// HACK: required to use usermanager inside passport callbacks that are inside usermanager. This is because `this` becomes `global` inside these callbacks for some fucking reason
|
||||
let usermanager = require("./usermanager.js");
|
||||
let user;
|
||||
try {
|
||||
user = await usermanager.getUser({ email });
|
||||
}
|
||||
catch (err) {
|
||||
if (err.message === "User not found") {
|
||||
done(new Error("Email or password is incorrect."));
|
||||
}
|
||||
else {
|
||||
log.error(`Auth callback failed: ${err}`);
|
||||
done(new Error("An unknown error occurred. This is a bug."));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line array-bracket-newline
|
||||
let result = await pwd.verify(Buffer.concat([user.salt, Buffer.from(password)]), Buffer.from(user.hash));
|
||||
switch (result) {
|
||||
case securePassword.INVALID_UNRECOGNIZED_HASH:
|
||||
log.error(`${email}: Unrecognized hash. I don't think this should ever happen.`);
|
||||
done(null, false);
|
||||
break;
|
||||
case securePassword.INVALID:
|
||||
log.debug(`${email}: Hash is invalid`);
|
||||
done(new Error("Email or password is incorrect."), false);
|
||||
break;
|
||||
case securePassword.VALID_NEEDS_REHASH:
|
||||
log.debug(`${email}: Hash is valid, needs rehash`);
|
||||
// eslint-disable-next-line array-bracket-newline
|
||||
user.hash = await pwd.hash(Buffer.concat([user.salt, Buffer.from(password)]));
|
||||
await user.save();
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
case securePassword.VALID:
|
||||
log.debug(`${email}: Hash is valid`);
|
||||
done(null, user);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Converts a User into their user id.
|
||||
* Used for persistent session storage.
|
||||
*/
|
||||
serializeUser(user, done) {
|
||||
done(null, user.id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Converts a user id into a User.
|
||||
* Used for persistent session storage.
|
||||
*/
|
||||
async deserializeUser(id, done) {
|
||||
// HACK: required to use usermanager inside passport callbacks that are inside usermanager. This is because `this` becomes `global` inside these callbacks for some fucking reason
|
||||
let usermanager = require("./usermanager.js");
|
||||
try {
|
||||
let user = await usermanager.getUser({ id });
|
||||
done(null, user);
|
||||
}
|
||||
catch (err) {
|
||||
log.error(`Unable to deserialize user id=${id} ${err}`);
|
||||
done(err, false);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Middleware to handle errors in serialize and deserialize callbacks
|
||||
*/
|
||||
passportErrorHandler(err, req, res, next) {
|
||||
if (err) {
|
||||
log.error(`Error in middleware ${err}, logging user out.`);
|
||||
req.logout();
|
||||
req.session.save();
|
||||
next();
|
||||
}
|
||||
else {
|
||||
next();
|
||||
}
|
||||
},
|
||||
|
||||
async registerUser({ email, username, password }) {
|
||||
if (!this.isPasswordValid(password)) {
|
||||
return Promise.reject(new BadPasswordError());
|
||||
}
|
||||
|
||||
let salt = crypto.randomBytes(128);
|
||||
// eslint-disable-next-line array-bracket-newline
|
||||
let hash = await pwd.hash(Buffer.concat([salt, Buffer.from(password)]));
|
||||
|
||||
return User.create({
|
||||
email,
|
||||
username,
|
||||
salt,
|
||||
hash,
|
||||
}).then(user => {
|
||||
return user;
|
||||
}).catch(err => {
|
||||
log.error(`Failed to create new user in the database: ${err} ${err.message}`);
|
||||
throw err;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets a User based on either their email or id.
|
||||
* @param {*} param0
|
||||
* @returns Promise<User>
|
||||
*/
|
||||
async getUser({ email, id }) {
|
||||
if (!email && !id) {
|
||||
log.error("Invalid parameters to find user");
|
||||
throw new Error("Invalid parameters to find user");
|
||||
}
|
||||
let where = {};
|
||||
if (email) {
|
||||
where = { email };
|
||||
}
|
||||
else if (id) {
|
||||
where = { id };
|
||||
}
|
||||
return User.findOne({ where }).then(user => {
|
||||
if (!user) {
|
||||
log.error("User not found");
|
||||
throw new Error("User not found");
|
||||
}
|
||||
return user;
|
||||
});
|
||||
},
|
||||
|
||||
isPasswordValid(password) {
|
||||
if (process.env.NODE_ENV === "development" && password === "1") {
|
||||
return true;
|
||||
}
|
||||
let conditions = [
|
||||
!!/^(?=.*[a-z])/.exec(password),
|
||||
!!/^(?=.*[A-Z])/.exec(password),
|
||||
!!/^(?=.*[0-9])/.exec(password),
|
||||
!!/^(?=.*[!@#$%^&*])/.exec(password),
|
||||
];
|
||||
return conditions.reduce((acc, curr) => acc + curr) >= 2 && !!/^(?=.{8,})/.exec(password);
|
||||
},
|
||||
|
||||
onUserLogIn(user, session) {
|
||||
log.info(`${user.username} (id: ${user.id}) has logged in.`);
|
||||
for (let room of roommanager.rooms) {
|
||||
for (let client of room.clients) {
|
||||
if (client.session.id === session.id) {
|
||||
client.user = user;
|
||||
room._dirtyProps.push("users");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onUserLogOut(user, session) {
|
||||
log.info(`${user.username} (id: ${user.id}) has logged out.`);
|
||||
for (let room of roommanager.rooms) {
|
||||
for (let client of room.clients) {
|
||||
if (client.session.id === session.id) {
|
||||
client.user = null;
|
||||
room._dirtyProps.push("users");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onUserModified(session) {
|
||||
for (let room of roommanager.rooms) {
|
||||
for (let client of room.clients) {
|
||||
if (client.session.id === session.id) {
|
||||
if (client.isLoggedIn) {
|
||||
client.user.reload();
|
||||
}
|
||||
room._dirtyProps.push("users");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
usermanager.registerUser({
|
||||
email: "forced@localhost",
|
||||
username: "forced test user",
|
||||
password: "test1234",
|
||||
}).catch(err => {
|
||||
log.warn(`failed to register test user ${err.message}`);
|
||||
});
|
||||
|
||||
usermanager.registerUser({
|
||||
email: "test@localhost",
|
||||
username: "test user",
|
||||
password: "test1234",
|
||||
}).catch(err => {
|
||||
log.warn(`failed to register test user ${err.message}`);
|
||||
});
|
||||
|
||||
router.get("/test/forceLogin", async (req, res) => {
|
||||
req.login(await usermanager.getUser({ email: "forced@localhost" }), (err) => {
|
||||
res.json({
|
||||
success: !!err,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = usermanager;
|
||||
28
opentogethertube/opentogethertube/vue.config.js
Normal file
28
opentogethertube/opentogethertube/vue.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.resolve(process.cwd(), `env/${process.env.NODE_ENV}.env`) });
|
||||
|
||||
module.exports = {
|
||||
devServer: {
|
||||
proxy: {
|
||||
"^/api": {
|
||||
target: "http://localhost:3000",
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
transpileDependencies: ["vuetify"],
|
||||
pluginOptions: {
|
||||
webpackBundleAnalyzer: {
|
||||
openAnalyzer: false,
|
||||
},
|
||||
},
|
||||
configureWebpack: {
|
||||
plugins: [],
|
||||
},
|
||||
chainWebpack: (config) => {
|
||||
config.plugin('define').tap(definitions => {
|
||||
definitions[0]['process.env']['GOOGLE_DRIVE_API_KEY'] = JSON.stringify(process.env.GOOGLE_DRIVE_API_KEY);
|
||||
return definitions;
|
||||
});
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user