Compare commits

..

83 Commits

Author SHA1 Message Date
Isaac Abadi
d6a43c76a4 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into concurrent-streams-and-player-refactor 2021-07-20 21:36:48 -06:00
Isaac Abadi
407333a314 Updated dev default.json 2021-07-20 21:34:33 -06:00
Isaac Abadi
0fb01469c4 Fixed issue in player component where errors were displayed in the console due to vars being changed after Angular detection
Fixed spooky issue where recent videos' navigateToFile stopped working
2021-07-20 21:29:49 -06:00
Isaac Abadi
d10eb4f2eb Fixed issue where old DB backup didn't work
Massive insertions to local DB are now split up into 30k chunks
2021-07-20 20:55:47 -06:00
Isaac Abadi
148ed9aa65 Added support for MongoDB indexing to increase query performance
Fixed db backup functionality
2021-07-18 23:18:46 -06:00
Tzahi12345
00b591a9a4 Merge pull request #392 from itsthejoker/patch-1
Update default.json to use a longer subscription interval
2021-07-18 18:15:44 -06:00
Tzahi12345
06d9793d1a Merge pull request #389 from Tzahi12345/dependabot/npm_and_yarn/backend/glob-parent-5.1.2
Bump glob-parent from 5.1.1 to 5.1.2 in /backend
2021-07-18 18:13:49 -06:00
Isaac Abadi
0a2529330d Fixes issue in some browsers where the audio player disappears 2021-07-18 18:10:33 -06:00
Tzahi12345
19317dbddb Merge pull request #383 from ErwanGit/master
Update API docs links in settings
2021-07-18 17:46:37 -06:00
Isaac Abadi
3b74a2b5da Updated docker-compose to include mongodb instance 2021-07-18 17:41:46 -06:00
Isaac Abadi
a810628f15 Fixed DB migration for tables with no docs 2021-07-17 20:00:49 -06:00
Isaac Abadi
a7d349a71a Updated ES to 2019/2020 and local default.json is ignored for reloads when in dev mode 2021-07-17 19:42:32 -06:00
Isaac Abadi
f8c4653ae0 Added migration from old to new DB system 2021-07-16 00:10:35 -06:00
Isaac Abadi
bb6503e86d Changed DB structure again
Added support for MongoDB

Added tests relating to new DB system

Category rules are now case insensitive

Fixed playlist modification change state
2021-07-16 00:05:08 -06:00
Joe Kaufeld
dbbfc041a4 Update default.json to use a longer update period
See https://github.com/Tzahi12345/YoutubeDL-Material/issues/385 for context; setting this to a daily value instead of every five minutes means that updates still come in but it doesn't completely trample all other network traffic, especially if you have a lot of subscriptions.
2021-06-23 10:42:12 -04:00
dependabot[bot]
342dafd52a Bump glob-parent from 5.1.1 to 5.1.2 in /backend
Bumps [glob-parent](https://github.com/gulpjs/glob-parent) from 5.1.1 to 5.1.2.
- [Release notes](https://github.com/gulpjs/glob-parent/releases)
- [Changelog](https://github.com/gulpjs/glob-parent/blob/main/CHANGELOG.md)
- [Commits](https://github.com/gulpjs/glob-parent/compare/v5.1.1...v5.1.2)

---
updated-dependencies:
- dependency-name: glob-parent
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-06-11 14:32:16 +00:00
Isaac Abadi
984e990103 Fixed issue where categories could not be viewed 2021-06-08 16:32:35 -06:00
Isaac Abadi
4ea239170e If multiple videos exist in one URL, a playlist will be auto generated
Removed tomp3 and tomp4 routes, replaced with /downloadFile

Simplified category->playlist conversion

Simplified playlist creation

Simplified file deletion

Playlist duration calculation is now done on the backend (categories uses this now too)

removeIDFromArchive moved from subscriptions->utils

Added plumbing to support type agnostic playlists
2021-05-30 00:39:00 -06:00
Isaac Abadi
e2c31319cf Migrated playlist and subscription (per video and sub-wide) video downloading functionality to new schema
Migrated modify playlist component to new schema

Moved wait function and playlist generation function(s) to utils
- added tests for zip generation
2021-05-23 03:59:38 -06:00
Erwan
b933af03e2 Update API docs links in settings 2021-05-22 14:58:48 +02:00
Isaac Abadi
419fe3c3c6 Fixed frontend security issues for several depepndencies 2021-05-16 02:58:16 -06:00
Isaac Abadi
07b48a4da1 Fixed backend security issues with several dependencies 2021-05-16 02:55:27 -06:00
Isaac Abadi
a11445b80d Added backend tests and made authentication more testable 2021-05-16 02:54:15 -06:00
Isaac Abadi
297a4a3f34 Simplified streaming and file deletion functions 2021-05-16 02:53:36 -06:00
Isaac Abadi
1d2ab0dc41 401 errors will now not cause redirects in the /player route 2021-05-12 22:56:38 -06:00
Isaac Abadi
46f8579439 Refactored player component to utilize uids instead of fileNames to improve maintainability, consistency, and reliability
Playlists now use uids instead of fileNames

Added generic getPlaylist and updatePlaylist functions
2021-05-12 22:56:16 -06:00
Isaac Abadi
b3744e616d Users can now stream videos concurrently with other users with the new concurrent stream component 2021-05-12 22:52:46 -06:00
Isaac Abadi
de154a9c3e Updated dockerfile to fix UID/GID bug related to forever.js 2021-05-12 21:57:42 -06:00
Tzahi12345
9e71b1ff12 Merge pull request #359 from benashby/helm-chart
Helm chart improvements
2021-05-12 21:48:59 -06:00
Tzahi12345
6d318234b6 Merge pull request #360 from s55ma/patch-1
Update README.md
2021-03-28 19:23:04 -04:00
Isaac Abadi
49925848ff Material Icons are now hosted locally to avoid requesting them from Google for proxied users 2021-03-28 15:51:53 -04:00
s55ma
356a807cad Update README.md
Some packages are missing for Ubuntu/Debian install, especially python. Without python package, you get the following error when trying to download from youtube:

2021-03-28T15:28:30.461Z ERROR: Error while retrieving info on video with URL https://www.youtube.com/watch?v=[some_ID] with the following message: Error: Command failed with exit code 127: /root/youtubedl-material/node_modules/youtube-dl/bin/youtube-dl --dump-json -o video/%(title)s.mp4 --write-info-json --print-json -f bestvideo+bestaudio --merge-output-format mp4 --write-thumbnail http://www.youtube.com/watch?v=[some_ID]
2021-03-28T15:28:30.461Z ERROR: /usr/bin/env: 'python': No such file or directory
2021-03-28 17:33:47 +02:00
Ben Ashby
4e07440ed2 Removed Accidental Dir 2021-03-27 16:34:14 -06:00
Ben Ashby
59c9237be5 integrated pvc's 2021-03-26 09:59:02 -06:00
Ben Ashby
4ba4710741 Added helm chart 2021-03-26 09:46:20 -06:00
Isaac Abadi
addd54fefd Switched nodemon to foreverjs to hopefully enable restarting internally and fix runtime errors 2021-03-20 16:22:59 -06:00
Isaac Abadi
aefdde5401 Fixed issue (hopefully) where nodemon is not properly installed on Docker 2021-03-18 20:59:46 -06:00
Isaac Abadi
4c1f975eae Force nodemon to install during the container setup
Docker now starts through nodemon directly
2021-03-18 19:29:03 -06:00
Isaac Abadi
4c06bc750c Fixed issue where on some Docker environments the container failed to start due to the error "nodemon update check failed" 2021-03-17 19:13:52 -06:00
Isaac Abadi
4643efbae0 Added ability to restart the server from the frontend
Dockerfile/entrypoint.sh now uses nodemon enabling restarting from the UI in a container
2021-03-16 22:41:07 -06:00
Isaac Abadi
1f0153b17e Subscription videos being downloaded will get registered into the database as they are added to avoid having to wait until the subscription completes 2021-03-16 20:06:05 -06:00
Isaac Abadi
f32b394715 Added maxBuffer option to all downloads 2021-02-22 12:55:30 -07:00
Isaac Abadi
9d09eeffe3 Added maxbuffer option to subscriptions 2021-02-22 12:54:28 -07:00
Isaac Abadi
669c87dd1b Removed unecessary suffix in crop file inputs 2021-02-12 21:21:45 -07:00
Isaac Abadi
023df9c29d Fixed issue where playlists couldn't be favorited after downloading 2021-02-12 21:21:09 -07:00
Isaac Abadi
433d08e9df Added ability to crop files
Fixed bug in downloading playlists
2021-02-12 21:20:48 -07:00
Isaac Abadi
e34aa4d9d6 Adds Dutch language support 2021-01-31 19:47:14 -05:00
Isaac Abadi
3f9314a0c3 Fixed bug where categories selection logic had an out of range exception 2021-01-28 22:11:04 -05:00
Isaac Abadi
00a0ab460b Subscription's videos are now stripped from HTTP requests where they are not needed 2021-01-20 08:50:15 -05:00
Tzahi12345
b8cab673ae Merge pull request #316 from Tzahi12345/categories-playlist-fix
Categories playlist download fix
2021-01-13 16:13:22 -05:00
Isaac Abadi
6481102e01 Changes forEach loops in categorize() to regular for loops to facilitate early breaking 2021-01-13 16:12:11 -05:00
Isaac Abadi
af58854f0e Added info button to the player component 2021-01-13 12:50:18 -05:00
Isaac Abadi
d7d861ef0e Fixed typo in default custom output key for categories 2021-01-12 22:32:27 -05:00
Isaac Abadi
1d5490c0ff Allows playlists to be categorized based on the first video that matches 2021-01-12 22:08:42 -05:00
Isaac Abadi
28ee77cee0 Hotfix that allows playlists to be downloaded with categories 2021-01-12 16:42:30 -05:00
Isaac Abadi
133d848729 Fixed bug where deleting a file card wasn't possible if it was already deleted manually 2021-01-11 13:55:02 -05:00
Isaac Abadi
a78f4e99d0 Removed trivial browser log that occured at file deletion 2021-01-11 01:20:53 -05:00
Isaac Abadi
539bc5094a Fixed bug where sometimes a subscription video's thumbnail would get deleted twice and throw an error 2021-01-11 01:20:07 -05:00
Isaac Abadi
f0f2faa398 Sub's videos are removed from the post request when deleting a video as it's not needed 2021-01-11 01:19:29 -05:00
Isaac Abadi
7835185fe0 Made file card deletion much more reliable by finding out the index of the file on deletion rather than attempting to maintain a valid index 2021-01-11 01:18:58 -05:00
Isaac Abadi
95bb69f16b Fixed bug where videos would not delete in single-user mode 2021-01-10 17:14:10 -05:00
Isaac Abadi
a93aa080b3 Fixed bug where playlistd could not be made 2021-01-09 17:25:46 -05:00
Isaac Abadi
ed1375d40b Fixed bug where deleting videos while searching caused them to still show up in the UI 2021-01-09 14:07:51 -05:00
Isaac Abadi
db78e4ad5e Fixed bug where playlist downloads would fail and progress would not show (for playlist downloads) 2021-01-09 14:07:51 -05:00
Tzahi12345
6ef0082563 Merge pull request #304 from Tzahi12345/dependabot/npm_and_yarn/backend/axios-0.21.1
Bump axios from 0.21.0 to 0.21.1 in /backend
2021-01-06 09:55:01 -05:00
dependabot[bot]
b978007472 Bump axios from 0.21.0 to 0.21.1 in /backend
Bumps [axios](https://github.com/axios/axios) from 0.21.0 to 0.21.1.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v0.21.1/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.21.0...v0.21.1)

Signed-off-by: dependabot[bot] <support@github.com>
2021-01-06 10:25:29 +00:00
Isaac Abadi
c09dd7a03b Updated Chinese and Spanish translations and added Italian translations 2021-01-01 17:38:08 -05:00
Isaac Abadi
b6c09324d9 Updated error messages to make them more verbose and fixed ID3 tagging for file names 2021-01-01 17:35:14 -05:00
Tzahi12345
1a900399d8 Merge pull request #292 from diveflo/ci/releaseassetnamefix
Fix asset name in automated release creation
2020-12-29 18:13:18 -05:00
Isaac Abadi
ea959547fd Fixed bug where file indices were incorrectly assigned 2020-12-28 00:22:14 -05:00
Isaac Abadi
085849c7ee Fixed bug that prevented the menu for file cards from being opened (2) 2020-12-27 21:13:24 -05:00
Isaac Abadi
cf1dd43d36 Fixed bug that prevented the menu for file cards from being opened 2020-12-26 19:27:03 -05:00
Isaac Abadi
250f150587 Download checker now only runs if the video info was successfully retrieved 2020-12-26 18:56:01 -05:00
Isaac Abadi
dbf08e1276 Fixed bug where audio files that had a stale webm extension in the metadata file path would fail to register 2020-12-26 15:51:13 -05:00
Isaac Abadi
f74ce4b865 Fixed bug that caused the UI to fail loading after creating a user in multi-user mode 2020-12-26 15:35:13 -05:00
Florian Gabsteiger
8e4e0c7908 fix wrongly named ci step 2020-12-25 18:32:32 +01:00
Florian Gabsteiger
b0cb09309d Fix release asset name creation
The complete git ref name was used as part of the release asset filename for tagged commits.
This includes the refs/tags prefix, which fails as "/" characters can't be part of filenames.
To work around this, a step is added that extracts the pure tag name first.
2020-12-25 18:21:05 +01:00
Isaac Abadi
75c1c9e9b7 Fixed name of docker release workflow 2020-12-24 16:10:57 -05:00
Isaac Abadi
c19e0bb881 Adds manually-triggered GH workflow for release builds 2020-12-24 16:09:58 -05:00
Tzahi12345
a1af5496c7 Update README.md
Updated preview images in README
2020-12-24 03:41:38 -05:00
Isaac Abadi
3c206c31d5 Updated translations base file 2020-12-24 03:21:56 -05:00
Tzahi12345
3ffcfac28b Merge pull request #290 from Tzahi12345/updated-player
Updated player & much more (v4.2)
2020-12-24 03:13:50 -05:00
Isaac Abadi
1cc4df2829 Updated translation file to v4.2 2020-12-22 01:29:19 -05:00
84 changed files with 15960 additions and 4909 deletions

View File

@@ -69,17 +69,20 @@ jobs:
with: with:
name: youtubedl-material name: youtubedl-material
path: ${{runner.temp}}/youtubedl-material path: ${{runner.temp}}/youtubedl-material
- name: extract tag name
id: tag_name
run: echo ::set-output name=tag_name::${GITHUB_REF#refs/tags/}
- name: prepare release asset - name: prepare release asset
shell: pwsh shell: pwsh
run: Compress-Archive -Path ${{runner.temp}}/youtubedl-material -DestinationPath youtubedl-material-${{ github.ref }}.zip run: Compress-Archive -Path ${{runner.temp}}/youtubedl-material -DestinationPath youtubedl-material-${{ steps.tag_name.outputs.tag_name }}.zip
- name: upload build asset - name: upload release asset
uses: actions/upload-release-asset@v1 uses: actions/upload-release-asset@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
upload_url: ${{ steps.create_release.outputs.upload_url }} upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./youtubedl-material-${{ github.ref }}.zip asset_path: ./youtubedl-material-${{ steps.tag_name.outputs.tag_name }}.zip
asset_name: youtubedl-material-${{ github.ref }}.zip asset_name: youtubedl-material-${{ steps.tag_name.outputs.tag_name }}.zip
asset_content_type: application/zip asset_content_type: application/zip
- name: upload docker-compose asset - name: upload docker-compose asset
uses: actions/upload-release-asset@v1 uses: actions/upload-release-asset@v1

32
.github/workflows/docker-release.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: docker-release
on:
workflow_dispatch:
inputs:
tags:
description: 'Docker tags'
required: true
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: checkout code
uses: actions/checkout@v2
- name: setup platform emulator
uses: docker/setup-qemu-action@v1
- name: setup multi-arch docker build
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: build & push images
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm,linux/arm64/v8
push: true
tags: ${{ github.event.inputs.tags }}

View File

@@ -21,6 +21,9 @@ ENV UID=1000 \
GID=1000 \ GID=1000 \
USER=youtube USER=youtube
ENV NO_UPDATE_NOTIFIER=true
ENV FOREVER_ROOT=/app/.forever
RUN addgroup -S $USER -g $GID && adduser -D -S $USER -G $USER -u $UID RUN addgroup -S $USER -g $GID && adduser -D -S $USER -G $USER -u $UID
RUN apk add --no-cache \ RUN apk add --no-cache \
@@ -33,6 +36,7 @@ RUN apk add --no-cache \
WORKDIR /app WORKDIR /app
COPY --chown=$UID:$GID [ "backend/package.json", "backend/package-lock.json", "/app/" ] COPY --chown=$UID:$GID [ "backend/package.json", "backend/package-lock.json", "/app/" ]
RUN npm install forever -g
RUN npm install && chown -R $UID:$GID ./ RUN npm install && chown -R $UID:$GID ./
COPY --chown=$UID:$GID --from=frontend [ "/build/backend/public/", "/app/public/" ] COPY --chown=$UID:$GID --from=frontend [ "/build/backend/public/", "/app/public/" ]
@@ -40,4 +44,4 @@ COPY --chown=$UID:$GID [ "/backend/", "/app/" ]
EXPOSE 17442 EXPOSE 17442
ENTRYPOINT [ "/app/entrypoint.sh" ] ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD [ "node", "app.js" ] CMD [ "forever", "app.js" ]

View File

@@ -16,15 +16,11 @@ Check out the prerequisites, and go to the installation section. Easy as pie!
Here's an image of what it'll look like once you're done: Here's an image of what it'll look like once you're done:
![frontpage](https://i.imgur.com/w8iofbb.png) <img src="https://i.imgur.com/C6vFGbL.png" width="800">
With optional file management enabled (default):
![frontpage_with_files](https://i.imgur.com/FTATqBM.png)
Dark mode: Dark mode:
![dark_mode](https://i.imgur.com/r5ZtBqd.png) <img src="https://i.imgur.com/vOtvH5w.png" width="800">
### Prerequisites ### Prerequisites
@@ -33,7 +29,7 @@ NOTE: If you would like to use Docker, you can skip down to the [Docker](#Docker
Debian/Ubuntu: Debian/Ubuntu:
```bash ```bash
sudo apt-get install nodejs youtube-dl ffmpeg sudo apt-get install nodejs youtube-dl ffmpeg unzip python npm
``` ```
CentOS 7: CentOS 7:

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,8 @@
"allow_quality_select": true, "allow_quality_select": true,
"download_only_mode": false, "download_only_mode": false,
"allow_multi_download_mode": true, "allow_multi_download_mode": true,
"enable_downloads_manager": true "enable_downloads_manager": true,
"allow_playlist_categorization": true
}, },
"API": { "API": {
"use_API_key": false, "use_API_key": false,
@@ -53,6 +54,10 @@
"searchFilter": "(uid={{username}})" "searchFilter": "(uid={{username}})"
} }
}, },
"Database": {
"use_local_db": false,
"mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib"
},
"Advanced": { "Advanced": {
"default_downloader": "youtube-dl", "default_downloader": "youtube-dl",
"use_default_downloading_agent": true, "use_default_downloading_agent": true,

View File

@@ -1,12 +1,10 @@
const path = require('path'); const path = require('path');
const config_api = require('../config'); const config_api = require('../config');
const consts = require('../consts'); const consts = require('../consts');
var subscriptions_api = require('../subscriptions')
const fs = require('fs-extra'); const fs = require('fs-extra');
var jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { uuid } = require('uuidv4'); const { uuid } = require('uuidv4');
var bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
var LocalStrategy = require('passport-local').Strategy; var LocalStrategy = require('passport-local').Strategy;
var LdapStrategy = require('passport-ldapauth'); var LdapStrategy = require('passport-ldapauth');
@@ -15,16 +13,15 @@ var JwtStrategy = require('passport-jwt').Strategy,
// other required vars // other required vars
let logger = null; let logger = null;
let db = null; let db_api = null;
let users_db = null;
let SERVER_SECRET = null; let SERVER_SECRET = null;
let JWT_EXPIRATION = null; let JWT_EXPIRATION = null;
let opts = null; let opts = null;
let saltRounds = null; let saltRounds = null;
exports.initialize = function(input_db, input_users_db, input_logger) { exports.initialize = function(db_api, input_logger) {
setLogger(input_logger) setLogger(input_logger)
setDB(input_db, input_users_db); setDB(db_api);
/************************* /*************************
* Authentication module * Authentication module
@@ -34,21 +31,19 @@ exports.initialize = function(input_db, input_users_db, input_logger) {
JWT_EXPIRATION = config_api.getConfigItem('ytdl_jwt_expiration'); JWT_EXPIRATION = config_api.getConfigItem('ytdl_jwt_expiration');
SERVER_SECRET = null; SERVER_SECRET = null;
if (users_db.get('jwt_secret').value()) { if (db_api.users_db.get('jwt_secret').value()) {
SERVER_SECRET = users_db.get('jwt_secret').value(); SERVER_SECRET = db_api.users_db.get('jwt_secret').value();
} else { } else {
SERVER_SECRET = uuid(); SERVER_SECRET = uuid();
users_db.set('jwt_secret', SERVER_SECRET).write(); db_api.users_db.set('jwt_secret', SERVER_SECRET).write();
} }
opts = {} opts = {}
opts.jwtFromRequest = ExtractJwt.fromUrlQueryParameter('jwt'); opts.jwtFromRequest = ExtractJwt.fromUrlQueryParameter('jwt');
opts.secretOrKey = SERVER_SECRET; opts.secretOrKey = SERVER_SECRET;
/*opts.issuer = 'example.com';
opts.audience = 'example.com';*/
exports.passport.use(new JwtStrategy(opts, function(jwt_payload, done) { exports.passport.use(new JwtStrategy(opts, async function(jwt_payload, done) {
const user = users_db.get('users').find({uid: jwt_payload.user}).value(); const user = await db_api.getRecord('users', {uid: jwt_payload.user});
if (user) { if (user) {
return done(null, user); return done(null, user);
} else { } else {
@@ -62,9 +57,8 @@ function setLogger(input_logger) {
logger = input_logger; logger = input_logger;
} }
function setDB(input_db, input_users_db) { function setDB(input_db_api) {
db = input_db; db_api = input_db_api;
users_db = input_users_db;
} }
exports.passport = require('passport'); exports.passport = require('passport');
@@ -80,7 +74,7 @@ exports.passport.deserializeUser(function(user, done) {
/*************************************** /***************************************
* Register user with hashed password * Register user with hashed password
**************************************/ **************************************/
exports.registerUser = function(req, res) { exports.registerUser = async function(req, res) {
var userid = req.body.userid; var userid = req.body.userid;
var username = req.body.username; var username = req.body.username;
var plaintextPassword = req.body.password; var plaintextPassword = req.body.password;
@@ -98,20 +92,20 @@ exports.registerUser = function(req, res) {
} }
bcrypt.hash(plaintextPassword, saltRounds) bcrypt.hash(plaintextPassword, saltRounds)
.then(function(hash) { .then(async function(hash) {
let new_user = generateUserObject(userid, username, hash); let new_user = generateUserObject(userid, username, hash);
// check if user exists // check if user exists
if (users_db.get('users').find({uid: userid}).value()) { if (await db_api.getRecord('users', {uid: userid})) {
// user id is taken! // user id is taken!
logger.error('Registration failed: UID is already taken!'); logger.error('Registration failed: UID is already taken!');
res.status(409).send('UID is already taken!'); res.status(409).send('UID is already taken!');
} else if (users_db.get('users').find({name: username}).value()) { } else if (await db_api.getRecord('users', {name: username})) {
// user name is taken! // user name is taken!
logger.error('Registration failed: User name is already taken!'); logger.error('Registration failed: User name is already taken!');
res.status(409).send('User name is already taken!'); res.status(409).send('User name is already taken!');
} else { } else {
// add to db // add to db
users_db.get('users').push(new_user).write(); await db_api.insertRecordIntoTable('users', new_user);
logger.verbose(`New user created: ${new_user.name}`); logger.verbose(`New user created: ${new_user.name}`);
res.send({ res.send({
user: new_user user: new_user
@@ -144,16 +138,18 @@ exports.registerUser = function(req, res) {
************************************************/ ************************************************/
exports.login = async (username, password) => {
const user = await db_api.getRecord('users', {name: username});
if (!user) { logger.error(`User ${username} not found`); false }
if (user.auth_method && user.auth_method !== 'internal') { return false }
return await bcrypt.compare(password, user.passhash) ? user : false;
}
exports.passport.use(new LocalStrategy({ exports.passport.use(new LocalStrategy({
usernameField: 'username', usernameField: 'username',
passwordField: 'password'}, passwordField: 'password'},
async function(username, password, done) { async function(username, password, done) {
const user = users_db.get('users').find({name: username}).value(); return done(null, await exports.login(username, password));
if (!user) { logger.error(`User ${username} not found`); return done(null, false); }
if (user.auth_method && user.auth_method !== 'internal') { return done(null, false); }
if (user) {
return done(null, (await bcrypt.compare(password, user.passhash)) ? user : false);
}
} }
)); ));
@@ -164,17 +160,17 @@ var getLDAPConfiguration = function(req, callback) {
}; };
exports.passport.use(new LdapStrategy(getLDAPConfiguration, exports.passport.use(new LdapStrategy(getLDAPConfiguration,
function(user, done) { async function(user, done) {
// check if ldap auth is enabled // check if ldap auth is enabled
const ldap_enabled = config_api.getConfigItem('ytdl_auth_method') === 'ldap'; const ldap_enabled = config_api.getConfigItem('ytdl_auth_method') === 'ldap';
if (!ldap_enabled) return done(null, false); if (!ldap_enabled) return done(null, false);
const user_uid = user.uid; const user_uid = user.uid;
let db_user = users_db.get('users').find({uid: user_uid}).value(); let db_user = await db_api.getRecord('users', {uid: user_uid});
if (!db_user) { if (!db_user) {
// generate DB user // generate DB user
let new_user = generateUserObject(user_uid, user_uid, null, 'ldap'); let new_user = generateUserObject(user_uid, user_uid, null, 'ldap');
users_db.get('users').push(new_user).write(); await db_api.insertRecordIntoTable('users', new_user);
db_user = new_user; db_user = new_user;
logger.verbose(`Generated new user ${user_uid} using LDAP`); logger.verbose(`Generated new user ${user_uid} using LDAP`);
} }
@@ -198,11 +194,11 @@ exports.generateJWT = function(req, res, next) {
next(); next();
} }
exports.returnAuthResponse = function(req, res) { exports.returnAuthResponse = async function(req, res) {
res.status(200).json({ res.status(200).json({
user: req.user, user: req.user,
token: req.token, token: req.token,
permissions: exports.userPermissions(req.user.uid), permissions: await exports.userPermissions(req.user.uid),
available_permissions: consts['AVAILABLE_PERMISSIONS'] available_permissions: consts['AVAILABLE_PERMISSIONS']
}); });
} }
@@ -215,7 +211,7 @@ exports.returnAuthResponse = function(req, res) {
* It also passes the user object to the next * It also passes the user object to the next
* middleware through res.locals * middleware through res.locals
**************************************/ **************************************/
exports.ensureAuthenticatedElseError = function(req, res, next) { exports.ensureAuthenticatedElseError = (req, res, next) => {
var token = getToken(req.query); var token = getToken(req.query);
if( token ) { if( token ) {
try { try {
@@ -233,10 +229,10 @@ exports.ensureAuthenticatedElseError = function(req, res, next) {
} }
// change password // change password
exports.changeUserPassword = async function(user_uid, new_pass) { exports.changeUserPassword = async (user_uid, new_pass) => {
try { try {
const hash = await bcrypt.hash(new_pass, saltRounds); const hash = await bcrypt.hash(new_pass, saltRounds);
users_db.get('users').find({uid: user_uid}).assign({passhash: hash}).write(); await db_api.updateRecord('users', {uid: user_uid}, {passhash: hash});
return true; return true;
} catch (err) { } catch (err) {
return false; return false;
@@ -244,16 +240,15 @@ exports.changeUserPassword = async function(user_uid, new_pass) {
} }
// change user permissions // change user permissions
exports.changeUserPermissions = function(user_uid, permission, new_value) { exports.changeUserPermissions = async (user_uid, permission, new_value) => {
try { try {
const user_db_obj = users_db.get('users').find({uid: user_uid}); await db_api.pullFromRecordsArray('users', {uid: user_uid}, 'permissions', permission);
user_db_obj.get('permissions').pull(permission).write(); await db_api.pullFromRecordsArray('users', {uid: user_uid}, 'permission_overrides', permission);
user_db_obj.get('permission_overrides').pull(permission).write();
if (new_value === 'yes') { if (new_value === 'yes') {
user_db_obj.get('permissions').push(permission).write(); await db_api.pushToRecordsArray('users', {uid: user_uid}, 'permissions', permission);
user_db_obj.get('permission_overrides').push(permission).write(); await db_api.pushToRecordsArray('users', {uid: user_uid}, 'permission_overrides', permission);
} else if (new_value === 'no') { } else if (new_value === 'no') {
user_db_obj.get('permission_overrides').push(permission).write(); await db_api.pushToRecordsArray('users', {uid: user_uid}, 'permission_overrides', permission);
} }
return true; return true;
} catch (err) { } catch (err) {
@@ -263,12 +258,11 @@ exports.changeUserPermissions = function(user_uid, permission, new_value) {
} }
// change role permissions // change role permissions
exports.changeRolePermissions = function(role, permission, new_value) { exports.changeRolePermissions = async (role, permission, new_value) => {
try { try {
const role_db_obj = users_db.get('roles').get(role); await db_api.pullFromRecordsArray('roles', {key: role}, 'permissions', permission);
role_db_obj.get('permissions').pull(permission).write();
if (new_value === 'yes') { if (new_value === 'yes') {
role_db_obj.get('permissions').push(permission).write(); await db_api.pushToRecordsArray('roles', {key: role}, 'permissions', permission);
} }
return true; return true;
} catch (err) { } catch (err) {
@@ -277,19 +271,19 @@ exports.changeRolePermissions = function(role, permission, new_value) {
} }
} }
exports.adminExists = function() { exports.adminExists = async function() {
return !!users_db.get('users').find({uid: 'admin'}).value(); return !!(await db_api.getRecord('users', {uid: 'admin'}));
} }
// video stuff // video stuff
exports.getUserVideos = function(user_uid, type) { exports.getUserVideos = async function(user_uid, type) {
const user = users_db.get('users').find({uid: user_uid}).value(); const files = await db_api.getRecords('files', {user_uid: user_uid});
return type ? user['files'].filter(file => file.isAudio = (type === 'audio')) : user['files']; return type ? files.filter(file => file.isAudio === (type === 'audio')) : files;
} }
exports.getUserVideo = function(user_uid, file_uid, requireSharing = false) { exports.getUserVideo = async function(user_uid, file_uid, requireSharing = false) {
let file = users_db.get('users').find({uid: user_uid}).get(`files`).find({uid: file_uid}).value(); let file = await db_api.getRecord('files', {file_uid: file_uid});
// prevent unauthorized users from accessing the file info // prevent unauthorized users from accessing the file info
if (file && !file['sharingEnabled'] && requireSharing) file = null; if (file && !file['sharingEnabled'] && requireSharing) file = null;
@@ -297,58 +291,22 @@ exports.getUserVideo = function(user_uid, file_uid, requireSharing = false) {
return file; return file;
} }
exports.addPlaylist = function(user_uid, new_playlist) {
users_db.get('users').find({uid: user_uid}).get(`playlists`).push(new_playlist).write();
return true;
}
exports.updatePlaylistFiles = function(user_uid, playlistID, new_filenames) { exports.updatePlaylistFiles = function(user_uid, playlistID, new_filenames) {
users_db.get('users').find({uid: user_uid}).get(`playlists`).find({id: playlistID}).assign({fileNames: new_filenames}); users_db.get('users').find({uid: user_uid}).get(`playlists`).find({id: playlistID}).assign({fileNames: new_filenames});
return true; return true;
} }
exports.removePlaylist = function(user_uid, playlistID) { exports.removePlaylist = async function(user_uid, playlistID) {
users_db.get('users').find({uid: user_uid}).get(`playlists`).remove({id: playlistID}).write(); await db_api.removeRecord('playlist', {playlistID: playlistID});
return true; return true;
} }
exports.getUserPlaylists = function(user_uid, user_files = null) { exports.getUserPlaylists = async function(user_uid, user_files = null) {
const user = users_db.get('users').find({uid: user_uid}).value(); return await db_api.getRecords('playlists', {user_uid: user_uid});
const playlists = JSON.parse(JSON.stringify(user['playlists']));
const categories = db.get('categories').value();
if (categories && user_files) {
categories.forEach(category => {
const audio_files = user_files && user_files.filter(file => file.category && file.category.uid === category.uid && file.isAudio);
const video_files = user_files && user_files.filter(file => file.category && file.category.uid === category.uid && !file.isAudio);
if (audio_files && audio_files.length > 0) {
playlists.push({
name: category['name'],
thumbnailURL: audio_files[0].thumbnailURL,
thumbnailPath: audio_files[0].thumbnailPath,
fileNames: audio_files.map(file => file.id),
type: 'audio',
uid: user_uid,
auto: true
});
}
if (video_files && video_files.length > 0) {
playlists.push({
name: category['name'],
thumbnailURL: video_files[0].thumbnailURL,
thumbnailPath: video_files[0].thumbnailPath,
fileNames: video_files.map(file => file.id),
type: 'video',
uid: user_uid,
auto: true
});
}
});
}
return playlists;
} }
exports.getUserPlaylist = function(user_uid, playlistID, requireSharing = false) { exports.getUserPlaylist = async function(user_uid, playlistID, requireSharing = false) {
let playlist = users_db.get('users').find({uid: user_uid}).get(`playlists`).find({id: playlistID}).value(); let playlist = await db_api.getRecord('playlists', {id: playlistID});
// prevent unauthorized users from accessing the file info // prevent unauthorized users from accessing the file info
if (requireSharing && !playlist['sharingEnabled']) playlist = null; if (requireSharing && !playlist['sharingEnabled']) playlist = null;
@@ -356,109 +314,23 @@ exports.getUserPlaylist = function(user_uid, playlistID, requireSharing = false)
return playlist; return playlist;
} }
exports.registerUserFile = function(user_uid, file_object) { exports.changeSharingMode = async function(user_uid, file_uid, is_playlist, enabled) {
users_db.get('users').find({uid: user_uid}).get(`files`)
.remove({
path: file_object['path']
}).write();
users_db.get('users').find({uid: user_uid}).get(`files`)
.push(file_object)
.write();
}
exports.deleteUserFile = async function(user_uid, file_uid, blacklistMode = false) {
let success = false; let success = false;
const file_obj = users_db.get('users').find({uid: user_uid}).get(`files`).find({uid: file_uid}).value(); is_playlist ? await db_api.updateRecord(`playlists`, {id: file_uid}, {sharingEnabled: enabled}) : await db_api.updateRecord(`files`, {uid: file_uid}, {sharingEnabled: enabled});
if (file_obj) { success = true;
const type = file_obj.isAudio ? 'audio' : 'video';
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const ext = type === 'audio' ? '.mp3' : '.mp4';
// close descriptors
if (config_api.descriptors[file_obj.id]) {
try {
for (let i = 0; i < config_api.descriptors[file_obj.id].length; i++) {
config_api.descriptors[file_obj.id][i].destroy();
}
} catch(e) {
}
}
const full_path = path.join(usersFileFolder, user_uid, type, file_obj.id + ext);
users_db.get('users').find({uid: user_uid}).get(`files`)
.remove({
uid: file_uid
}).write();
if (await fs.pathExists(full_path)) {
// remove json and file
const json_path = path.join(usersFileFolder, user_uid, type, file_obj.id + '.info.json');
const alternate_json_path = path.join(usersFileFolder, user_uid, type, file_obj.id + ext + '.info.json');
let youtube_id = null;
if (await fs.pathExists(json_path)) {
youtube_id = await fs.readJSON(json_path).id;
await fs.unlink(json_path);
} else if (await fs.pathExists(alternate_json_path)) {
youtube_id = await fs.readJSON(alternate_json_path).id;
await fs.unlink(alternate_json_path);
}
await fs.unlink(full_path);
// do archive stuff
let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive) {
const archive_path = path.join(usersFileFolder, user_uid, 'archives', `archive_${type}.txt`);
// use subscriptions API to remove video from the archive file, and write it to the blacklist
if (await fs.pathExists(archive_path)) {
const line = youtube_id ? await subscriptions_api.removeIDFromArchive(archive_path, youtube_id) : null;
if (blacklistMode && line) {
let blacklistPath = path.join(usersFileFolder, user_uid, 'archives', `blacklist_${type}.txt`);
// adds newline to the beginning of the line
line = '\n' + line;
await fs.appendFile(blacklistPath, line);
}
} else {
logger.info(`Could not find archive file for ${type} files. Creating...`);
await fs.ensureFile(archive_path);
}
}
}
success = true;
} else {
success = false;
logger.warn(`User file ${file_uid} does not exist!`);
}
return success; return success;
} }
exports.changeSharingMode = function(user_uid, file_uid, is_playlist, enabled) { exports.userHasPermission = async function(user_uid, permission) {
let success = false;
const user_db_obj = users_db.get('users').find({uid: user_uid});
if (user_db_obj.value()) {
const file_db_obj = is_playlist ? user_db_obj.get(`playlists`).find({id: file_uid}) : user_db_obj.get(`files`).find({uid: file_uid});
if (file_db_obj.value()) {
success = true;
file_db_obj.assign({sharingEnabled: enabled}).write();
}
}
return success; const user_obj = await db_api.getRecord('users', ({uid: user_uid}));
}
exports.userHasPermission = function(user_uid, permission) {
const user_obj = users_db.get('users').find({uid: user_uid}).value();
const role = user_obj['role']; const role = user_obj['role'];
if (!role) { if (!role) {
// role doesn't exist // role doesn't exist
logger.error('Invalid role ' + role); logger.error('Invalid role ' + role);
return false; return false;
} }
const role_permissions = (users_db.get('roles').value())['permissions']; const role_permissions = (await db_api.getRecords('roles'))['permissions'];
const user_has_explicit_permission = user_obj['permissions'].includes(permission); const user_has_explicit_permission = user_obj['permissions'].includes(permission);
const permission_in_overrides = user_obj['permission_overrides'].includes(permission); const permission_in_overrides = user_obj['permission_overrides'].includes(permission);
@@ -481,16 +353,17 @@ exports.userHasPermission = function(user_uid, permission) {
} }
} }
exports.userPermissions = function(user_uid) { exports.userPermissions = async function(user_uid) {
let user_permissions = []; let user_permissions = [];
const user_obj = users_db.get('users').find({uid: user_uid}).value(); const user_obj = await db_api.getRecord('users', ({uid: user_uid}));
const role = user_obj['role']; const role = user_obj['role'];
if (!role) { if (!role) {
// role doesn't exist // role doesn't exist
logger.error('Invalid role ' + role); logger.error('Invalid role ' + role);
return null; return null;
} }
const role_permissions = users_db.get('roles').get(role).get('permissions').value() const role_obj = await db_api.getRecord('roles', {key: role});
const role_permissions = role_obj['permissions'];
for (let i = 0; i < consts['AVAILABLE_PERMISSIONS'].length; i++) { for (let i = 0; i < consts['AVAILABLE_PERMISSIONS'].length; i++) {
let permission = consts['AVAILABLE_PERMISSIONS'][i]; let permission = consts['AVAILABLE_PERMISSIONS'][i];
@@ -536,14 +409,8 @@ function generateUserObject(userid, username, hash, auth_method = 'internal') {
name: username, name: username,
uid: userid, uid: userid,
passhash: auth_method === 'internal' ? hash : null, passhash: auth_method === 'internal' ? hash : null,
files: { files: [],
audio: [], playlists: [],
video: []
},
playlists: {
audio: [],
video: []
},
subscriptions: [], subscriptions: [],
created: Date.now(), created: Date.now(),
role: userid === 'admin' && auth_method === 'internal' ? 'admin' : 'user', role: userid === 'admin' && auth_method === 'internal' ? 'admin' : 'user',

View File

@@ -1,4 +1,5 @@
const config_api = require('./config'); const config_api = require('./config');
const utils = require('./utils');
var logger = null; var logger = null;
var db = null; var db = null;
@@ -33,35 +34,58 @@ Rules:
*/ */
async function categorize(file_json) { async function categorize(file_jsons) {
// to make the logic easier, let's assume the file metadata is an array
if (!Array.isArray(file_jsons)) file_jsons = [file_jsons];
let selected_category = null; let selected_category = null;
const categories = getCategories(); const categories = await getCategories();
if (!categories) { if (!categories) {
logger.warn('Categories could not be found. Initializing categories...'); logger.warn('Categories could not be found.');
db.assign({categories: []}).write();
return null; return null;
return;
} }
for (let i = 0; i < categories.length; i++) { for (let i = 0; i < file_jsons.length; i++) {
const category = categories[i]; const file_json = file_jsons[i];
const rules = category['rules']; for (let j = 0; j < categories.length; j++) {
const category = categories[j];
// if rules for current category apply, then that is the selected category const rules = category['rules'];
if (applyCategoryRules(file_json, rules, category['name'])) {
selected_category = category; // if rules for current category apply, then that is the selected category
logger.verbose(`Selected category ${category['name']} for ${file_json['webpage_url']}`); if (applyCategoryRules(file_json, rules, category['name'])) {
return selected_category; selected_category = category;
logger.verbose(`Selected category ${category['name']} for ${file_json['webpage_url']}`);
return selected_category;
}
} }
} }
return selected_category; return selected_category;
} }
function getCategories() { async function getCategories() {
const categories = db.get('categories').value(); const categories = await db_api.getRecords('categories');
return categories ? categories : null; return categories ? categories : null;
} }
async function getCategoriesAsPlaylists(files = null) {
const categories_as_playlists = [];
const available_categories = await getCategories();
if (available_categories && files) {
for (category of available_categories) {
const files_that_match = utils.addUIDsToCategory(category, files);
if (files_that_match && files_that_match.length > 0) {
category['thumbnailURL'] = files_that_match[0].thumbnailURL;
category['thumbnailPath'] = files_that_match[0].thumbnailPath;
category['duration'] = files_that_match.reduce((a, b) => a + utils.durationStringToNumber(b.duration), 0);
category['id'] = category['uid'];
categories_as_playlists.push(category);
}
}
}
return categories_as_playlists;
}
function applyCategoryRules(file_json, rules, category_name) { function applyCategoryRules(file_json, rules, category_name) {
let rules_apply = false; let rules_apply = false;
for (let i = 0; i < rules.length; i++) { for (let i = 0; i < rules.length; i++) {
@@ -72,10 +96,10 @@ function applyCategoryRules(file_json, rules, category_name) {
switch (rule['comparator']) { switch (rule['comparator']) {
case 'includes': case 'includes':
rule_applies = file_json[rule['property']].includes(rule['value']); rule_applies = file_json[rule['property']].toLowerCase().includes(rule['value'].toLowerCase());
break; break;
case 'not_includes': case 'not_includes':
rule_applies = !(file_json[rule['property']].includes(rule['value'])); rule_applies = !(file_json[rule['property']].toLowerCase().includes(rule['value'].toLowerCase()));
break; break;
case 'equals': case 'equals':
rule_applies = file_json[rule['property']] === rule['value']; rule_applies = file_json[rule['property']] === rule['value'];
@@ -120,4 +144,6 @@ async function addTagToExistingTags(tag) {
module.exports = { module.exports = {
initialize: initialize, initialize: initialize,
categorize: categorize, categorize: categorize,
getCategories: getCategories,
getCategoriesAsPlaylists: getCategoriesAsPlaylists
} }

View File

@@ -197,7 +197,8 @@ DEFAULT_CONFIG = {
"allow_quality_select": true, "allow_quality_select": true,
"download_only_mode": false, "download_only_mode": false,
"allow_multi_download_mode": true, "allow_multi_download_mode": true,
"enable_downloads_manager": true "enable_downloads_manager": true,
"allow_playlist_categorization": true
}, },
"API": { "API": {
"use_API_key": false, "use_API_key": false,
@@ -230,6 +231,10 @@ DEFAULT_CONFIG = {
"searchFilter": "(uid={{username}})" "searchFilter": "(uid={{username}})"
} }
}, },
"Database": {
"use_local_db": false,
"mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib"
},
"Advanced": { "Advanced": {
"default_downloader": "youtube-dl", "default_downloader": "youtube-dl",
"use_default_downloading_agent": true, "use_default_downloading_agent": true,

View File

@@ -68,6 +68,10 @@ let CONFIG_ITEMS = {
'key': 'ytdl_enable_downloads_manager', 'key': 'ytdl_enable_downloads_manager',
'path': 'YoutubeDLMaterial.Extra.enable_downloads_manager' 'path': 'YoutubeDLMaterial.Extra.enable_downloads_manager'
}, },
'ytdl_allow_playlist_categorization': {
'key': 'ytdl_allow_playlist_categorization',
'path': 'YoutubeDLMaterial.Extra.allow_playlist_categorization'
},
// API // API
'ytdl_use_api_key': { 'ytdl_use_api_key': {
@@ -149,6 +153,16 @@ let CONFIG_ITEMS = {
'path': 'YoutubeDLMaterial.Users.ldap_config' 'path': 'YoutubeDLMaterial.Users.ldap_config'
}, },
// Database
'ytdl_use_local_db': {
'key': 'ytdl_use_local_db',
'path': 'YoutubeDLMaterial.Database.use_local_db'
},
'ytdl_mongodb_connection_string': {
'key': 'ytdl_mongodb_connection_string',
'path': 'YoutubeDLMaterial.Database.mongodb_connection_string'
},
// Advanced // Advanced
'ytdl_default_downloader': { 'ytdl_default_downloader': {
'key': 'ytdl_default_downloader', 'key': 'ytdl_default_downloader',

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
CMD="node app.js" CMD="forever app.js"
# if the first arg starts with "-" pass it to program # if the first arg starts with "-" pass it to program
if [ "${1#-}" != "$1" ]; then if [ "${1#-}" != "$1" ]; then

1272
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,8 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"start": "nodemon -q app.js" "start": "nodemon app.js",
"debug": "set YTDL_MODE=debug && node app.js"
}, },
"nodemonConfig": { "nodemonConfig": {
"ignore": [ "ignore": [
@@ -14,7 +15,8 @@
"public/*" "public/*"
], ],
"watch": [ "watch": [
"restart.json" "restart_update.json",
"restart_general.json"
] ]
}, },
"repository": { "repository": {
@@ -30,7 +32,7 @@
"dependencies": { "dependencies": {
"archiver": "^3.1.1", "archiver": "^3.1.1",
"async": "^3.1.0", "async": "^3.1.0",
"axios": "^0.21.0", "axios": "^0.21.1",
"bcryptjs": "^2.4.0", "bcryptjs": "^2.4.0",
"compression": "^1.7.4", "compression": "^1.7.4",
"config": "^3.2.3", "config": "^3.2.3",
@@ -43,11 +45,13 @@
"lowdb": "^1.0.0", "lowdb": "^1.0.0",
"md5": "^2.2.1", "md5": "^2.2.1",
"merge-files": "^0.1.2", "merge-files": "^0.1.2",
"mocha": "^8.4.0",
"moment": "^2.29.1", "moment": "^2.29.1",
"mongodb": "^3.6.9",
"multer": "^1.4.2", "multer": "^1.4.2",
"node-fetch": "^2.6.1", "node-fetch": "^2.6.1",
"node-id3": "^0.1.14", "node-id3": "^0.1.14",
"nodemon": "^2.0.2", "nodemon": "^2.0.7",
"passport": "^0.4.1", "passport": "^0.4.1",
"passport-http": "^0.3.0", "passport-http": "^0.3.0",
"passport-jwt": "^4.0.0", "passport-jwt": "^4.0.0",

View File

@@ -14,13 +14,13 @@ const debugMode = process.env.YTDL_MODE === 'debug';
var logger = null; var logger = null;
var db = null; var db = null;
var users_db = null; var users_db = null;
var db_api = null; let db_api = null;
function setDB(input_db, input_users_db, input_db_api) { db = input_db; users_db = input_users_db; db_api = input_db_api } function setDB(input_db_api) { db_api = input_db_api }
function setLogger(input_logger) { logger = input_logger; } function setLogger(input_logger) { logger = input_logger; }
function initialize(input_db, input_users_db, input_logger, input_db_api) { function initialize(input_db_api, input_logger) {
setDB(input_db, input_users_db, input_db_api); setDB(input_db_api);
setLogger(input_logger); setLogger(input_logger);
} }
@@ -34,12 +34,7 @@ async function subscribe(sub, user_uid = null) {
sub.isPlaylist = sub.url.includes('playlist'); sub.isPlaylist = sub.url.includes('playlist');
sub.videos = []; sub.videos = [];
let url_exists = false; let url_exists = !!(await db_api.getRecord('subscriptions', {url: sub.url, user_uid: user_uid}));
if (user_uid)
url_exists = !!users_db.get('users').find({uid: user_uid}).get('subscriptions').find({url: sub.url}).value()
else
url_exists = !!db.get('subscriptions').find({url: sub.url}).value();
if (!sub.name && url_exists) { if (!sub.name && url_exists) {
logger.error(`Sub with the same URL "${sub.url}" already exists -- please provide a custom name for this new subscription.`); logger.error(`Sub with the same URL "${sub.url}" already exists -- please provide a custom name for this new subscription.`);
@@ -48,19 +43,12 @@ async function subscribe(sub, user_uid = null) {
return; return;
} }
// add sub to db sub['user_uid'] = user_uid ? user_uid : undefined;
let sub_db = null; await db_api.insertRecordIntoTable('subscriptions', sub);
if (user_uid) {
users_db.get('users').find({uid: user_uid}).get('subscriptions').push(sub).write();
sub_db = users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id});
} else {
db.get('subscriptions').push(sub).write();
sub_db = db.get('subscriptions').find({id: sub.id});
}
let success = await getSubscriptionInfo(sub, user_uid); let success = await getSubscriptionInfo(sub, user_uid);
if (success) { if (success) {
sub = sub_db.value();
getVideosForSub(sub, user_uid); getVideosForSub(sub, user_uid);
} else { } else {
logger.error('Subscribe: Failed to get subscription info. Subscribe failed.') logger.error('Subscribe: Failed to get subscription info. Subscribe failed.')
@@ -91,8 +79,8 @@ async function getSubscriptionInfo(sub, user_uid = null) {
} }
} }
return new Promise(resolve => { return new Promise(async resolve => {
youtubedl.exec(sub.url, downloadConfig, {}, function(err, output) { youtubedl.exec(sub.url, downloadConfig, {maxBuffer: Infinity}, async (err, output) => {
if (debugMode) { if (debugMode) {
logger.info('Subscribe: got info for subscription ' + sub.id); logger.info('Subscribe: got info for subscription ' + sub.id);
} }
@@ -122,10 +110,7 @@ async function getSubscriptionInfo(sub, user_uid = null) {
} }
// if it's now valid, update // if it's now valid, update
if (sub.name) { if (sub.name) {
if (user_uid) await db_api.updateRecord('subscriptions', {id: sub.id}, {name: sub.name});
users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign({name: sub.name}).write();
else
db.get('subscriptions').find({id: sub.id}).assign({name: sub.name}).write();
} }
} }
@@ -141,10 +126,8 @@ async function getSubscriptionInfo(sub, user_uid = null) {
// updates subscription // updates subscription
sub.archive = archive_dir; sub.archive = archive_dir;
if (user_uid)
users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign({archive: archive_dir}).write(); await db_api.updateRecord('subscriptions', {id: sub.id}, {archive: archive_dir});
else
db.get('subscriptions').find({id: sub.id}).assign({archive: archive_dir}).write();
} }
// TODO: get even more info // TODO: get even more info
@@ -166,10 +149,8 @@ async function unsubscribe(sub, deleteMode, user_uid = null) {
let result_obj = { success: false, error: '' }; let result_obj = { success: false, error: '' };
let id = sub.id; let id = sub.id;
if (user_uid) await db_api.removeRecord('subscriptions', {id: id});
users_db.get('users').find({uid: user_uid}).get('subscriptions').remove({id: id}).write(); await db_api.removeAllRecords('files', {sub_id: id});
else
db.get('subscriptions').remove({id: id}).write();
// failed subs have no name, on unsubscribe they shouldn't error // failed subs have no name, on unsubscribe they shouldn't error
if (!sub.name) { if (!sub.name) {
@@ -191,27 +172,23 @@ async function unsubscribe(sub, deleteMode, user_uid = null) {
} }
async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null, user_uid = null) { async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null, user_uid = null) {
// TODO: combine this with deletefile
let basePath = null; let basePath = null;
let sub_db = null; basePath = user_uid ? path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions')
if (user_uid) { : config_api.getConfigItem('ytdl_subscriptions_base_path');
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
sub_db = users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id});
} else {
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
sub_db = db.get('subscriptions').find({id: sub.id});
}
const useArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive'); const useArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
const appendedBasePath = getAppendedBasePath(sub, basePath); const appendedBasePath = getAppendedBasePath(sub, basePath);
const name = file; const name = file;
let retrievedID = null; let retrievedID = null;
sub_db.get('videos').remove({uid: file_uid}).write();
await db_api.removeRecord('files', {uid: file_uid});
let filePath = appendedBasePath; let filePath = appendedBasePath;
const ext = (sub.type && sub.type === 'audio') ? '.mp3' : '.mp4' const ext = (sub.type && sub.type === 'audio') ? '.mp3' : '.mp4'
var jsonPath = path.join(__dirname,filePath,name+'.info.json'); var jsonPath = path.join(__dirname,filePath,name+'.info.json');
var videoFilePath = path.join(__dirname,filePath,name+ext); var videoFilePath = path.join(__dirname,filePath,name+ext);
var imageFilePath = path.join(__dirname,filePath,name+'.jpg'); var imageFilePath = path.join(__dirname,filePath,name+'.jpg');
var altImageFilePath = path.join(__dirname,filePath,name+'.jpg'); var altImageFilePath = path.join(__dirname,filePath,name+'.webp');
const [jsonExists, videoFileExists, imageFileExists, altImageFileExists] = await Promise.all([ const [jsonExists, videoFileExists, imageFileExists, altImageFileExists] = await Promise.all([
fs.pathExists(jsonPath), fs.pathExists(jsonPath),
@@ -243,7 +220,7 @@ async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null,
const archive_path = path.join(sub.archive, 'archive.txt') const archive_path = path.join(sub.archive, 'archive.txt')
// if archive exists, remove line with video ID // if archive exists, remove line with video ID
if (await fs.pathExists(archive_path)) { if (await fs.pathExists(archive_path)) {
await removeIDFromArchive(archive_path, retrievedID); utils.removeIDFromArchive(archive_path, retrievedID);
} }
} }
return true; return true;
@@ -255,14 +232,7 @@ async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null,
} }
async function getVideosForSub(sub, user_uid = null) { async function getVideosForSub(sub, user_uid = null) {
// get sub_db const latest_sub_obj = await getSubscription(sub.id);
let sub_db = null;
if (user_uid)
sub_db = users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id});
else
sub_db = db.get('subscriptions').find({id: sub.id});
const latest_sub_obj = sub_db.value();
if (!latest_sub_obj || latest_sub_obj['downloading']) { if (!latest_sub_obj || latest_sub_obj['downloading']) {
return false; return false;
} }
@@ -277,6 +247,7 @@ async function getVideosForSub(sub, user_uid = null) {
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
let appendedBasePath = getAppendedBasePath(sub, basePath); let appendedBasePath = getAppendedBasePath(sub, basePath);
fs.ensureDirSync(appendedBasePath);
let multiUserMode = null; let multiUserMode = null;
if (user_uid) { if (user_uid) {
@@ -291,9 +262,18 @@ async function getVideosForSub(sub, user_uid = null) {
// get videos // get videos
logger.verbose('Subscription: getting videos for subscription ' + sub.name); logger.verbose('Subscription: getting videos for subscription ' + sub.name);
return new Promise(resolve => { return new Promise(async resolve => {
youtubedl.exec(sub.url, downloadConfig, {}, async function(err, output) { const preimported_file_paths = [];
const PREIMPORT_INTERVAL = 5000;
const preregister_check = setInterval(async () => {
if (sub.streamingOnly) return;
await db_api.preimportUnregisteredSubscriptionFile(sub, appendedBasePath);
}, PREIMPORT_INTERVAL);
youtubedl.exec(sub.url, downloadConfig, {maxBuffer: Infinity}, async function(err, output) {
// cleanup
updateSubscriptionProperty(sub, {downloading: false}, user_uid); updateSubscriptionProperty(sub, {downloading: false}, user_uid);
clearInterval(preregister_check);
logger.verbose('Subscription: finished check for ' + sub.name); logger.verbose('Subscription: finished check for ' + sub.name);
if (err && !output) { if (err && !output) {
logger.error(err.stderr ? err.stderr : err.message); logger.error(err.stderr ? err.stderr : err.message);
@@ -303,7 +283,7 @@ async function getVideosForSub(sub, user_uid = null) {
const outputs = err.stdout.split(/\r\n|\r|\n/); const outputs = err.stdout.split(/\r\n|\r|\n/);
for (let i = 0; i < outputs.length; i++) { for (let i = 0; i < outputs.length; i++) {
const output = JSON.parse(outputs[i]); const output = JSON.parse(outputs[i]);
handleOutputJSON(sub, sub_db, output, i === 0, multiUserMode) await handleOutputJSON(sub, output, i === 0, multiUserMode)
if (err.stderr.includes(output['id']) && archive_path) { if (err.stderr.includes(output['id']) && archive_path) {
// we found a video that errored! add it to the archive to prevent future errors // we found a video that errored! add it to the archive to prevent future errors
if (sub.archive) { if (sub.archive) {
@@ -337,7 +317,7 @@ async function getVideosForSub(sub, user_uid = null) {
} }
const reset_videos = i === 0; const reset_videos = i === 0;
handleOutputJSON(sub, sub_db, output_json, multiUserMode, reset_videos); await handleOutputJSON(sub, output_json, multiUserMode, preimported_file_paths, reset_videos);
} }
if (config_api.getConfigItem('ytdl_subscriptions_redownload_fresh_uploads')) { if (config_api.getConfigItem('ytdl_subscriptions_redownload_fresh_uploads')) {
@@ -351,6 +331,7 @@ async function getVideosForSub(sub, user_uid = null) {
}, err => { }, err => {
logger.error(err); logger.error(err);
updateSubscriptionProperty(sub, {downloading: false}, user_uid); updateSubscriptionProperty(sub, {downloading: false}, user_uid);
clearInterval(preregister_check);
}); });
} }
@@ -433,8 +414,9 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
return downloadConfig; return downloadConfig;
} }
function handleOutputJSON(sub, sub_db, output_json, multiUserMode = null, reset_videos = false) { async function handleOutputJSON(sub, output_json, multiUserMode = null, reset_videos = false) {
if (sub.streamingOnly) { // TODO: remove streaming only mode
if (false && sub.streamingOnly) {
if (reset_videos) { if (reset_videos) {
sub_db.assign({videos: []}).write(); sub_db.assign({videos: []}).write();
} }
@@ -448,12 +430,15 @@ function handleOutputJSON(sub, sub_db, output_json, multiUserMode = null, reset_
path_object = path.parse(output_json['_filename']); path_object = path.parse(output_json['_filename']);
const path_string = path.format(path_object); const path_string = path.format(path_object);
if (sub_db.get('videos').find({path: path_string}).value()) { const file_exists = await db_api.getRecord('files', {path: path_string, sub_id: sub.id});
if (file_exists) {
// TODO: fix issue where files of different paths due to custom path get downloaded multiple times
// file already exists in DB, return early to avoid reseting the download date // file already exists in DB, return early to avoid reseting the download date
return; return;
} }
db_api.registerFileDB(path.basename(output_json['_filename']), sub.type, multiUserMode, sub); await db_api.registerFileDB2(output_json['_filename'], sub.type, sub.user_uid, null, sub.id);
const url = output_json['webpage_url']; const url = output_json['webpage_url'];
if (sub.type === 'video' && url.includes('twitch.tv/videos/') && url.split('twitch.tv/videos/').length > 1 if (sub.type === 'video' && url.includes('twitch.tv/videos/') && url.split('twitch.tv/videos/').length > 1
&& config_api.getConfigItem('ytdl_use_twitch_api') && config_api.getConfigItem('ytdl_twitch_auto_download_chat')) { && config_api.getConfigItem('ytdl_use_twitch_api') && config_api.getConfigItem('ytdl_twitch_auto_download_chat')) {
@@ -466,73 +451,41 @@ function handleOutputJSON(sub, sub_db, output_json, multiUserMode = null, reset_
} }
} }
function getSubscriptions(user_uid = null) { async function getSubscriptions(user_uid = null) {
if (user_uid) return await db_api.getRecords('subscriptions', {user_uid: user_uid});
return users_db.get('users').find({uid: user_uid}).get('subscriptions').value();
else
return db.get('subscriptions').value();
} }
function getAllSubscriptions() { async function getAllSubscriptions() {
let subscriptions = null; const all_subs = await db_api.getRecords('subscriptions');
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode'); const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
if (multiUserMode) { return all_subs.filter(sub => !!(sub.user_uid) === multiUserMode);
subscriptions = [];
let users = users_db.get('users').value();
for (let i = 0; i < users.length; i++) {
if (users[i]['subscriptions']) subscriptions = subscriptions.concat(users[i]['subscriptions']);
}
} else {
subscriptions = getSubscriptions();
}
return subscriptions;
} }
function getSubscription(subID, user_uid = null) { async function getSubscription(subID) {
if (user_uid) return await db_api.getRecord('subscriptions', {id: subID});
return users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: subID}).value();
else
return db.get('subscriptions').find({id: subID}).value();
} }
function getSubscriptionByName(subName, user_uid = null) { async function getSubscriptionByName(subName, user_uid = null) {
if (user_uid) return await db_api.getRecord('subscriptions', {name: subName, user_uid: user_uid});
return users_db.get('users').find({uid: user_uid}).get('subscriptions').find({name: subName}).value();
else
return db.get('subscriptions').find({name: subName}).value();
} }
function updateSubscription(sub, user_uid = null) { async function updateSubscription(sub, user_uid = null) {
if (user_uid) { await db_api.updateRecord('subscriptions', {id: sub.id}, sub);
users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign(sub).write();
} else {
db.get('subscriptions').find({id: sub.id}).assign(sub).write();
}
return true; return true;
} }
function updateSubscriptionPropertyMultiple(subs, assignment_obj) { async function updateSubscriptionPropertyMultiple(subs, assignment_obj) {
subs.forEach(sub => { subs.forEach(async sub => {
updateSubscriptionProperty(sub, assignment_obj, sub.user_uid); await updateSubscriptionProperty(sub, assignment_obj, sub.user_uid);
}); });
} }
function updateSubscriptionProperty(sub, assignment_obj, user_uid = null) { async function updateSubscriptionProperty(sub, assignment_obj, user_uid = null) {
if (user_uid) { // TODO: combine with updateSubscription
users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign(assignment_obj).write(); await db_api.updateRecord('subscriptions', {id: sub.id}, assignment_obj);
} else {
db.get('subscriptions').find({id: sub.id}).assign(assignment_obj).write();
}
return true; return true;
} }
function subExists(subID, user_uid = null) {
if (user_uid)
return !!users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: subID}).value();
else
return !!db.get('subscriptions').find({id: subID}).value();
}
async function setFreshUploads(sub, user_uid) { async function setFreshUploads(sub, user_uid) {
const current_date = new Date().toISOString().split('T')[0].replace(/-/g, ''); const current_date = new Date().toISOString().split('T')[0].replace(/-/g, '');
sub.videos.forEach(async video => { sub.videos.forEach(async video => {
@@ -548,7 +501,7 @@ async function checkVideosForFreshUploads(sub, user_uid) {
const current_date = new Date().toISOString().split('T')[0].replace(/-/g, ''); const current_date = new Date().toISOString().split('T')[0].replace(/-/g, '');
sub.videos.forEach(async video => { sub.videos.forEach(async video => {
if (video['fresh_upload'] && current_date > video['upload_date'].replace(/-/g, '')) { if (video['fresh_upload'] && current_date > video['upload_date'].replace(/-/g, '')) {
checkVideoIfBetterExists(video, sub, user_uid) await checkVideoIfBetterExists(video, sub, user_uid)
} }
}); });
} }
@@ -558,14 +511,14 @@ async function checkVideoIfBetterExists(file_obj, sub, user_uid) {
const downloadConfig = await generateArgsForSubscription(sub, user_uid, true, new_path); const downloadConfig = await generateArgsForSubscription(sub, user_uid, true, new_path);
logger.verbose(`Checking if a better version of the fresh upload ${file_obj['id']} exists.`); logger.verbose(`Checking if a better version of the fresh upload ${file_obj['id']} exists.`);
// simulate a download to verify that a better version exists // simulate a download to verify that a better version exists
youtubedl.getInfo(file_obj['url'], downloadConfig, (err, output) => { youtubedl.getInfo(file_obj['url'], downloadConfig, async (err, output) => {
if (err) { if (err) {
// video is not available anymore for whatever reason // video is not available anymore for whatever reason
} else if (output) { } else if (output) {
const metric_to_compare = sub.type === 'audio' ? 'abr' : 'height'; const metric_to_compare = sub.type === 'audio' ? 'abr' : 'height';
if (output[metric_to_compare] > file_obj[metric_to_compare]) { if (output[metric_to_compare] > file_obj[metric_to_compare]) {
// download new video as the simulated one is better // download new video as the simulated one is better
youtubedl.exec(file_obj['url'], downloadConfig, async (err, output) => { youtubedl.exec(file_obj['url'], downloadConfig, {maxBuffer: Infinity}, async (err, output) => {
if (err) { if (err) {
logger.verbose(`Failed to download better version of video ${file_obj['id']}`); logger.verbose(`Failed to download better version of video ${file_obj['id']}`);
} else if (output) { } else if (output) {
@@ -586,33 +539,6 @@ function getAppendedBasePath(sub, base_path) {
return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name); return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name);
} }
async function removeIDFromArchive(archive_path, id) {
let data = await fs.readFile(archive_path, {encoding: 'utf-8'});
if (!data) {
logger.error('Archive could not be found.');
return;
}
let dataArray = data.split('\n'); // convert file data in an array
const searchKeyword = id; // we are looking for a line, contains, key word id in the file
let lastIndex = -1; // let say, we have not found the keyword
for (let index=0; index<dataArray.length; index++) {
if (dataArray[index].includes(searchKeyword)) { // check if a line contains the id keyword
lastIndex = index; // found a line includes a id keyword
break;
}
}
const line = dataArray.splice(lastIndex, 1); // remove the keyword id from the data Array
// UPDATE FILE WITH NEW DATA
const updatedData = dataArray.join('\n');
await fs.writeFile(archive_path, updatedData);
if (line) return line;
if (err) throw err;
}
module.exports = { module.exports = {
getSubscription : getSubscription, getSubscription : getSubscription,
getSubscriptionByName : getSubscriptionByName, getSubscriptionByName : getSubscriptionByName,
@@ -623,7 +549,6 @@ module.exports = {
unsubscribe : unsubscribe, unsubscribe : unsubscribe,
deleteSubscriptionFile : deleteSubscriptionFile, deleteSubscriptionFile : deleteSubscriptionFile,
getVideosForSub : getVideosForSub, getVideosForSub : getVideosForSub,
removeIDFromArchive : removeIDFromArchive,
setLogger : setLogger, setLogger : setLogger,
initialize : initialize, initialize : initialize,
updateSubscriptionPropertyMultiple : updateSubscriptionPropertyMultiple updateSubscriptionPropertyMultiple : updateSubscriptionPropertyMultiple

279
backend/test/tests.js Normal file
View File

@@ -0,0 +1,279 @@
var assert = require('assert');
const low = require('lowdb')
var winston = require('winston');
process.chdir('./backend')
const FileSync = require('lowdb/adapters/FileSync');
const adapter = new FileSync('./appdata/db.json');
const db = low(adapter)
const users_adapter = new FileSync('./appdata/users.json');
const users_db = low(users_adapter);
const defaultFormat = winston.format.printf(({ level, message, label, timestamp }) => {
return `${timestamp} ${level.toUpperCase()}: ${message}`;
});
let debugMode = process.env.YTDL_MODE === 'debug';
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(winston.format.timestamp(), defaultFormat),
defaultMeta: {},
transports: [
//
// - Write to all logs with level `info` and below to `combined.log`
// - Write all logs error (and below) to `error.log`.
//
new winston.transports.File({ filename: 'appdata/logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'appdata/logs/combined.log' }),
new winston.transports.Console({level: 'debug', name: 'console'})
]
});
var auth_api = require('../authentication/auth');
var db_api = require('../db');
const utils = require('../utils');
const subscriptions_api = require('../subscriptions');
const fs = require('fs-extra');
const { uuid } = require('uuidv4');
db_api.initialize(db, users_db, logger);
describe('Database', async function() {
describe('Import', async function() {
it('Migrate', async function() {
await db_api.connectToDB();
await db_api.removeAllRecords();
const success = await db_api.importJSONToDB(db.value(), users_db.value());
assert(success);
});
it('Transfer to remote', async function() {
await db_api.removeAllRecords('test');
await db_api.insertRecordIntoTable('test', {test: 'test'});
await db_api.transferDB(true);
const success = await db_api.getRecord('test', {test: 'test'});
assert(success);
});
it('Transfer to local', async function() {
await db_api.connectToDB();
await db_api.removeAllRecords('test');
await db_api.insertRecordIntoTable('test', {test: 'test'});
await db_api.transferDB(false);
const success = await db_api.getRecord('test', {test: 'test'});
assert(success);
});
});
describe('Export', function() {
});
describe('Import and Export', async function() {
it('Existing data', async function() {
const users_db_json = users_db.value();
const db_json = db.value();
const users_db_json_stringified = JSON.stringify(users_db_json);
const db_json_stringified = JSON.stringify(db_json);
const tables_obj = await db_api.importJSONtoDB(users_db_json, db_json);
const db_jsons = await db_api.exportDBToJSON(tables_obj);
const users_db_json_returned_stringified = db_jsons['users_db_json'];
const db_json_returned_stringified = db_jsons['db_json'];
assert(users_db_json_returned_stringified.length === users_db_json_stringified.length);
assert(db_json_returned_stringified.length === db_json_stringified.length);
});
});
describe('Basic functions', async function() {
beforeEach(async function() {
await db_api.connectToDB();
await db_api.removeAllRecords('test');
});
it('Add and read record', async function() {
await db_api.insertRecordIntoTable('test', {test_add: 'test', test_undefined: undefined, test_null: undefined});
const added_record = await db_api.getRecord('test', {test_add: 'test', test_undefined: undefined, test_null: null});
assert(added_record['test_add'] === 'test');
await db_api.removeRecord('test', {test_add: 'test'});
});
it('Update record', async function() {
await db_api.insertRecordIntoTable('test', {test_update: 'test'});
await db_api.updateRecord('test', {test_update: 'test'}, {added_field: true});
const updated_record = await db_api.getRecord('test', {test_update: 'test'});
assert(updated_record['added_field']);
await db_api.removeRecord('test', {test_update: 'test'});
});
it('Remove record', async function() {
await db_api.insertRecordIntoTable('test', {test_remove: 'test'});
const delete_succeeded = await db_api.removeRecord('test', {test_remove: 'test'});
assert(delete_succeeded);
const deleted_record = await db_api.getRecord('test', {test_remove: 'test'});
assert(!deleted_record);
});
it('Push to record array', async function() {
await db_api.insertRecordIntoTable('test', {test: 'test', test_array: []});
await db_api.pushToRecordsArray('test', {test: 'test'}, 'test_array', 'test_item');
const record = await db_api.getRecord('test', {test: 'test'});
assert(record);
assert(record['test_array'].length === 1);
});
it('Pull from record array', async function() {
await db_api.insertRecordIntoTable('test', {test: 'test', test_array: ['test_item']});
await db_api.pullFromRecordsArray('test', {test: 'test'}, 'test_array', 'test_item');
const record = await db_api.getRecord('test', {test: 'test'});
assert(record);
assert(record['test_array'].length === 0);
});
it('Bulk add', async function() {
const NUM_RECORDS_TO_ADD = 2002; // max batch ops is 1000
const test_records = [];
for (let i = 0; i < NUM_RECORDS_TO_ADD; i++) {
test_records.push({
uid: uuid()
});
}
const succcess = await db_api.bulkInsertRecordsIntoTable('test', test_records);
const received_records = await db_api.getRecords('test');
assert(succcess && received_records && received_records.length === NUM_RECORDS_TO_ADD);
});
it('Bulk update', async function() {
// bulk add records
const NUM_RECORDS_TO_ADD = 100; // max batch ops is 1000
const test_records = [];
const update_obj = {};
for (let i = 0; i < NUM_RECORDS_TO_ADD; i++) {
const test_uid = uuid();
test_records.push({
uid: test_uid
});
update_obj[test_uid] = {added_field: true};
}
let success = await db_api.bulkInsertRecordsIntoTable('test', test_records);
assert(success);
// makes sure they are added
const received_records = await db_api.getRecords('test');
assert(received_records && received_records.length === NUM_RECORDS_TO_ADD);
success = await db_api.bulkUpdateRecords('test', 'uid', update_obj);
assert(success);
const received_updated_records = await db_api.getRecords('test');
for (let i = 0; i < received_updated_records.length; i++) {
success &= received_updated_records[i]['added_field'];
}
assert(success);
});
it('Stats', async function() {
const stats = await db_api.getDBStats();
assert(stats);
});
});
});
describe('Multi User', async function() {
let user = null;
const user_to_test = 'admin';
const sub_to_test = 'dc834388-3454-41bf-a618-e11cb8c7de1c';
const playlist_to_test = 'ysabVZz4x';
beforeEach(async function() {
await db_api.connectToDB();
auth_api.initialize(db_api, logger);
subscriptions_api.initialize(db_api, logger);
user = await auth_api.login('admin', 'pass');
});
describe('Authentication', function() {
it('login', async function() {
assert(user);
});
});
describe('Video player - normal', function() {
const video_to_test = 'ebbcfffb-d6f1-4510-ad25-d1ec82e0477e';
it('Get video', async function() {
const video_obj = db_api.getVideo(video_to_test, 'admin');
assert(video_obj);
});
it('Video access - disallowed', async function() {
await db_api.setVideoProperty(video_to_test, {sharingEnabled: false}, user_to_test);
const video_obj = auth_api.getUserVideo('admin', video_to_test, true);
assert(!video_obj);
});
it('Video access - allowed', async function() {
await db_api.setVideoProperty(video_to_test, {sharingEnabled: true}, user_to_test);
const video_obj = auth_api.getUserVideo('admin', video_to_test, true);
assert(video_obj);
});
});
describe('Zip generators', function() {
it('Playlist zip generator', async function() {
const playlist = await db_api.getPlaylist(playlist_to_test, user_to_test);
assert(playlist);
const playlist_files_to_download = [];
for (let i = 0; i < playlist['uids'].length; i++) {
const uid = playlist['uids'][i];
const playlist_file = await db_api.getVideo(uid, user_to_test);
playlist_files_to_download.push(playlist_file);
}
const zip_path = await utils.createContainerZipFile(playlist, playlist_files_to_download);
const zip_exists = fs.pathExistsSync(zip_path);
assert(zip_exists);
if (zip_exists) fs.unlinkSync(zip_path);
});
it('Subscription zip generator', async function() {
const sub = await subscriptions_api.getSubscription(sub_to_test, user_to_test);
const sub_videos = await db_api.getRecords('files', {sub_id: sub.id});
assert(sub);
const sub_files_to_download = [];
for (let i = 0; i < sub_videos.length; i++) {
const sub_file = sub_videos[i];
sub_files_to_download.push(sub_file);
}
const zip_path = await utils.createContainerZipFile(sub, sub_files_to_download);
const zip_exists = fs.pathExistsSync(zip_path);
assert(zip_exists);
if (zip_exists) fs.unlinkSync(zip_path);
});
});
// describe('Video player - subscription', function() {
// const sub_to_test = '';
// const video_to_test = 'ebbcfffb-d6f1-4510-ad25-d1ec82e0477e';
// it('Get video', async function() {
// const video_obj = db_api.getVideo(video_to_test, 'admin', );
// assert(video_obj);
// });
// it('Video access - disallowed', async function() {
// await db_api.setVideoProperty(video_to_test, {sharingEnabled: false}, user_to_test, sub_to_test);
// const video_obj = auth_api.getUserVideo('admin', video_to_test, true);
// assert(!video_obj);
// });
// it('Video access - allowed', async function() {
// await db_api.setVideoProperty(video_to_test, {sharingEnabled: true}, user_to_test, sub_to_test);
// const video_obj = auth_api.getUserVideo('admin', video_to_test, true);
// assert(video_obj);
// });
// });
});

View File

@@ -1,6 +1,7 @@
var fs = require('fs-extra') const fs = require('fs-extra')
var path = require('path') const path = require('path')
const config_api = require('./config'); const config_api = require('./config');
const archiver = require('archiver');
const is_windows = process.platform === 'win32'; const is_windows = process.platform === 'win32';
@@ -52,6 +53,43 @@ async function getDownloadedFilesByType(basePath, type, full_metadata = false) {
return files; return files;
} }
async function createContainerZipFile(container_obj, container_file_objs) {
const container_files_to_download = [];
for (let i = 0; i < container_file_objs.length; i++) {
const container_file_obj = container_file_objs[i];
container_files_to_download.push(container_file_obj.path);
}
return await createZipFile(path.join('appdata', container_obj.name + '.zip'), container_files_to_download);
}
async function createZipFile(zip_file_path, file_paths) {
let output = fs.createWriteStream(zip_file_path);
var archive = archiver('zip', {
gzip: true,
zlib: { level: 9 } // Sets the compression level.
});
archive.on('error', function(err) {
logger.error(err);
throw err;
});
// pipe archive data to the output file
archive.pipe(output);
for (let file_path of file_paths) {
const file_name = path.parse(file_path).base;
archive.file(file_path, {name: file_name})
}
await archive.finalize();
// wait a tiny bit for the zip to reload in fs
await wait(100);
return zip_file_path;
}
function getJSONMp4(name, customPath, openReadPerms = false) { function getJSONMp4(name, customPath, openReadPerms = false) {
var obj = null; // output var obj = null; // output
if (!customPath) customPath = config_api.getConfigItem('ytdl_video_folder_path'); if (!customPath) customPath = config_api.getConfigItem('ytdl_video_folder_path');
@@ -84,6 +122,21 @@ function getJSONMp3(name, customPath, openReadPerms = false) {
return obj; return obj;
} }
function getJSON(file_path, type) {
const ext = type === 'audio' ? '.mp3' : '.mp4';
let obj = null;
var jsonPath = removeFileExtension(file_path) + '.info.json';
var alternateJsonPath = removeFileExtension(file_path) + `${ext}.info.json`;
if (fs.existsSync(jsonPath))
{
obj = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
} else if (fs.existsSync(alternateJsonPath)) {
obj = JSON.parse(fs.readFileSync(alternateJsonPath, 'utf8'));
}
else obj = 0;
return obj;
}
function getJSONByType(type, name, customPath, openReadPerms = false) { function getJSONByType(type, name, customPath, openReadPerms = false) {
return type === 'audio' ? getJSONMp3(name, customPath, openReadPerms) : getJSONMp4(name, customPath, openReadPerms) return type === 'audio' ? getJSONMp3(name, customPath, openReadPerms) : getJSONMp4(name, customPath, openReadPerms)
} }
@@ -105,20 +158,43 @@ function getDownloadedThumbnail(name, type, customPath = null) {
return null; return null;
} }
function getExpectedFileSize(info_json) { function getDownloadedThumbnail2(file_path, type) {
if (info_json['filesize']) { const file_path_no_extension = removeFileExtension(file_path);
return info_json['filesize'];
} let jpgPath = file_path_no_extension + '.jpg';
let webpPath = file_path_no_extension + '.webp';
let pngPath = file_path_no_extension + '.png';
if (fs.existsSync(jpgPath))
return jpgPath;
else if (fs.existsSync(webpPath))
return webpPath;
else if (fs.existsSync(pngPath))
return pngPath;
else
return null;
}
function getExpectedFileSize(input_info_jsons) {
// treat single videos as arrays to have the file sizes checked/added to. makes the code cleaner
const info_jsons = Array.isArray(input_info_jsons) ? input_info_jsons : [input_info_jsons];
const formats = info_json['format_id'].split('+');
let expected_filesize = 0; let expected_filesize = 0;
formats.forEach(format_id => { info_jsons.forEach(info_json => {
if (!info_json.formats) return expected_filesize; if (info_json['filesize']) {
info_json.formats.forEach(available_format => { expected_filesize += info_json['filesize'];
if (available_format.format_id === format_id && available_format.filesize) { return;
expected_filesize += available_format.filesize; }
} const formats = info_json['format_id'].split('+');
let individual_expected_filesize = 0;
formats.forEach(format_id => {
info_json.formats.forEach(available_format => {
if (available_format.format_id === format_id && available_format.filesize) {
individual_expected_filesize += available_format.filesize;
}
});
}); });
expected_filesize += individual_expected_filesize;
}); });
return expected_filesize; return expected_filesize;
@@ -146,6 +222,28 @@ function fixVideoMetadataPerms(name, type, customPath = null) {
} }
} }
function fixVideoMetadataPerms2(file_path, type) {
if (is_windows) return;
const ext = type === 'audio' ? '.mp3' : '.mp4';
const file_path_no_extension = removeFileExtension(file_path);
const files_to_fix = [
// JSONs
file_path_no_extension + '.info.json',
file_path_no_extension + ext + '.info.json',
// Thumbnails
file_path_no_extension + '.webp',
file_path_no_extension + '.jpg'
];
for (const file of files_to_fix) {
if (!fs.existsSync(file)) continue;
fs.chmodSync(file, 0o644);
}
}
function deleteJSONFile(name, type, customPath = null) { function deleteJSONFile(name, type, customPath = null) {
if (!customPath) customPath = type === 'audio' ? config_api.getConfigItem('ytdl_audio_folder_path') if (!customPath) customPath = type === 'audio' ? config_api.getConfigItem('ytdl_audio_folder_path')
: config_api.getConfigItem('ytdl_video_folder_path'); : config_api.getConfigItem('ytdl_video_folder_path');
@@ -158,6 +256,64 @@ function deleteJSONFile(name, type, customPath = null) {
if (fs.existsSync(alternate_json_path)) fs.unlinkSync(alternate_json_path); if (fs.existsSync(alternate_json_path)) fs.unlinkSync(alternate_json_path);
} }
function deleteJSONFile2(file_path, type) {
const ext = type === 'audio' ? '.mp3' : '.mp4';
const file_path_no_extension = removeFileExtension(file_path);
let json_path = file_path_no_extension + '.info.json';
let alternate_json_path = file_path_no_extension + ext + '.info.json';
if (fs.existsSync(json_path)) fs.unlinkSync(json_path);
if (fs.existsSync(alternate_json_path)) fs.unlinkSync(alternate_json_path);
}
async function removeIDFromArchive(archive_path, id) {
let data = await fs.readFile(archive_path, {encoding: 'utf-8'});
if (!data) {
logger.error('Archive could not be found.');
return;
}
let dataArray = data.split('\n'); // convert file data in an array
const searchKeyword = id; // we are looking for a line, contains, key word id in the file
let lastIndex = -1; // let say, we have not found the keyword
for (let index=0; index<dataArray.length; index++) {
if (dataArray[index].includes(searchKeyword)) { // check if a line contains the id keyword
lastIndex = index; // found a line includes a id keyword
break;
}
}
const line = dataArray.splice(lastIndex, 1); // remove the keyword id from the data Array
// UPDATE FILE WITH NEW DATA
const updatedData = dataArray.join('\n');
await fs.writeFile(archive_path, updatedData);
if (line) return line;
if (err) throw err;
}
function durationStringToNumber(dur_str) {
if (typeof dur_str === 'number') return dur_str;
let num_sum = 0;
const dur_str_parts = dur_str.split(':');
for (let i = dur_str_parts.length-1; i >= 0; i--) {
num_sum += parseInt(dur_str_parts[i])*(60**(dur_str_parts.length-1-i));
}
return num_sum;
}
function getMatchingCategoryFiles(category, files) {
return files && files.filter(file => file.category && file.category.uid === category.uid);
}
function addUIDsToCategory(category, files) {
const files_that_match = getMatchingCategoryFiles(category, files);
category['uids'] = files_that_match.map(file => file.uid);
return files_that_match;
}
async function recFindByExt(base,ext,files,result) async function recFindByExt(base,ext,files,result)
{ {
@@ -181,6 +337,22 @@ async function recFindByExt(base,ext,files,result)
return result return result
} }
function removeFileExtension(filename) {
const filename_parts = filename.split('.');
filename_parts.splice(filename_parts.length - 1);
return filename_parts.join('.');
}
/**
* setTimeout, but its a promise.
* @param {number} ms
*/
async function wait(ms) {
await new Promise(resolve => {
setTimeout(resolve, ms);
});
}
// objects // objects
function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date, description, view_count, height, abr) { function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date, description, view_count, height, abr) {
@@ -203,12 +375,23 @@ function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, p
module.exports = { module.exports = {
getJSONMp3: getJSONMp3, getJSONMp3: getJSONMp3,
getJSONMp4: getJSONMp4, getJSONMp4: getJSONMp4,
getJSON: getJSON,
getTrueFileName: getTrueFileName, getTrueFileName: getTrueFileName,
getDownloadedThumbnail: getDownloadedThumbnail, getDownloadedThumbnail: getDownloadedThumbnail,
getDownloadedThumbnail2: getDownloadedThumbnail2,
getExpectedFileSize: getExpectedFileSize, getExpectedFileSize: getExpectedFileSize,
fixVideoMetadataPerms: fixVideoMetadataPerms, fixVideoMetadataPerms: fixVideoMetadataPerms,
fixVideoMetadataPerms2: fixVideoMetadataPerms2,
deleteJSONFile: deleteJSONFile, deleteJSONFile: deleteJSONFile,
deleteJSONFile2: deleteJSONFile2,
removeIDFromArchive, removeIDFromArchive,
getDownloadedFilesByType: getDownloadedFilesByType, getDownloadedFilesByType: getDownloadedFilesByType,
createContainerZipFile: createContainerZipFile,
durationStringToNumber: durationStringToNumber,
getMatchingCategoryFiles: getMatchingCategoryFiles,
addUIDsToCategory: addUIDsToCategory,
recFindByExt: recFindByExt, recFindByExt: recFindByExt,
removeFileExtension: removeFileExtension,
wait: wait,
File: File File: File
} }

23
chart/.helmignore Normal file
View File

@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

24
chart/Chart.yaml Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: v2
name: youtubedl-material
description: A Helm chart for Kubernetes
# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.0
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "4.2"

22
chart/templates/NOTES.txt Normal file
View File

@@ -0,0 +1,22 @@
1. Get the application URL by running these commands:
{{- if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
{{- range .paths }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
{{- end }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "youtubedl-material.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "youtubedl-material.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "youtubedl-material.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "youtubedl-material.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}

View File

@@ -0,0 +1,62 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "youtubedl-material.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "youtubedl-material.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "youtubedl-material.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "youtubedl-material.labels" -}}
helm.sh/chart: {{ include "youtubedl-material.chart" . }}
{{ include "youtubedl-material.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "youtubedl-material.selectorLabels" -}}
app.kubernetes.io/name: {{ include "youtubedl-material.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "youtubedl-material.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "youtubedl-material.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,21 @@
{{- if and .Values.persistence.appdata.enabled (not .Values.persistence.appdata.existingClaim) }}
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: {{ template "youtubedl-material.fullname" . }}-appdata
labels:
{{- include "youtubedl-material.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.persistence.appdata.accessMode | quote }}
resources:
requests:
storage: {{ .Values.persistence.appdata.size | quote }}
{{- if .Values.persistence.appdata.storageClass }}
{{- if (eq "-" .Values.persistence.appdata.storageClass) }}
storageClassName: ""
{{- else }}
storageClassName: "{{ .Values.persistence.appdata.storageClass }}"
{{- end }}
{{- end }}
{{- end -}}

View File

@@ -0,0 +1,21 @@
{{- if and .Values.persistence.audio.enabled (not .Values.persistence.audio.existingClaim) }}
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: {{ template "youtubedl-material.fullname" . }}-audio
labels:
{{- include "youtubedl-material.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.persistence.audio.accessMode | quote }}
resources:
requests:
storage: {{ .Values.persistence.audio.size | quote }}
{{- if .Values.persistence.audio.storageClass }}
{{- if (eq "-" .Values.persistence.audio.storageClass) }}
storageClassName: ""
{{- else }}
storageClassName: "{{ .Values.persistence.audio.storageClass }}"
{{- end }}
{{- end }}
{{- end -}}

View File

@@ -0,0 +1,121 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "youtubedl-material.fullname" . }}
labels:
{{- include "youtubedl-material.labels" . | nindent 4 }}
spec:
replicas: 1
selector:
matchLabels:
{{- include "youtubedl-material.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "youtubedl-material.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "youtubedl-material.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 17442
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
readinessProbe:
httpGet:
path: /
port: http
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- mountPath: /app/appdata
name: appdata
{{- if .Values.persistence.appdata.subPath }}
subPath: {{ .Values.persistence.appdata.subPath }}
{{- end }}
- mountPath: /app/audio
name: audio
{{- if .Values.persistence.audio.subPath }}
subPath: {{ .Values.persistence.audio.subPath }}
{{- end }}
- mountPath: /app/video
name: video
{{- if .Values.persistence.video.subPath }}
subPath: {{ .Values.persistence.video.subPath }}
{{- end }}
- mountPath: /app/subscriptions
name: subscriptions
{{- if .Values.persistence.subscriptions.subPath }}
subPath: {{ .Values.persistence.subscriptions.subPath }}
{{- end }}
- mountPath: /app/users
name: users
{{- if .Values.persistence.users.subPath }}
subPath: {{ .Values.persistence.users.subPath }}
{{- end }}
volumes:
- name: appdata
{{- if .Values.persistence.appdata.enabled}}
persistentVolumeClaim:
claimName: {{ if .Values.persistence.appdata.existingClaim }}{{ .Values.persistence.appdata.existingClaim }}{{- else }}{{ template "youtubedl-material.fullname" . }}-appdata{{- end }}
{{- else }}
emptyDir: {}
{{- end }}
- name: audio
{{- if .Values.persistence.audio.enabled}}
persistentVolumeClaim:
claimName: {{ if .Values.persistence.audio.existingClaim }}{{ .Values.persistence.audio.existingClaim }}{{- else }}{{ template "youtubedl-material.fullname" . }}-audio{{- end }}
{{- else }}
emptyDir: {}
{{- end }}
- name: subscriptions
{{- if .Values.persistence.subscriptions.enabled}}
persistentVolumeClaim:
claimName: {{ if .Values.persistence.subscriptions.existingClaim }}{{ .Values.persistence.subscriptions.existingClaim }}{{- else }}{{ template "youtubedl-material.fullname" . }}-subscriptions{{- end }}
{{- else }}
emptyDir: {}
{{- end }}
- name: users
{{- if .Values.persistence.users.enabled}}
persistentVolumeClaim:
claimName: {{ if .Values.persistence.users.existingClaim }}{{ .Values.persistence.users.existingClaim }}{{- else }}{{ template "youtubedl-material.fullname" . }}-users{{- end }}
{{- else }}
emptyDir: {}
{{- end }}
- name: video
{{- if .Values.persistence.video.enabled}}
persistentVolumeClaim:
claimName: {{ if .Values.persistence.video.existingClaim }}{{ .Values.persistence.video.existingClaim }}{{- else }}{{ template "youtubedl-material.fullname" . }}-video{{- end }}
{{- else }}
emptyDir: {}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -0,0 +1,41 @@
{{- if .Values.ingress.enabled -}}
{{- $fullName := include "youtubedl-material.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
{{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}
labels:
{{- include "youtubedl-material.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
backend:
serviceName: {{ $fullName }}
servicePort: {{ $svcPort }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "youtubedl-material.fullname" . }}
labels:
{{- include "youtubedl-material.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "youtubedl-material.selectorLabels" . | nindent 4 }}

View File

@@ -0,0 +1,12 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "youtubedl-material.serviceAccountName" . }}
labels:
{{- include "youtubedl-material.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,21 @@
{{- if and .Values.persistence.subscriptions.enabled (not .Values.persistence.subscriptions.existingClaim) }}
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: {{ template "youtubedl-material.fullname" . }}-subscriptions
labels:
{{- include "youtubedl-material.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.persistence.subscriptions.accessMode | quote }}
resources:
requests:
storage: {{ .Values.persistence.subscriptions.size | quote }}
{{- if .Values.persistence.subscriptions.storageClass }}
{{- if (eq "-" .Values.persistence.subscriptions.storageClass) }}
storageClassName: ""
{{- else }}
storageClassName: "{{ .Values.persistence.subscriptions.storageClass }}"
{{- end }}
{{- end }}
{{- end -}}

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "youtubedl-material.fullname" . }}-test-connection"
labels:
{{- include "youtubedl-material.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "youtubedl-material.fullname" . }}:{{ .Values.service.port }}']
restartPolicy: Never

View File

@@ -0,0 +1,21 @@
{{- if and .Values.persistence.users.enabled (not .Values.persistence.users.existingClaim) }}
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: {{ template "youtubedl-material.fullname" . }}-users
labels:
{{- include "youtubedl-material.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.persistence.users.accessMode | quote }}
resources:
requests:
storage: {{ .Values.persistence.users.size | quote }}
{{- if .Values.persistence.users.storageClass }}
{{- if (eq "-" .Values.persistence.users.storageClass) }}
storageClassName: ""
{{- else }}
storageClassName: "{{ .Values.persistence.users.storageClass }}"
{{- end }}
{{- end }}
{{- end -}}

View File

@@ -0,0 +1,21 @@
{{- if and .Values.persistence.video.enabled (not .Values.persistence.video.existingClaim) }}
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: {{ template "youtubedl-material.fullname" . }}-video
labels:
{{- include "youtubedl-material.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.persistence.video.accessMode | quote }}
resources:
requests:
storage: {{ .Values.persistence.video.size | quote }}
{{- if .Values.persistence.video.storageClass }}
{{- if (eq "-" .Values.persistence.video.storageClass) }}
storageClassName: ""
{{- else }}
storageClassName: "{{ .Values.persistence.video.storageClass }}"
{{- end }}
{{- end }}
{{- end -}}

153
chart/values.yaml Normal file
View File

@@ -0,0 +1,153 @@
# Default values for youtubedl-material.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
replicaCount: 1
image:
repository: tzahi12345/youtubedl-material
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: ""
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
# Specifies whether a service account should be created
create: true
# Annotations to add to the service account
annotations: {}
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name: ""
podAnnotations: {}
podSecurityContext: {}
# fsGroup: 2000
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
service:
type: ClusterIP
port: 17442
ingress:
enabled: false
annotations: {}
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
hosts:
- host: chart-example.local
paths: []
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little
# resources, such as Minikube. If you do want to specify resources, uncomment the following
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
persistence:
appdata:
enabled: true
## If defined, storageClassName: <storageClass>
## If set to "-", storageClassName: "", which disables dynamic provisioning
## If undefined (the default) or set to null, no storageClassName spec is
## set, choosing the default provisioner. (gp2 on AWS, standard on
## GKE, AWS & OpenStack)
##
# storageClass: "-"
## If you want to reuse an existing claim, you can pass the name of the PVC using
## the existingClaim variable
# existingClaim: your-claim
# subPath: some-subpath
accessMode: ReadWriteOnce
size: 1Gi
audio:
enabled: true
## If defined, storageClassName: <storageClass>
## If set to "-", storageClassName: "", which disables dynamic provisioning
## If undefined (the default) or set to null, no storageClassName spec is
## set, choosing the default provisioner. (gp2 on AWS, standard on
## GKE, AWS & OpenStack)
##
# storageClass: "-"
##
## If you want to reuse an existing claim, you can pass the name of the PVC using
## the existingClaim variable
# existingClaim: your-claim
# subPath: some-subpath
accessMode: ReadWriteOnce
size: 50Gi
video:
enabled: true
## If defined, storageClassName: <storageClass>
## If set to "-", storageClassName: "", which disables dynamic provisioning
## If undefined (the default) or set to null, no storageClassName spec is
## set, choosing the default provisioner. (gp2 on AWS, standard on
## GKE, AWS & OpenStack)
##
# storageClass: "-"
##
## If you want to reuse an existing claim, you can pass the name of the PVC using
## the existingClaim variable
# existingClaim: your-claim
# subPath: some-subpath
accessMode: ReadWriteOnce
size: 50Gi
subscriptions:
enabled: true
## If defined, storageClassName: <storageClass>
## If set to "-", storageClassName: "", which disables dynamic provisioning
## If undefined (the default) or set to null, no storageClassName spec is
## set, choosing the default provisioner. (gp2 on AWS, standard on
## GKE, AWS & OpenStack)
##
# storageClass: "-"
##
## If you want to reuse an existing claim, you can pass the name of the PVC using
## the existingClaim variable
# existingClaim: your-claim
# subPath: some-subpath
accessMode: ReadWriteOnce
size: 50Gi
users:
enabled: true
## If defined, storageClassName: <storageClass>
## If set to "-", storageClassName: "", which disables dynamic provisioning
## If undefined (the default) or set to null, no storageClassName spec is
## set, choosing the default provisioner. (gp2 on AWS, standard on
## GKE, AWS & OpenStack)
##
# storageClass: "-"
##
## If you want to reuse an existing claim, you can pass the name of the PVC using
## the existingClaim variable
# existingClaim: your-claim
# subPath: some-subpath
accessMode: ReadWriteOnce
size: 50Gi
nodeSelector: {}
tolerations: []
affinity: {}

View File

@@ -3,6 +3,8 @@ services:
ytdl_material: ytdl_material:
environment: environment:
ALLOW_CONFIG_MUTATIONS: 'true' ALLOW_CONFIG_MUTATIONS: 'true'
ytdl_mongodb_connection_string: 'mongodb://ytdl-mongo-db:27018'
ytdl_use_local_db: 'false'
restart: always restart: always
volumes: volumes:
- ./appdata:/app/appdata - ./appdata:/app/appdata
@@ -12,4 +14,13 @@ services:
- ./users:/app/users - ./users:/app/users
ports: ports:
- "8998:17442" - "8998:17442"
image: tzahi12345/youtubedl-material:latest image: tzahi12345/youtubedl-material:latest
ytdl-mongo-db:
image: mongo
ports:
- "27018:27017"
logging:
driver: "none"
container_name: mongo-db
volumes:
- ./db/:/data/db

323
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{ {
"name": "youtube-dl-material", "name": "youtube-dl-material",
"version": "4.1.0", "version": "4.2.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@@ -180,9 +180,9 @@
} }
}, },
"lodash": { "lodash": {
"version": "4.17.20", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true "dev": true
}, },
"semver": { "semver": {
@@ -316,6 +316,12 @@
"ms": "2.1.2" "ms": "2.1.2"
} }
}, },
"ini": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
"integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
"dev": true
},
"resolve": { "resolve": {
"version": "1.18.1", "version": "1.18.1",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.18.1.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.18.1.tgz",
@@ -432,9 +438,9 @@
} }
}, },
"lodash": { "lodash": {
"version": "4.17.20", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true "dev": true
}, },
"semver": { "semver": {
@@ -705,9 +711,9 @@
}, },
"dependencies": { "dependencies": {
"lodash": { "lodash": {
"version": "4.17.20", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true "dev": true
} }
} }
@@ -784,9 +790,9 @@
}, },
"dependencies": { "dependencies": {
"lodash": { "lodash": {
"version": "4.17.20", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true "dev": true
} }
} }
@@ -1592,9 +1598,9 @@
}, },
"dependencies": { "dependencies": {
"lodash": { "lodash": {
"version": "4.17.20", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
} }
} }
}, },
@@ -1609,9 +1615,9 @@
}, },
"dependencies": { "dependencies": {
"lodash": { "lodash": {
"version": "4.17.20", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
} }
} }
}, },
@@ -1760,6 +1766,12 @@
"semver-intersect": "1.4.0" "semver-intersect": "1.4.0"
}, },
"dependencies": { "dependencies": {
"ini": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
"integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
"dev": true
},
"semver": { "semver": {
"version": "7.3.2", "version": "7.3.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz",
@@ -2616,15 +2628,6 @@
"tweetnacl": "^0.14.3" "tweetnacl": "^0.14.3"
} }
}, },
"better-assert": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/better-assert/-/better-assert-1.0.2.tgz",
"integrity": "sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=",
"dev": true,
"requires": {
"callsite": "1.0.0"
}
},
"big.js": { "big.js": {
"version": "5.2.2", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
@@ -3059,12 +3062,6 @@
"caller-callsite": "^2.0.0" "caller-callsite": "^2.0.0"
} }
}, },
"callsite": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz",
"integrity": "sha1-KAOY5dZkvXQDi28JBRU+borxvCA=",
"dev": true
},
"callsites": { "callsites": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz",
@@ -4513,24 +4510,24 @@
"dev": true "dev": true
}, },
"elliptic": { "elliptic": {
"version": "6.5.3", "version": "6.5.4",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz",
"integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==", "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==",
"dev": true, "dev": true,
"requires": { "requires": {
"bn.js": "^4.4.0", "bn.js": "^4.11.9",
"brorand": "^1.0.1", "brorand": "^1.1.0",
"hash.js": "^1.0.0", "hash.js": "^1.0.0",
"hmac-drbg": "^1.0.0", "hmac-drbg": "^1.0.1",
"inherits": "^2.0.1", "inherits": "^2.0.4",
"minimalistic-assert": "^1.0.0", "minimalistic-assert": "^1.0.1",
"minimalistic-crypto-utils": "^1.0.0" "minimalistic-crypto-utils": "^1.0.1"
}, },
"dependencies": { "dependencies": {
"bn.js": { "bn.js": {
"version": "4.11.9", "version": "4.12.0",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.9.tgz", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
"integrity": "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==", "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
"dev": true "dev": true
} }
} }
@@ -4582,37 +4579,37 @@
} }
}, },
"engine.io": { "engine.io": {
"version": "3.4.2", "version": "3.5.0",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-3.4.2.tgz", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-3.5.0.tgz",
"integrity": "sha512-b4Q85dFkGw+TqgytGPrGgACRUhsdKc9S9ErRAXpPGy/CXKs4tYoHDkvIRdsseAF7NjfVwjRFIn6KTnbw7LwJZg==", "integrity": "sha512-21HlvPUKaitDGE4GXNtQ7PLP0Sz4aWLddMPw2VTyFz1FVZqu/kZsJUO8WNpKuE/OCL7nkfRaOui2ZCJloGznGA==",
"dev": true, "dev": true,
"requires": { "requires": {
"accepts": "~1.3.4", "accepts": "~1.3.4",
"base64id": "2.0.0", "base64id": "2.0.0",
"cookie": "0.3.1", "cookie": "~0.4.1",
"debug": "~4.1.0", "debug": "~4.1.0",
"engine.io-parser": "~2.2.0", "engine.io-parser": "~2.2.0",
"ws": "^7.1.2" "ws": "~7.4.2"
}, },
"dependencies": { "dependencies": {
"cookie": { "cookie": {
"version": "0.3.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
"integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=", "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==",
"dev": true "dev": true
}, },
"ws": { "ws": {
"version": "7.4.1", "version": "7.4.5",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.1.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.5.tgz",
"integrity": "sha512-pTsP8UAfhy3sk1lSk/O/s4tjD0CRwvMnzvwr4OKGX7ZvqZtUyx4KIJB5JWbkykPoc55tixMGgTNoh3k4FkNGFQ==", "integrity": "sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g==",
"dev": true "dev": true
} }
} }
}, },
"engine.io-client": { "engine.io-client": {
"version": "3.4.4", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-3.4.4.tgz", "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-3.5.2.tgz",
"integrity": "sha512-iU4CRr38Fecj8HoZEnFtm2EiKGbYZcPn3cHxqNGl/tmdWRf60KhK+9vE0JeSjgnlS/0oynEfLgKbT9ALpim0sQ==", "integrity": "sha512-QEqIp+gJ/kMHeUun7f5Vv3bteRHppHH/FMBQX/esFj/fuYfjyUKWGMo3VCvIP/V8bE9KcjHmRZrhIz2Z9oNsDA==",
"dev": true, "dev": true,
"requires": { "requires": {
"component-emitter": "~1.3.0", "component-emitter": "~1.3.0",
@@ -4623,8 +4620,8 @@
"indexof": "0.0.1", "indexof": "0.0.1",
"parseqs": "0.0.6", "parseqs": "0.0.6",
"parseuri": "0.0.6", "parseuri": "0.0.6",
"ws": "~6.1.0", "ws": "~7.4.2",
"xmlhttprequest-ssl": "~1.5.4", "xmlhttprequest-ssl": "~1.6.2",
"yeast": "0.1.2" "yeast": "0.1.2"
}, },
"dependencies": { "dependencies": {
@@ -4643,26 +4640,11 @@
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
"dev": true "dev": true
}, },
"parseqs": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.6.tgz",
"integrity": "sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w==",
"dev": true
},
"parseuri": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.6.tgz",
"integrity": "sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow==",
"dev": true
},
"ws": { "ws": {
"version": "6.1.4", "version": "7.4.5",
"resolved": "https://registry.npmjs.org/ws/-/ws-6.1.4.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.5.tgz",
"integrity": "sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA==", "integrity": "sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g==",
"dev": true, "dev": true
"requires": {
"async-limiter": "~1.0.0"
}
} }
} }
}, },
@@ -5930,9 +5912,9 @@
} }
}, },
"hosted-git-info": { "hosted-git-info": {
"version": "3.0.7", "version": "3.0.8",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-3.0.7.tgz", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-3.0.8.tgz",
"integrity": "sha512-fWqc0IcuXs+BmE9orLDyVykAG9GJtGLGuZAAqgcckPgv5xad4AcXGIv8galtQvlwutxSlaMcdw7BUtq2EIvqCQ==", "integrity": "sha512-aXpmwoOhRBrw6X3j0h5RloK4x1OzsxMPyxqIHyNfSe2pypkVTZFpEiRoSipPEPlMrh0HW/XsjkJ5WgnCirpNUw==",
"dev": true, "dev": true,
"requires": { "requires": {
"lru-cache": "^6.0.0" "lru-cache": "^6.0.0"
@@ -6338,9 +6320,9 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
}, },
"ini": { "ini": {
"version": "1.3.5", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"dev": true "dev": true
}, },
"inquirer": { "inquirer": {
@@ -6405,9 +6387,9 @@
"dev": true "dev": true
}, },
"lodash": { "lodash": {
"version": "4.17.20", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true "dev": true
}, },
"supports-color": { "supports-color": {
@@ -7498,9 +7480,9 @@
} }
}, },
"lodash": { "lodash": {
"version": "4.17.15", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
}, },
"lodash.memoize": { "lodash.memoize": {
"version": "4.1.2", "version": "4.1.2",
@@ -7719,9 +7701,9 @@
} }
}, },
"ssri": { "ssri": {
"version": "6.0.1", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.1.tgz", "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz",
"integrity": "sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA==", "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==",
"dev": true, "dev": true,
"requires": { "requires": {
"figgy-pudding": "^3.5.1" "figgy-pudding": "^3.5.1"
@@ -7775,6 +7757,11 @@
} }
} }
}, },
"material-icons": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/material-icons/-/material-icons-0.5.4.tgz",
"integrity": "sha512-5ycazkNmIOtV78Ff3WgvxQESoJuujdRm0cNbf18fmyJN20jHyqp9rpwi4EfQyGimag0ZLElxtVg3H9enIKdOOw=="
},
"md5.js": { "md5.js": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
@@ -8284,9 +8271,9 @@
}, },
"dependencies": { "dependencies": {
"hosted-git-info": { "hosted-git-info": {
"version": "2.8.8", "version": "2.8.9",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
"integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==", "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
"dev": true "dev": true
} }
} }
@@ -8435,9 +8422,9 @@
}, },
"dependencies": { "dependencies": {
"hosted-git-info": { "hosted-git-info": {
"version": "2.8.8", "version": "2.8.9",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
"integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==", "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
"dev": true "dev": true
}, },
"lru-cache": { "lru-cache": {
@@ -8511,12 +8498,6 @@
"integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
"dev": true "dev": true
}, },
"object-component": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/object-component/-/object-component-0.0.3.tgz",
"integrity": "sha1-8MaapQ78lbhmwYb0AKM3acsvEpE=",
"dev": true
},
"object-copy": { "object-copy": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz",
@@ -8937,9 +8918,9 @@
} }
}, },
"hosted-git-info": { "hosted-git-info": {
"version": "2.8.8", "version": "2.8.9",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
"integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==", "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
"dev": true "dev": true
}, },
"lru-cache": { "lru-cache": {
@@ -9003,9 +8984,9 @@
} }
}, },
"ssri": { "ssri": {
"version": "6.0.1", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.1.tgz", "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz",
"integrity": "sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA==", "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==",
"dev": true, "dev": true,
"requires": { "requires": {
"figgy-pudding": "^3.5.1" "figgy-pudding": "^3.5.1"
@@ -9139,22 +9120,16 @@
} }
}, },
"parseqs": { "parseqs": {
"version": "0.0.5", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.5.tgz", "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.6.tgz",
"integrity": "sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=", "integrity": "sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w==",
"dev": true, "dev": true
"requires": {
"better-assert": "~1.0.0"
}
}, },
"parseuri": { "parseuri": {
"version": "0.0.5", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.5.tgz", "resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.6.tgz",
"integrity": "sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=", "integrity": "sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow==",
"dev": true, "dev": true
"requires": {
"better-assert": "~1.0.0"
}
}, },
"parseurl": { "parseurl": {
"version": "1.3.3", "version": "1.3.3",
@@ -11672,16 +11647,16 @@
} }
}, },
"socket.io": { "socket.io": {
"version": "2.3.0", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-2.3.0.tgz", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-2.4.1.tgz",
"integrity": "sha512-2A892lrj0GcgR/9Qk81EaY2gYhCBxurV0PfmmESO6p27QPrUK1J3zdns+5QPqvUYK2q657nSj0guoIil9+7eFg==", "integrity": "sha512-Si18v0mMXGAqLqCVpTxBa8MGqriHGQh8ccEOhmsmNS3thNCGBwO8WGrwMibANsWtQQ5NStdZwHqZR3naJVFc3w==",
"dev": true, "dev": true,
"requires": { "requires": {
"debug": "~4.1.0", "debug": "~4.1.0",
"engine.io": "~3.4.0", "engine.io": "~3.5.0",
"has-binary2": "~1.0.2", "has-binary2": "~1.0.2",
"socket.io-adapter": "~1.1.0", "socket.io-adapter": "~1.1.0",
"socket.io-client": "2.3.0", "socket.io-client": "2.4.0",
"socket.io-parser": "~3.4.0" "socket.io-parser": "~3.4.0"
} }
}, },
@@ -11692,38 +11667,32 @@
"dev": true "dev": true
}, },
"socket.io-client": { "socket.io-client": {
"version": "2.3.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-2.3.0.tgz", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-2.4.0.tgz",
"integrity": "sha512-cEQQf24gET3rfhxZ2jJ5xzAOo/xhZwK+mOqtGRg5IowZsMgwvHwnf/mCRapAAkadhM26y+iydgwsXGObBB5ZdA==", "integrity": "sha512-M6xhnKQHuuZd4Ba9vltCLT9oa+YvTsP8j9NcEiLElfIg8KeYPyhWOes6x4t+LTAC8enQbE/995AdTem2uNyKKQ==",
"dev": true, "dev": true,
"requires": { "requires": {
"backo2": "1.0.2", "backo2": "1.0.2",
"base64-arraybuffer": "0.1.5",
"component-bind": "1.0.0", "component-bind": "1.0.0",
"component-emitter": "1.2.1", "component-emitter": "~1.3.0",
"debug": "~4.1.0", "debug": "~3.1.0",
"engine.io-client": "~3.4.0", "engine.io-client": "~3.5.0",
"has-binary2": "~1.0.2", "has-binary2": "~1.0.2",
"has-cors": "1.1.0",
"indexof": "0.0.1", "indexof": "0.0.1",
"object-component": "0.0.3", "parseqs": "0.0.6",
"parseqs": "0.0.5", "parseuri": "0.0.6",
"parseuri": "0.0.5",
"socket.io-parser": "~3.3.0", "socket.io-parser": "~3.3.0",
"to-array": "0.1.4" "to-array": "0.1.4"
}, },
"dependencies": { "dependencies": {
"base64-arraybuffer": { "debug": {
"version": "0.1.5", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
"integrity": "sha1-c5JncZI7Whl0etZmqlzUv5xunOg=", "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
"dev": true "dev": true,
}, "requires": {
"component-emitter": { "ms": "2.0.0"
"version": "1.2.1", }
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz",
"integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=",
"dev": true
}, },
"isarray": { "isarray": {
"version": "2.0.1", "version": "2.0.1",
@@ -11738,31 +11707,14 @@
"dev": true "dev": true
}, },
"socket.io-parser": { "socket.io-parser": {
"version": "3.3.1", "version": "3.3.2",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.3.1.tgz", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.3.2.tgz",
"integrity": "sha512-1QLvVAe8dTz+mKmZ07Swxt+LAo4Y1ff50rlyoEx00TQmDFVQYPfcqGvIDJLGaBdhdNCecXtyKpD+EgKGcmmbuQ==", "integrity": "sha512-FJvDBuOALxdCI9qwRrO/Rfp9yfndRtc1jSgVgV8FDraihmSP/MLGD5PEuJrNfjALvcQ+vMDM/33AWOYP/JSjDg==",
"dev": true, "dev": true,
"requires": { "requires": {
"component-emitter": "~1.3.0", "component-emitter": "~1.3.0",
"debug": "~3.1.0", "debug": "~3.1.0",
"isarray": "2.0.1" "isarray": "2.0.1"
},
"dependencies": {
"component-emitter": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
"integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==",
"dev": true
},
"debug": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
"dev": true,
"requires": {
"ms": "2.0.0"
}
}
} }
} }
} }
@@ -12077,9 +12029,9 @@
} }
}, },
"ssri": { "ssri": {
"version": "8.0.0", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.0.tgz", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz",
"integrity": "sha512-aq/pz989nxVYwn16Tsbj1TqFpD5LLrQxHf5zaHuieFV+R0Bbr4y8qUsOA45hXT/N4/9UNXTarBjnjVmjSOVaAA==", "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==",
"dev": true, "dev": true,
"requires": { "requires": {
"minipass": "^3.1.1" "minipass": "^3.1.1"
@@ -13069,9 +13021,9 @@
} }
}, },
"url-parse": { "url-parse": {
"version": "1.4.7", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.4.7.tgz", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.1.tgz",
"integrity": "sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg==", "integrity": "sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q==",
"dev": true, "dev": true,
"requires": { "requires": {
"querystringify": "^2.1.1", "querystringify": "^2.1.1",
@@ -13791,8 +13743,7 @@
}, },
"ssri": { "ssri": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.1.tgz", "resolved": "",
"integrity": "sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA==",
"dev": true, "dev": true,
"requires": { "requires": {
"figgy-pudding": "^3.5.1" "figgy-pudding": "^3.5.1"
@@ -14533,9 +14484,9 @@
"dev": true "dev": true
}, },
"xmlhttprequest-ssl": { "xmlhttprequest-ssl": {
"version": "1.5.5", "version": "1.6.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.6.2.tgz",
"integrity": "sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=", "integrity": "sha512-tYOaldF/0BLfKuoA39QMwD4j2m8lq4DIncqj1yuNELX4vz9+z/ieG/vwmctjJce+boFHXstqhWnHSxc4W8f4qg==",
"dev": true "dev": true
}, },
"xtend": { "xtend": {

View File

@@ -36,6 +36,7 @@
"file-saver": "^2.0.2", "file-saver": "^2.0.2",
"filesize": "^6.1.0", "filesize": "^6.1.0",
"fingerprintjs2": "^2.1.0", "fingerprintjs2": "^2.1.0",
"material-icons": "^0.5.4",
"nan": "^2.14.1", "nan": "^2.14.1",
"ng-lazyload-image": "^7.0.1", "ng-lazyload-image": "^7.0.1",
"ngx-avatar": "^4.0.0", "ngx-avatar": "^4.0.0",

View File

@@ -86,6 +86,7 @@ import { EditCategoryDialogComponent } from './dialogs/edit-category-dialog/edit
import { TwitchChatComponent } from './components/twitch-chat/twitch-chat.component'; import { TwitchChatComponent } from './components/twitch-chat/twitch-chat.component';
import { LinkifyPipe, SeeMoreComponent } from './components/see-more/see-more.component'; import { LinkifyPipe, SeeMoreComponent } from './components/see-more/see-more.component';
import { H401Interceptor } from './http.interceptor'; import { H401Interceptor } from './http.interceptor';
import { ConcurrentStreamComponent } from './components/concurrent-stream/concurrent-stream.component';
registerLocaleData(es, 'es'); registerLocaleData(es, 'es');
@@ -134,7 +135,8 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
CustomPlaylistsComponent, CustomPlaylistsComponent,
EditCategoryDialogComponent, EditCategoryDialogComponent,
TwitchChatComponent, TwitchChatComponent,
SeeMoreComponent SeeMoreComponent,
ConcurrentStreamComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,

View File

@@ -0,0 +1,6 @@
<div class="buttons-container">
<button (click)="startWatching()" *ngIf="!watch_together_clicked" mat-flat-button>Watch together</button>
<button (click)="startServer()" *ngIf="watch_together_clicked && !started && server_mode && server_already_exists === false" mat-flat-button>Start stream</button>
<button (click)="startClient()" *ngIf="watch_together_clicked && !started && server_already_exists === true" mat-flat-button>Join stream</button>
<button style="margin-left: 10px;" (click)="stop()" *ngIf="watch_together_clicked" mat-flat-button>Stop</button>
</div>

View File

@@ -0,0 +1,7 @@
.buttons-container {
display: flex;
align-items: center;
justify-content: center;
margin-top: 15px;
margin-bottom: 15px;
}

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ConcurrentStreamComponent } from './concurrent-stream.component';
describe('ConcurrentStreamComponent', () => {
let component: ConcurrentStreamComponent;
let fixture: ComponentFixture<ConcurrentStreamComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ConcurrentStreamComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ConcurrentStreamComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,140 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { PostsService } from 'app/posts.services';
@Component({
selector: 'app-concurrent-stream',
templateUrl: './concurrent-stream.component.html',
styleUrls: ['./concurrent-stream.component.scss']
})
export class ConcurrentStreamComponent implements OnInit {
@Input() server_mode = false;
@Input() playback_timestamp;
@Input() playing;
@Input() uid;
@Output() setPlaybackTimestamp = new EventEmitter<any>();
@Output() togglePlayback = new EventEmitter<boolean>();
@Output() setPlaybackRate = new EventEmitter<number>();
started = false;
server_started = false;
watch_together_clicked = false;
server_already_exists = null;
check_timeout: any;
update_timeout: any;
PLAYBACK_TIMESTAMP_DIFFERENCE_THRESHOLD_PLAYBACK_MODIFICATION = 0.5;
PLAYBACK_TIMESTAMP_DIFFERENCE_THRESHOLD_SKIP = 2;
PLAYBACK_MODIFIER = 0.1;
playback_rate_modified = false;
constructor(private postsService: PostsService) { }
// flow: click start watching -> check for available stream to enable join button and if user, display "start stream"
// users who join a stream will send continuous requests for info on playback
ngOnInit(): void {
}
startServer() {
this.started = true;
this.server_started = true;
this.update_timeout = setInterval(() => {
this.updateStream();
}, 1000);
}
updateStream() {
this.postsService.updateConcurrentStream(this.uid, this.playback_timestamp, Date.now()/1000, this.playing).subscribe(res => {
});
}
startClient() {
this.started = true;
}
checkStream() {
if (this.server_started) { return; }
const current_playback_timestamp = this.playback_timestamp;
const current_unix_timestamp = Date.now()/1000;
this.postsService.checkConcurrentStream(this.uid).subscribe(res => {
const stream = res['stream'];
if (!stream) {
this.server_already_exists = false;
return;
}
this.server_already_exists = true;
// check whether client has joined the stream
if (!this.started) { return; }
if (!stream['playing'] && this.playing) {
// tell client to pause and set the timestamp to sync
this.togglePlayback.emit(false);
this.setPlaybackTimestamp.emit(stream['playback_timestamp']);
} else if (stream['playing']) {
// sync unpause state
if (!this.playing) { this.togglePlayback.emit(true); }
// sync time
const zeroed_local_unix_timestamp = current_unix_timestamp - current_playback_timestamp;
const zeroed_server_unix_timestamp = stream['unix_timestamp'] - stream['playback_timestamp'];
const seconds_behind_locally = zeroed_local_unix_timestamp - zeroed_server_unix_timestamp;
if (Math.abs(seconds_behind_locally) > this.PLAYBACK_TIMESTAMP_DIFFERENCE_THRESHOLD_SKIP) {
// skip to playback timestamp because the difference is too high
this.setPlaybackTimestamp.emit(this.playback_timestamp + seconds_behind_locally + 0.3);
this.playback_rate_modified = false;
} else if (!this.playback_rate_modified && Math.abs(seconds_behind_locally) > this.PLAYBACK_TIMESTAMP_DIFFERENCE_THRESHOLD_PLAYBACK_MODIFICATION) {
// increase playback speed to avoid skipping
let seconds_to_wait = (Math.abs(seconds_behind_locally)/this.PLAYBACK_MODIFIER);
seconds_to_wait += 0.3/this.PLAYBACK_MODIFIER;
this.playback_rate_modified = true;
if (seconds_behind_locally > 0) {
// increase speed
this.setPlaybackRate.emit(1 + this.PLAYBACK_MODIFIER);
setTimeout(() => {
this.setPlaybackRate.emit(1);
this.playback_rate_modified = false;
}, seconds_to_wait * 1000);
} else {
// decrease speed
this.setPlaybackRate.emit(1 - this.PLAYBACK_MODIFIER);
setTimeout(() => {
this.setPlaybackRate.emit(1);
this.playback_rate_modified = false;
}, seconds_to_wait * 1000);
}
}
}
});
}
startWatching() {
this.watch_together_clicked = true;
this.check_timeout = setInterval(() => {
this.checkStream();
}, 1000);
}
stop() {
if (this.check_timeout) { clearInterval(this.check_timeout); }
if (this.update_timeout) { clearInterval(this.update_timeout); }
this.started = false;
this.server_started = false;
this.watch_together_clicked = false;
}
}

View File

@@ -53,16 +53,15 @@ export class CustomPlaylistsComponent implements OnInit {
goToPlaylist(info_obj) { goToPlaylist(info_obj) {
const playlist = info_obj.file; const playlist = info_obj.file;
const playlistID = playlist.id; const playlistID = playlist.id;
const type = playlist.type;
if (playlist) { if (playlist) {
if (this.postsService.config['Extra']['download_only_mode']) { if (this.postsService.config['Extra']['download_only_mode']) {
this.downloading_content[type][playlistID] = true; this.downloadPlaylist(playlist.id, playlist.name);
this.downloadPlaylist(playlist.fileNames, type, playlist.name, playlistID);
} else { } else {
localStorage.setItem('player_navigator', this.router.url); localStorage.setItem('player_navigator', this.router.url);
const fileNames = playlist.fileNames; const routeParams = {playlist_id: playlistID};
this.router.navigate(['/player', {fileNames: fileNames.join('|nvr|'), type: type, id: playlistID, uid: playlistID, auto: playlist.auto}]); if (playlist.auto) { routeParams['auto'] = playlist.auto; }
this.router.navigate(['/player', routeParams]);
} }
} else { } else {
// playlist not found // playlist not found
@@ -70,11 +69,12 @@ export class CustomPlaylistsComponent implements OnInit {
} }
} }
downloadPlaylist(fileNames, type, zipName = null, playlistID = null) { downloadPlaylist(playlist_id, playlist_name) {
this.postsService.downloadFileFromServer(fileNames, type, zipName).subscribe(res => { this.downloading_content[playlist_id] = true;
if (playlistID) { this.downloading_content[type][playlistID] = false }; this.postsService.downloadPlaylistFromServer(playlist_id).subscribe(res => {
const blob: Blob = res; this.downloading_content[playlist_id] = false;
saveAs(blob, zipName + '.zip'); const blob: any = res;
saveAs(blob, playlist_name + '.zip');
}); });
} }
@@ -97,7 +97,7 @@ export class CustomPlaylistsComponent implements OnInit {
const index = args.index; const index = args.index;
const dialogRef = this.dialog.open(ModifyPlaylistComponent, { const dialogRef = this.dialog.open(ModifyPlaylistComponent, {
data: { data: {
playlist: playlist, playlist_id: playlist.id,
width: '65vw' width: '65vw'
} }
}); });

View File

@@ -1,21 +1,21 @@
<div style="padding: 20px;"> <div style="padding: 20px;">
<div *ngFor="let session_downloads of downloads | keyvalue"> <div *ngFor="let session_downloads of downloads">
<ng-container *ngIf="keys(session_downloads.value).length > 0"> <ng-container *ngIf="keys(session_downloads).length > 2">
<mat-card style="padding-bottom: 30px; margin-bottom: 15px;"> <mat-card style="padding-bottom: 30px; margin-bottom: 15px;">
<h4 style="text-align: center;"><ng-container i18n="Session ID">Session ID:</ng-container>&nbsp;{{session_downloads.key}} <h4 style="text-align: center;"><ng-container i18n="Session ID">Session ID:</ng-container>&nbsp;{{session_downloads['session_id']}}
<span *ngIf="session_downloads.key === postsService.session_id">&nbsp;<ng-container i18n="Current session">(current)</ng-container></span> <span *ngIf="session_downloads['session_id'] === postsService.session_id">&nbsp;<ng-container i18n="Current session">(current)</ng-container></span>
</h4> </h4>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div *ngFor="let download of session_downloads.value | keyvalue: sort_downloads; let i = index;" class="col-12 my-1"> <div *ngFor="let download of session_downloads | keyvalue: sort_downloads; let i = index;" class="col-12 my-1">
<mat-card *ngIf="download.value" class="mat-elevation-z3"> <mat-card *ngIf="download.key !== 'session_id' && download.key !== '_id' && download.value" class="mat-elevation-z3">
<app-download-item [download]="download.value" [queueNumber]="i+1" (cancelDownload)="clearDownload(session_downloads.key, download.value.uid)"></app-download-item> <app-download-item [download]="download.value" [queueNumber]="i+1" (cancelDownload)="clearDownload(session_downloads['session_id'], download.value.uid)"></app-download-item>
</mat-card> </mat-card>
</div> </div>
</div> </div>
</div> </div>
<div> <div>
<button style="top: 15px;" (click)="clearDownloads(session_downloads.key)" mat-stroked-button color="warn"><ng-container i18n="clear all downloads action button">Clear all downloads</ng-container></button> <button style="top: 15px;" (click)="clearDownloads(session_downloads['session_id'])" mat-stroked-button color="warn"><ng-container i18n="clear all downloads action button">Clear all downloads</ng-container></button>
</div> </div>
</mat-card> </mat-card>
</ng-container> </ng-container>

View File

@@ -35,7 +35,7 @@ import { Router } from '@angular/router';
export class DownloadsComponent implements OnInit, OnDestroy { export class DownloadsComponent implements OnInit, OnDestroy {
downloads_check_interval = 1000; downloads_check_interval = 1000;
downloads = {}; downloads = [];
interval_id = null; interval_id = null;
keys = Object.keys; keys = Object.keys;
@@ -137,6 +137,7 @@ export class DownloadsComponent implements OnInit, OnDestroy {
this.downloads[session_id] = session_downloads_by_id; this.downloads[session_id] = session_downloads_by_id;
} else { } else {
for (let j = 0; j < session_download_ids.length; j++) { for (let j = 0; j < session_download_ids.length; j++) {
if (session_download_ids[j] === 'session_id' || session_download_ids[j] === '_id') continue;
const download_id = session_download_ids[j]; const download_id = session_download_ids[j];
const download = new_downloads_by_session[session_id][download_id] const download = new_downloads_by_session[session_id][download_id]
if (!this.downloads[session_id][download_id]) { if (!this.downloads[session_id][download_id]) {
@@ -156,11 +157,10 @@ export class DownloadsComponent implements OnInit, OnDestroy {
downloadsValid() { downloadsValid() {
let valid = false; let valid = false;
const keys = this.keys(this.downloads); for (let i = 0; i < this.downloads.length; i++) {
for (let i = 0; i < keys.length; i++) { const session_downloads = this.downloads[i];
const key = keys[i]; if (!session_downloads) continue;
const value = this.downloads[key]; if (this.keys(session_downloads).length > 2) {
if (this.keys(value).length > 0) {
valid = true; valid = true;
break; break;
} }

View File

@@ -5,7 +5,7 @@
<mat-list-item role="listitem" *ngFor="let permission of available_permissions"> <mat-list-item role="listitem" *ngFor="let permission of available_permissions">
<h3 matLine>{{permissionToLabel[permission] ? permissionToLabel[permission] : permission}}</h3> <h3 matLine>{{permissionToLabel[permission] ? permissionToLabel[permission] : permission}}</h3>
<span matLine> <span matLine>
<mat-radio-group [disabled]="permission === 'settings' && role.name === 'admin'" (change)="changeRolePermissions($event, permission, permissions[permission])" [(ngModel)]="permissions[permission]" [attr.aria-label]="'Give role permission for ' + permission"> <mat-radio-group [disabled]="permission === 'settings' && role.key === 'admin'" (change)="changeRolePermissions($event, permission, permissions[permission])" [(ngModel)]="permissions[permission]" [attr.aria-label]="'Give role permission for ' + permission">
<mat-radio-button value="yes"><ng-container i18n="Yes">Yes</ng-container></mat-radio-button> <mat-radio-button value="yes"><ng-container i18n="Yes">Yes</ng-container></mat-radio-button>
<mat-radio-button value="no"><ng-container i18n="No">No</ng-container></mat-radio-button> <mat-radio-button value="no"><ng-container i18n="No">No</ng-container></mat-radio-button>
</mat-radio-group> </mat-radio-group>

View File

@@ -47,7 +47,7 @@ export class ManageRoleComponent implements OnInit {
} }
changeRolePermissions(change, permission) { changeRolePermissions(change, permission) {
this.postsService.setRolePermission(this.role.name, permission, change.value).subscribe(res => { this.postsService.setRolePermission(this.role.key, permission, change.value).subscribe(res => {
if (res['success']) { if (res['success']) {
} else { } else {

View File

@@ -94,7 +94,7 @@
</div> </div>
<button color="primary" [matMenuTriggerFor]="edit_roles_menu" class="edit-role" mat-raised-button><ng-container i18n="Edit role">Edit Role</ng-container></button> <button color="primary" [matMenuTriggerFor]="edit_roles_menu" class="edit-role" mat-raised-button><ng-container i18n="Edit role">Edit Role</ng-container></button>
<mat-menu #edit_roles_menu="matMenu"> <mat-menu #edit_roles_menu="matMenu">
<button (click)="openModifyRole(role)" mat-menu-item *ngFor="let role of roles">{{role.name}}</button> <button (click)="openModifyRole(role)" mat-menu-item *ngFor="let role of roles">{{role.key}}</button>
</mat-menu> </mat-menu>
</div> </div>

View File

@@ -78,16 +78,7 @@ export class ModifyUsersComponent implements OnInit, AfterViewInit {
getRoles() { getRoles() {
this.postsService.getRoles().subscribe(res => { this.postsService.getRoles().subscribe(res => {
this.roles = []; this.roles = res['roles'];
const roles = res['roles'];
const role_names = Object.keys(roles);
for (let i = 0; i < role_names.length; i++) {
const role_name = role_names[i];
this.roles.push({
name: role_name,
permissions: roles[role_name]['permissions']
});
}
}); });
} }

View File

@@ -127,12 +127,11 @@ export class RecentVideosComponent implements OnInit {
this.normal_files_received = false; this.normal_files_received = false;
this.postsService.getAllFiles().subscribe(res => { this.postsService.getAllFiles().subscribe(res => {
this.files = res['files']; this.files = res['files'];
this.files.sort(this.sortFiles);
for (let i = 0; i < this.files.length; i++) { for (let i = 0; i < this.files.length; i++) {
const file = this.files[i]; const file = this.files[i];
file.duration = typeof file.duration !== 'string' ? file.duration : this.durationStringToNumber(file.duration); file.duration = typeof file.duration !== 'string' ? file.duration : this.durationStringToNumber(file.duration);
file.index = i;
} }
this.files.sort(this.sortFiles);
if (this.search_mode) { if (this.search_mode) {
this.filterFiles(this.search_text); this.filterFiles(this.search_text);
} else { } else {
@@ -167,15 +166,14 @@ export class RecentVideosComponent implements OnInit {
const sub = this.postsService.getSubscriptionByID(file.sub_id); const sub = this.postsService.getSubscriptionByID(file.sub_id);
if (sub.streamingOnly) { if (sub.streamingOnly) {
// streaming only mode subscriptions // streaming only mode subscriptions
!new_tab ? this.router.navigate(['/player', {name: file.id, // !new_tab ? this.router.navigate(['/player', {name: file.id,
url: file.requested_formats ? file.requested_formats[0].url : file.url}]) // url: file.requested_formats ? file.requested_formats[0].url : file.url}])
: window.open(`/#/player;name=${file.id};url=${file.requested_formats ? file.requested_formats[0].url : file.url}`); // : window.open(`/#/player;name=${file.id};url=${file.requested_formats ? file.requested_formats[0].url : file.url}`);
} else { } else {
// normal subscriptions // normal subscriptions
!new_tab ? this.router.navigate(['/player', {fileNames: file.id, !new_tab ? this.router.navigate(['/player', {uid: file.uid,
type: file.isAudio ? 'audio' : 'video', subscriptionName: sub.name, type: file.isAudio ? 'audio' : 'video', sub_id: sub.id}])
subPlaylist: sub.isPlaylist}]) : window.open(`/#/player;uid=${file.uid};type=${file.isAudio ? 'audio' : 'video'};sub_id=${sub.id}`);
: window.open(`/#/player;fileNames=${file.id};type=${file.isAudio ? 'audio' : 'video'};subscriptionName=${sub.name};subPlaylist=${sub.isPlaylist}`);
} }
} else { } else {
// normal files // normal files
@@ -202,8 +200,7 @@ export class RecentVideosComponent implements OnInit {
const type = file.isAudio ? 'audio' : 'video'; const type = file.isAudio ? 'audio' : 'video';
const ext = type === 'audio' ? '.mp3' : '.mp4' const ext = type === 'audio' ? '.mp3' : '.mp4'
const sub = this.postsService.getSubscriptionByID(file.sub_id); const sub = this.postsService.getSubscriptionByID(file.sub_id);
this.postsService.downloadFileFromServer(file.id, type, null, null, sub.name, sub.isPlaylist, this.postsService.downloadFileFromServer(file.uid).subscribe(res => {
this.postsService.user ? this.postsService.user.uid : null, null).subscribe(res => {
const blob: Blob = res; const blob: Blob = res;
saveAs(blob, file.id + ext); saveAs(blob, file.id + ext);
}, err => { }, err => {
@@ -216,14 +213,14 @@ export class RecentVideosComponent implements OnInit {
const ext = type === 'audio' ? '.mp3' : '.mp4' const ext = type === 'audio' ? '.mp3' : '.mp4'
const name = file.id; const name = file.id;
this.downloading_content[type][name] = true; this.downloading_content[type][name] = true;
this.postsService.downloadFileFromServer(name, type).subscribe(res => { this.postsService.downloadFileFromServer(file.uid).subscribe(res => {
this.downloading_content[type][name] = false; this.downloading_content[type][name] = false;
const blob: Blob = res; const blob: Blob = res;
saveAs(blob, decodeURIComponent(name) + ext); saveAs(blob, decodeURIComponent(name) + ext);
if (!this.postsService.config.Extra.file_manager_enabled) { if (!this.postsService.config.Extra.file_manager_enabled) {
// tell server to delete the file once downloaded // tell server to delete the file once downloaded
this.postsService.deleteFile(name, type).subscribe(delRes => { this.postsService.deleteFile(file.uid).subscribe(delRes => {
// reload mp4s // reload mp4s
this.getAllFiles(); this.getAllFiles();
}); });
@@ -239,19 +236,17 @@ export class RecentVideosComponent implements OnInit {
const blacklistMode = args.blacklistMode; const blacklistMode = args.blacklistMode;
if (file.sub_id) { if (file.sub_id) {
this.deleteSubscriptionFile(file, index, blacklistMode); this.deleteSubscriptionFile(file, blacklistMode);
} else { } else {
this.deleteNormalFile(file, index, blacklistMode); this.deleteNormalFile(file, blacklistMode);
} }
} }
deleteNormalFile(file, index, blacklistMode = false) { deleteNormalFile(file, blacklistMode = false) {
this.postsService.deleteFile(file.uid, file.isAudio ? 'audio' : 'video', blacklistMode).subscribe(result => { this.postsService.deleteFile(file.uid, blacklistMode).subscribe(result => {
if (result) { if (result) {
this.postsService.openSnackBar('Delete success!', 'OK.'); this.postsService.openSnackBar('Delete success!', 'OK.');
this.files.splice(file.index, 1); this.removeFileCard(file);
for (let i = 0; i < this.files.length; i++) { this.files[i].index = i }
this.filterByProperty(this.filterProperty['property']);
} else { } else {
this.postsService.openSnackBar('Delete failed!', 'OK.'); this.postsService.openSnackBar('Delete failed!', 'OK.');
} }
@@ -260,30 +255,39 @@ export class RecentVideosComponent implements OnInit {
}); });
} }
deleteSubscriptionFile(file, index, blacklistMode = false) { deleteSubscriptionFile(file, blacklistMode = false) {
if (blacklistMode) { if (blacklistMode) {
this.deleteForever(file, index); this.deleteForever(file);
} else { } else {
this.deleteAndRedownload(file, index); this.deleteAndRedownload(file);
} }
} }
deleteAndRedownload(file, index) { deleteAndRedownload(file) {
const sub = this.postsService.getSubscriptionByID(file.sub_id); const sub = this.postsService.getSubscriptionByID(file.sub_id);
this.postsService.deleteSubscriptionFile(sub, file.id, false, file.uid).subscribe(res => { this.postsService.deleteSubscriptionFile(sub, file.id, false, file.uid).subscribe(res => {
this.postsService.openSnackBar(`Successfully deleted file: '${file.id}'`); this.postsService.openSnackBar(`Successfully deleted file: '${file.id}'`);
this.files.splice(index, 1); this.removeFileCard(file);
}); });
} }
deleteForever(file, index) { deleteForever(file) {
const sub = this.postsService.getSubscriptionByID(file.sub_id); const sub = this.postsService.getSubscriptionByID(file.sub_id);
this.postsService.deleteSubscriptionFile(sub, file.id, true, file.uid).subscribe(res => { this.postsService.deleteSubscriptionFile(sub, file.id, true, file.uid).subscribe(res => {
this.postsService.openSnackBar(`Successfully deleted file: '${file.id}'`); this.postsService.openSnackBar(`Successfully deleted file: '${file.id}'`);
this.files.splice(index, 1); this.removeFileCard(file);
}); });
} }
removeFileCard(file_to_remove) {
const index = this.files.map(e => e.uid).indexOf(file_to_remove.uid);
this.files.splice(index, 1);
if (this.search_mode) {
this.filterFiles(this.search_text);
}
this.filterByProperty(this.filterProperty['property']);
}
// sorting and filtering // sorting and filtering
sortFiles(a, b) { sortFiles(a, b) {

View File

@@ -110,7 +110,7 @@
position: absolute; position: absolute;
top: 1px; top: 1px;
left: 5px; left: 5px;
z-index: 99999; z-index: 999;
width: calc(100% - 8px); width: calc(100% - 8px);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;

View File

@@ -19,9 +19,9 @@
<mat-label *ngIf="type === 'audio'"><ng-container i18n="Audio files title">Audio files</ng-container></mat-label> <mat-label *ngIf="type === 'audio'"><ng-container i18n="Audio files title">Audio files</ng-container></mat-label>
<mat-label *ngIf="type === 'video'"><ng-container i18n="Videos title">Videos</ng-container></mat-label> <mat-label *ngIf="type === 'video'"><ng-container i18n="Videos title">Videos</ng-container></mat-label>
<mat-select [formControl]="filesSelect" multiple required aria-required> <mat-select [formControl]="filesSelect" multiple required aria-required>
<ng-container *ngIf="filesToSelectFrom"><mat-option *ngFor="let file of filesToSelectFrom" [value]="file.id">{{file.id}}</mat-option></ng-container> <ng-container *ngIf="filesToSelectFrom"><mat-option *ngFor="let file of filesToSelectFrom" [value]="file.uid">{{file.id}}</mat-option></ng-container>
<ng-container *ngIf="audiosToSelectFrom && type === 'audio'"><mat-option *ngFor="let file of audiosToSelectFrom" [value]="file.id">{{file.id}}</mat-option></ng-container> <ng-container *ngIf="audiosToSelectFrom && type === 'audio'"><mat-option *ngFor="let file of audiosToSelectFrom" [value]="file.uid">{{file.id}}</mat-option></ng-container>
<ng-container *ngIf="videosToSelectFrom && type === 'video'"><mat-option *ngFor="let file of videosToSelectFrom" [value]="file.id">{{file.id}}</mat-option></ng-container> <ng-container *ngIf="videosToSelectFrom && type === 'video'"><mat-option *ngFor="let file of videosToSelectFrom" [value]="file.uid">{{file.id}}</mat-option></ng-container>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<!-- No videos available --> <!-- No videos available -->

View File

@@ -51,9 +51,8 @@ export class CreatePlaylistComponent implements OnInit {
createPlaylist() { createPlaylist() {
const thumbnailURL = this.getThumbnailURL(); const thumbnailURL = this.getThumbnailURL();
const duration = this.calculateDuration();
this.create_in_progress = true; this.create_in_progress = true;
this.postsService.createPlaylist(this.name, this.filesSelect.value, this.type, thumbnailURL, duration).subscribe(res => { this.postsService.createPlaylist(this.name, this.filesSelect.value, this.type, thumbnailURL).subscribe(res => {
this.create_in_progress = false; this.create_in_progress = false;
if (res['success']) { if (res['success']) {
this.dialogRef.close(true); this.dialogRef.close(true);
@@ -78,36 +77,4 @@ export class CreatePlaylistComponent implements OnInit {
} }
return null; return null;
} }
getDuration(file_id) {
let properFilesToSelectFrom = this.filesToSelectFrom;
if (!this.filesToSelectFrom) {
properFilesToSelectFrom = this.type === 'audio' ? this.audiosToSelectFrom : this.videosToSelectFrom;
}
for (let i = 0; i < properFilesToSelectFrom.length; i++) {
const file = properFilesToSelectFrom[i];
if (file.id === file_id) {
return file.duration;
}
}
return null;
}
calculateDuration() {
let sum = 0;
for (let i = 0; i < this.filesSelect.value.length; i++) {
const duration_val = this.getDuration(this.filesSelect.value[i]);
sum += typeof duration_val === 'string' ? this.durationStringToNumber(duration_val) : duration_val;
}
return sum;
}
durationStringToNumber(dur_str) {
let num_sum = 0;
const dur_str_parts = dur_str.split(':');
for (let i = dur_str_parts.length-1; i >= 0; i--) {
num_sum += parseInt(dur_str_parts[i])*(60**(dur_str_parts.length-1-i));
}
return num_sum;
}
} }

View File

@@ -1,38 +1,40 @@
<h4 mat-dialog-title><ng-container i18n="Modify playlist dialog title">Modify playlist</ng-container></h4> <h4 mat-dialog-title><ng-container i18n="Modify playlist dialog title">Modify playlist</ng-container></h4>
<mat-dialog-content> <mat-dialog-content>
<!-- Playlist info --> <div *ngIf="playlist">
<div> <!-- Playlist info -->
<mat-form-field color="accent"> <div>
<input matInput placeholder="Name" i18n-placeholder="Name" [(ngModel)]="playlist.name"> <mat-form-field color="accent">
</mat-form-field> <input matInput placeholder="Name" i18n-placeholder="Name" [(ngModel)]="playlist.name">
</div> </mat-form-field>
<div style="margin-bottom: 10px; height: 40px;">
<div style="float: left">
<span *ngIf="reverse_order === false" i18n="Normal order">Normal order&nbsp;</span>
<span *ngIf="reverse_order === true" i18n="Reverse order">Reverse order&nbsp;</span>
<button (click)="togglePlaylistOrder()" mat-icon-button><mat-icon>{{!reverse_order ? 'arrow_downward' : 'arrow_upward'}}</mat-icon></button>
</div> </div>
<div style="float: right"> <div style="margin-bottom: 10px; height: 40px;">
<button [disabled]="available_files.length === 0" mat-stroked-button [matMenuTriggerFor]="menu"><ng-container i18n="Add content">Add content</ng-container></button> <div style="float: left">
</div> <span *ngIf="reverse_order === false" i18n="Normal order">Normal order&nbsp;</span>
</div> <span *ngIf="reverse_order === true" i18n="Reverse order">Reverse order&nbsp;</span>
<button (click)="togglePlaylistOrder()" mat-icon-button><mat-icon>{{!reverse_order ? 'arrow_downward' : 'arrow_upward'}}</mat-icon></button>
</div>
<!-- Playlist order --> <div style="float: right">
<mat-button-toggle-group class="media-list" cdkDropList (cdkDropListDropped)="drop($event)" style="width: 80%; left: 9%" vertical name="videoSelect" aria-label="Video Select" #group="matButtonToggleGroup"> <button [disabled]="available_files.length === 0" mat-stroked-button [matMenuTriggerFor]="menu"><ng-container i18n="Add content">Add content</ng-container></button>
<!-- The following for loop can be optimized but it requires a pipe https://stackoverflow.com/a/35703364/8088021 --> </div>
<mat-button-toggle class="media-box" cdkDrag *ngFor="let playlist_item of (reverse_order ? playlist.fileNames.slice().reverse() : playlist.fileNames); let i = index" [checked]="false"><div><div class="playlist-item-text">{{playlist_item}}</div> <button (click)="removeContent(i)" class="remove-item-button" mat-icon-button><mat-icon>cancel</mat-icon></button></div></mat-button-toggle> </div>
</mat-button-toggle-group>
<!-- Playlist order -->
<mat-menu #menu="matMenu"> <mat-button-toggle-group class="media-list" cdkDropList (cdkDropListDropped)="drop($event)" style="width: 80%; left: 9%" vertical name="videoSelect" aria-label="Video Select" #group="matButtonToggleGroup">
<button *ngFor="let file of available_files" (click)="addContent(file)" mat-menu-item>{{file}}</button> <!-- The following for loop can be optimized but it requires a pipe https://stackoverflow.com/a/35703364/8088021 -->
</mat-menu> <mat-button-toggle class="media-box" cdkDrag *ngFor="let playlist_item of (reverse_order ? playlist_file_objs.slice().reverse() : playlist_file_objs); let i = index" [checked]="false"><div><div class="playlist-item-text">{{playlist_item.title}}</div> <button (click)="removeContent(i)" class="remove-item-button" mat-icon-button><mat-icon>cancel</mat-icon></button></div></mat-button-toggle>
</mat-button-toggle-group>
<mat-menu #menu="matMenu">
<button *ngFor="let file of available_files" (click)="addContent(file)" mat-menu-item>{{file.title}}</button>
</mat-menu>
</div>
</mat-dialog-content> </mat-dialog-content>
<mat-dialog-actions> <mat-dialog-actions>
<!-- Save --> <!-- Save -->
<button [disabled]="!playlistChanged()" (click)="updatePlaylist()" [disabled]="playlistChanged()" mat-raised-button color="accent"><ng-container i18n="Save">Save</ng-container></button> <button [disabled]="!playlist || !playlistChanged()" (click)="updatePlaylist()" mat-raised-button color="accent"><ng-container i18n="Save">Save</ng-container></button>
</mat-dialog-actions> </mat-dialog-actions>

View File

@@ -10,8 +10,12 @@ import { PostsService } from 'app/posts.services';
}) })
export class ModifyPlaylistComponent implements OnInit { export class ModifyPlaylistComponent implements OnInit {
playlist_id = null;
original_playlist = null; original_playlist = null;
playlist = null; playlist = null;
playlist_file_objs = null;
available_files = []; available_files = [];
all_files = []; all_files = [];
playlist_updated = false; playlist_updated = false;
@@ -23,9 +27,8 @@ export class ModifyPlaylistComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
if (this.data) { if (this.data) {
this.playlist = JSON.parse(JSON.stringify(this.data.playlist)); this.playlist_id = this.data.playlist_id;
this.original_playlist = JSON.parse(JSON.stringify(this.data.playlist)); this.getPlaylist();
this.getFiles();
} }
this.reverse_order = localStorage.getItem('default_playlist_order_reversed') === 'true'; this.reverse_order = localStorage.getItem('default_playlist_order_reversed') === 'true';
@@ -44,11 +47,12 @@ export class ModifyPlaylistComponent implements OnInit {
} }
processFiles(new_files = null) { processFiles(new_files = null) {
if (new_files) { this.all_files = new_files.map(file => file.id); } if (new_files) { this.all_files = new_files; }
this.available_files = this.all_files.filter(e => !this.playlist.fileNames.includes(e)) this.available_files = this.all_files.filter(e => !this.playlist_file_objs.includes(e))
} }
updatePlaylist() { updatePlaylist() {
this.playlist['uids'] = this.playlist_file_objs.map(playlist_file_obj => playlist_file_obj['uid'])
this.postsService.updatePlaylist(this.playlist).subscribe(res => { this.postsService.updatePlaylist(this.playlist).subscribe(res => {
this.playlist_updated = true; this.playlist_updated = true;
this.postsService.openSnackBar('Playlist updated successfully.'); this.postsService.openSnackBar('Playlist updated successfully.');
@@ -57,28 +61,30 @@ export class ModifyPlaylistComponent implements OnInit {
} }
playlistChanged() { playlistChanged() {
return JSON.stringify(this.playlist) === JSON.stringify(this.original_playlist); return JSON.stringify(this.playlist) !== JSON.stringify(this.original_playlist);
} }
getPlaylist() { getPlaylist() {
this.postsService.getPlaylist(this.playlist.id, this.playlist.type, null).subscribe(res => { this.postsService.getPlaylist(this.playlist_id, null, true).subscribe(res => {
if (res['playlist']) { if (res['playlist']) {
this.playlist = res['playlist']; this.playlist = res['playlist'];
this.playlist_file_objs = res['file_objs'];
this.original_playlist = JSON.parse(JSON.stringify(this.playlist)); this.original_playlist = JSON.parse(JSON.stringify(this.playlist));
this.getFiles();
} }
}); });
} }
addContent(file) { addContent(file) {
this.playlist.fileNames.push(file); this.playlist_file_objs.push(file);
this.processFiles(); this.processFiles();
} }
removeContent(index) { removeContent(index) {
if (this.reverse_order) { if (this.reverse_order) {
index = this.playlist.fileNames.length - 1 - index; index = this.playlist_file_objs.length - 1 - index;
} }
this.playlist.fileNames.splice(index, 1); this.playlist_file_objs.splice(index, 1);
this.processFiles(); this.processFiles();
} }
@@ -89,10 +95,10 @@ export class ModifyPlaylistComponent implements OnInit {
drop(event: CdkDragDrop<string[]>) { drop(event: CdkDragDrop<string[]>) {
if (this.reverse_order) { if (this.reverse_order) {
event.previousIndex = this.playlist.fileNames.length - 1 - event.previousIndex; event.previousIndex = this.playlist_file_objs.length - 1 - event.previousIndex;
event.currentIndex = this.playlist.fileNames.length - 1 - event.currentIndex; event.currentIndex = this.playlist_file_objs.length - 1 - event.currentIndex;
} }
moveItemInArray(this.playlist.fileNames, event.previousIndex, event.currentIndex); moveItemInArray(this.playlist_file_objs, event.previousIndex, event.currentIndex);
} }
} }

View File

@@ -1,7 +1,6 @@
<h4 mat-dialog-title> <h4 mat-dialog-title>
<ng-container *ngIf="is_playlist" i18n="Share playlist dialog title">Share playlist</ng-container> <ng-container *ngIf="is_playlist" i18n="Share playlist dialog title">Share playlist</ng-container>
<ng-container *ngIf="!is_playlist && type === 'video'" i18n="Share video dialog title">Share video</ng-container> <ng-container *ngIf="!is_playlist" i18n="Share video dialog title">Share file</ng-container>
<ng-container *ngIf="!is_playlist && type === 'audio'" i18n="Share audio dialog title">Share audio</ng-container>
</h4> </h4>
<mat-dialog-content> <mat-dialog-content>

View File

@@ -11,7 +11,6 @@ import { PostsService } from 'app/posts.services';
}) })
export class ShareMediaDialogComponent implements OnInit { export class ShareMediaDialogComponent implements OnInit {
type = null;
uid = null; uid = null;
uuid = null; uuid = null;
share_url = null; share_url = null;
@@ -26,14 +25,13 @@ export class ShareMediaDialogComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
if (this.data) { if (this.data) {
this.type = this.data.type;
this.uid = this.data.uid; this.uid = this.data.uid;
this.uuid = this.data.uuid; this.uuid = this.data.uuid;
this.sharing_enabled = this.data.sharing_enabled; this.sharing_enabled = this.data.sharing_enabled;
this.is_playlist = this.data.is_playlist; this.is_playlist = this.data.is_playlist;
this.current_timestamp = (this.data.current_timestamp / 1000).toFixed(2); this.current_timestamp = (this.data.current_timestamp / 1000).toFixed(2);
const arg = (this.is_playlist ? ';id=' : ';uid='); const arg = (this.is_playlist ? ';playlist_id=' : ';uid=');
this.default_share_url = window.location.href.split(';')[0] + arg + this.uid; this.default_share_url = window.location.href.split(';')[0] + arg + this.uid;
if (this.uuid) { if (this.uuid) {
this.default_share_url += ';uuid=' + this.uuid; this.default_share_url += ';uuid=' + this.uuid;
@@ -65,7 +63,7 @@ export class ShareMediaDialogComponent implements OnInit {
sharingChanged(event) { sharingChanged(event) {
if (event.checked) { if (event.checked) {
this.postsService.enableSharing(this.uid, this.type, this.is_playlist).subscribe(res => { this.postsService.enableSharing(this.uid, this.is_playlist).subscribe(res => {
if (res['success']) { if (res['success']) {
this.openSnackBar('Sharing enabled.'); this.openSnackBar('Sharing enabled.');
this.sharing_enabled = true; this.sharing_enabled = true;
@@ -76,7 +74,7 @@ export class ShareMediaDialogComponent implements OnInit {
this.openSnackBar('Failed to enable sharing - server error.'); this.openSnackBar('Failed to enable sharing - server error.');
}); });
} else { } else {
this.postsService.disableSharing(this.uid, this.type, this.is_playlist).subscribe(res => { this.postsService.disableSharing(this.uid, this.is_playlist).subscribe(res => {
if (res['success']) { if (res['success']) {
this.openSnackBar('Sharing disabled.'); this.openSnackBar('Sharing disabled.');
this.sharing_enabled = false; this.sharing_enabled = false;

View File

@@ -56,7 +56,7 @@ export class FileCardComponent implements OnInit {
deleteFile(blacklistMode = false) { deleteFile(blacklistMode = false) {
if (!this.playlist) { if (!this.playlist) {
this.postsService.deleteFile(this.uid, this.isAudio ? 'audio' : 'video', blacklistMode).subscribe(result => { this.postsService.deleteFile(this.uid, blacklistMode).subscribe(result => {
if (result) { if (result) {
this.openSnackBar('Delete success!', 'OK.'); this.openSnackBar('Delete success!', 'OK.');
this.removeFile.emit(this.name); this.removeFile.emit(this.name);
@@ -84,7 +84,7 @@ export class FileCardComponent implements OnInit {
editPlaylistDialog() { editPlaylistDialog() {
const dialogRef = this.dialog.open(ModifyPlaylistComponent, { const dialogRef = this.dialog.open(ModifyPlaylistComponent, {
data: { data: {
playlist: this.playlist, playlist_id: this.playlist.id,
width: '65vw' width: '65vw'
} }
}); });

View File

@@ -14,7 +14,7 @@ export class H401Interceptor implements HttpInterceptor {
return next.handle(request).pipe(catchError(err => { return next.handle(request).pipe(catchError(err => {
if (err.status === 401) { if (err.status === 401) {
localStorage.setItem('jwt_token', null); localStorage.setItem('jwt_token', null);
if (this.router.url !== '/login') { if (this.router.url !== '/login' && !this.router.url.includes('player')) {
this.router.navigate(['/login']).then(() => { this.router.navigate(['/login']).then(() => {
this.openSnackBar('Login expired, please login again.'); this.openSnackBar('Login expired, please login again.');
}); });

View File

@@ -124,6 +124,10 @@ mat-form-field.mat-form-field {
width: 100%; width: 100%;
} }
.advanced-input-time {
margin-left: 10px;
}
.edit-button { .edit-button {
margin-left: 10px; margin-left: 10px;
top: -5px; top: -5px;

View File

@@ -20,11 +20,16 @@
</ng-container> </ng-container>
</mat-label> </mat-label>
<mat-select [ngModelOptions]="{standalone: true}" [(ngModel)]="selectedQuality"> <mat-select [ngModelOptions]="{standalone: true}" [(ngModel)]="selectedQuality">
<ng-container *ngFor="let option of qualityOptions[(audioOnly) ? 'audio' : 'video']"> <mat-option [value]="''">
<mat-option *ngIf="option.value === '' || url && cachedAvailableFormats[url] && cachedAvailableFormats[url]['formats'] && cachedAvailableFormats[url]['formats'][(audioOnly) ? 'audio' : 'video'][option.value]" [value]="option.value"> Max
{{option.label}}
</mat-option> </mat-option>
</ng-container> <ng-container *ngIf="url && cachedAvailableFormats && cachedAvailableFormats[url]?.formats">
<ng-container *ngFor="let option of cachedAvailableFormats[url]['formats'][audioOnly ? 'audio' : 'video']">
<mat-option *ngIf="option.key !== 'best_audio_format'" [value]="option">
{{option.key}}
</mat-option>
</ng-container>
</ng-container>
</mat-select> </mat-select>
<div class="spinner-div" *ngIf="url !== '' && cachedAvailableFormats[url] && cachedAvailableFormats[url]['formats_loading']"> <div class="spinner-div" *ngIf="url !== '' && cachedAvailableFormats[url] && cachedAvailableFormats[url]['formats_loading']">
<mat-spinner [diameter]="25"></mat-spinner> <mat-spinner [diameter]="25"></mat-spinner>
@@ -129,7 +134,7 @@
</mat-hint> </mat-hint>
</mat-form-field> </mat-form-field>
</div> </div>
<div *ngIf="!youtubeAuthDisabledOverride" class="col-12 col-sm-6 mt-2"> <div *ngIf="!youtubeAuthDisabledOverride" class="col-12 col-sm-6 mt-3">
<mat-checkbox color="accent" [disabled]="current_download" (change)="youtubeAuthEnabledChanged($event)" [(ngModel)]="youtubeAuthEnabled" style="z-index: 999" [ngModelOptions]="{standalone: true}"> <mat-checkbox color="accent" [disabled]="current_download" (change)="youtubeAuthEnabledChanged($event)" [(ngModel)]="youtubeAuthEnabled" style="z-index: 999" [ngModelOptions]="{standalone: true}">
<ng-container i18n="Use authentication checkbox"> <ng-container i18n="Use authentication checkbox">
Use authentication Use authentication
@@ -139,11 +144,26 @@
<input [(ngModel)]="youtubeUsername" [ngModelOptions]="{standalone: true}" [disabled]="!youtubeAuthEnabled" matInput placeholder="Username" i18n-placeholder="YT Username placeholder"> <input [(ngModel)]="youtubeUsername" [ngModelOptions]="{standalone: true}" [disabled]="!youtubeAuthEnabled" matInput placeholder="Username" i18n-placeholder="YT Username placeholder">
</mat-form-field> </mat-form-field>
</div> </div>
<div *ngIf="!youtubeAuthDisabledOverride" class="col-12 col-sm-6 mt-2"> <div *ngIf="!youtubeAuthDisabledOverride" class="col-12 col-sm-6 mt-3">
<mat-form-field style="margin-top: 31px;" color="accent" class="advanced-input"> <mat-form-field style="margin-top: 31px;" color="accent" class="advanced-input">
<input [(ngModel)]="youtubePassword" type="password" [ngModelOptions]="{standalone: true}" [disabled]="!youtubeAuthEnabled" matInput placeholder="Password" i18n-placeholder="YT Password placeholder"> <input [(ngModel)]="youtubePassword" type="password" [ngModelOptions]="{standalone: true}" [disabled]="!youtubeAuthEnabled" matInput placeholder="Password" i18n-placeholder="YT Password placeholder">
</mat-form-field> </mat-form-field>
</div> </div>
<div class="col-12 col-sm-6 mt-3">
<mat-checkbox color="accent" [disabled]="current_download" [(ngModel)]="cropFile" style="z-index: 999" [ngModelOptions]="{standalone: true}">
<ng-container i18n="Crop video checkbox">
Crop file
</ng-container>
</mat-checkbox>
<mat-form-field color="accent" class="advanced-input">
<input [(ngModel)]="cropFileStart" type="number" [ngModelOptions]="{standalone: true}" [disabled]="!cropFile" matInput placeholder="Crop from (seconds)" i18n-placeholder="Crom from placeholder">
</mat-form-field>
</div>
<div class="col-12 col-sm-6 mt-3">
<mat-form-field style="margin-top: 31px;" color="accent" class="advanced-input">
<input [(ngModel)]="cropFileEnd" type="number" [ngModelOptions]="{standalone: true}" [disabled]="!cropFile" matInput placeholder="Crop to (seconds)" i18n-placeholder="Crop to placeholder">
</mat-form-field>
</div>
</div> </div>
</div> </div>
</mat-expansion-panel> </mat-expansion-panel>

View File

@@ -54,6 +54,9 @@ export class MainComponent implements OnInit {
youtubeAuthEnabled = false; youtubeAuthEnabled = false;
youtubeUsername = null; youtubeUsername = null;
youtubePassword = null; youtubePassword = null;
cropFile = false;
cropFileStart = null;
cropFileEnd = null;
urlError = false; urlError = false;
path = ''; path = '';
url = ''; url = '';
@@ -339,12 +342,8 @@ export class MainComponent implements OnInit {
} }
} }
public goToFile(name, isAudio, uid) { public goToFile(container, isAudio, uid) {
if (isAudio) { this.downloadHelper(container, isAudio ? 'audio' : 'video', false, false, null, true);
this.downloadHelperMp3(name, uid, false, false, null, true);
} else {
this.downloadHelperMp4(name, uid, false, false, null, true);
}
} }
public goToPlaylist(playlistID, type) { public goToPlaylist(playlistID, type) {
@@ -352,7 +351,7 @@ export class MainComponent implements OnInit {
if (playlist) { if (playlist) {
if (this.downloadOnlyMode) { if (this.downloadOnlyMode) {
this.downloading_content[type][playlistID] = true; this.downloading_content[type][playlistID] = true;
this.downloadPlaylist(playlist.fileNames, type, playlist.name, playlistID); this.downloadPlaylist(playlist);
} else { } else {
localStorage.setItem('player_navigator', this.router.url); localStorage.setItem('player_navigator', this.router.url);
const fileNames = playlist.fileNames; const fileNames = playlist.fileNames;
@@ -376,56 +375,26 @@ export class MainComponent implements OnInit {
// download helpers // download helpers
downloadHelperMp3(name, uid, is_playlist = false, forceView = false, new_download = null, navigate_mode = false) { downloadHelper(container, type, is_playlist = false, force_view = false, new_download = null, navigate_mode = false) {
this.downloadingfile = false; this.downloadingfile = false;
if (this.multiDownloadMode && !this.downloadOnlyMode && !navigate_mode) { if (this.multiDownloadMode && !this.downloadOnlyMode && !navigate_mode) {
// do nothing // do nothing
this.reloadRecentVideos(); this.reloadRecentVideos();
} else { } else {
// if download only mode, just download the file. no redirect // if download only mode, just download the file. no redirect
if (forceView === false && this.downloadOnlyMode && !this.iOS) { if (force_view === false && this.downloadOnlyMode && !this.iOS) {
if (is_playlist) { if (is_playlist) {
const zipName = name[0].split(' ')[0] + name[1].split(' ')[0]; this.downloadPlaylist(container['uid']);
this.downloadPlaylist(name, 'audio', zipName);
} else { } else {
this.downloadAudioFile(decodeURI(name)); this.downloadFileFromServer(container, type);
} }
this.reloadRecentVideos(); this.reloadRecentVideos();
} else { } else {
localStorage.setItem('player_navigator', this.router.url.split(';')[0]); localStorage.setItem('player_navigator', this.router.url.split(';')[0]);
if (is_playlist) { if (is_playlist) {
this.router.navigate(['/player', {fileNames: name.join('|nvr|'), type: 'audio'}]); this.router.navigate(['/player', {playlist_id: container['id'], type: type}]);
} else { } else {
this.router.navigate(['/player', {type: 'audio', uid: uid}]); this.router.navigate(['/player', {type: type, uid: container['uid']}]);
}
}
}
// remove download from current downloads
this.removeDownloadFromCurrentDownloads(new_download);
}
downloadHelperMp4(name, uid, is_playlist = false, forceView = false, new_download = null, navigate_mode = false) {
this.downloadingfile = false;
if (this.multiDownloadMode && !this.downloadOnlyMode && !navigate_mode) {
// do nothing
this.reloadRecentVideos();
} else {
// if download only mode, just download the file. no redirect
if (forceView === false && this.downloadOnlyMode) {
if (is_playlist) {
const zipName = name[0].split(' ')[0] + name[1].split(' ')[0];
this.downloadPlaylist(name, 'video', zipName);
} else {
this.downloadVideoFile(decodeURI(name));
}
this.reloadRecentVideos();
} else {
localStorage.setItem('player_navigator', this.router.url.split(';')[0]);
if (is_playlist) {
this.router.navigate(['/player', {fileNames: name.join('|nvr|'), type: 'video'}]);
} else {
this.router.navigate(['/player', {type: 'video', uid: uid}]);
} }
} }
} }
@@ -436,124 +405,85 @@ export class MainComponent implements OnInit {
// download click handler // download click handler
downloadClicked() { downloadClicked() {
if (this.ValidURL(this.url)) { if (!this.ValidURL(this.url)) {
this.urlError = false;
this.path = '';
// get common args
const customArgs = (this.customArgsEnabled ? this.customArgs : null);
const customOutput = (this.customOutputEnabled ? this.customOutput : null);
const youtubeUsername = (this.youtubeAuthEnabled && this.youtubeUsername ? this.youtubeUsername : null);
const youtubePassword = (this.youtubeAuthEnabled && this.youtubePassword ? this.youtubePassword : null);
// set advanced inputs
if (this.allowAdvancedDownload) {
if (customArgs) {
localStorage.setItem('customArgs', customArgs);
}
if (customOutput) {
localStorage.setItem('customOutput', customOutput);
}
if (youtubeUsername) {
localStorage.setItem('youtubeUsername', youtubeUsername);
}
}
if (this.audioOnly) {
// create download object
const new_download: Download = {
uid: uuid(),
type: 'audio',
percent_complete: 0,
url: this.url,
downloading: true,
is_playlist: this.url.includes('playlist'),
error: false
};
this.downloads.push(new_download);
if (!this.current_download && !this.multiDownloadMode) { this.current_download = new_download };
this.downloadingfile = true;
let customQualityConfiguration = null;
if (this.selectedQuality !== '') {
customQualityConfiguration = this.getSelectedAudioFormat();
}
this.postsService.makeMP3(this.url, (this.selectedQuality === '' ? null : this.selectedQuality),
customQualityConfiguration, customArgs, customOutput, youtubeUsername, youtubePassword, new_download.uid).subscribe(posts => {
// update download object
new_download.downloading = false;
new_download.percent_complete = 100;
const is_playlist = !!(posts['file_names']);
this.path = is_playlist ? posts['file_names'] : posts['audiopathEncoded'];
this.current_download = null;
if (this.path !== '-1') {
this.downloadHelperMp3(this.path, posts['uid'], is_playlist, false, new_download);
}
}, error => { // can't access server or failed to download for other reasons
this.downloadingfile = false;
this.current_download = null;
new_download['downloading'] = false;
// removes download from list of downloads
const downloads_index = this.downloads.indexOf(new_download);
if (downloads_index !== -1) {
this.downloads.splice(downloads_index)
}
this.openSnackBar('Download failed!', 'OK.');
});
} else {
// create download object
const new_download: Download = {
uid: uuid(),
type: 'video',
percent_complete: 0,
url: this.url,
downloading: true,
is_playlist: this.url.includes('playlist'),
error: false
};
this.downloads.push(new_download);
if (!this.current_download && !this.multiDownloadMode) { this.current_download = new_download };
this.downloadingfile = true;
const customQualityConfiguration = this.getSelectedVideoFormat();
this.postsService.makeMP4(this.url, (this.selectedQuality === '' ? null : this.selectedQuality),
customQualityConfiguration, customArgs, customOutput, youtubeUsername, youtubePassword, new_download.uid).subscribe(posts => {
// update download object
new_download.downloading = false;
new_download.percent_complete = 100;
const is_playlist = !!(posts['file_names']);
this.path = is_playlist ? posts['file_names'] : posts['videopathEncoded'];
this.current_download = null;
if (this.path !== '-1') {
this.downloadHelperMp4(this.path, posts['uid'], is_playlist, false, new_download);
}
}, error => { // can't access server
this.downloadingfile = false;
this.current_download = null;
new_download['downloading'] = false;
// removes download from list of downloads
const downloads_index = this.downloads.indexOf(new_download);
if (downloads_index !== -1) {
this.downloads.splice(downloads_index)
}
this.openSnackBar('Download failed!', 'OK.');
});
}
if (this.multiDownloadMode) {
this.url = '';
this.downloadingfile = false;
}
} else {
this.urlError = true; this.urlError = true;
return;
}
this.urlError = false;
// get common args
const customArgs = (this.customArgsEnabled ? this.customArgs : null);
const customOutput = (this.customOutputEnabled ? this.customOutput : null);
const youtubeUsername = (this.youtubeAuthEnabled && this.youtubeUsername ? this.youtubeUsername : null);
const youtubePassword = (this.youtubeAuthEnabled && this.youtubePassword ? this.youtubePassword : null);
// set advanced inputs
if (this.allowAdvancedDownload) {
if (customArgs) {
localStorage.setItem('customArgs', customArgs);
}
if (customOutput) {
localStorage.setItem('customOutput', customOutput);
}
if (youtubeUsername) {
localStorage.setItem('youtubeUsername', youtubeUsername);
}
}
const type = this.audioOnly ? 'audio' : 'video';
// create download object
const new_download: Download = {
uid: uuid(),
type: type,
percent_complete: 0,
url: this.url,
downloading: true,
is_playlist: this.url.includes('playlist'),
error: false
};
this.downloads.push(new_download);
if (!this.current_download && !this.multiDownloadMode) { this.current_download = new_download };
this.downloadingfile = true;
let customQualityConfiguration = type === 'audio' ? this.getSelectedAudioFormat() : this.getSelectedVideoFormat();
let cropFileSettings = null;
if (this.cropFile) {
cropFileSettings = {
cropFileStart: this.cropFileStart,
cropFileEnd: this.cropFileEnd
}
}
this.postsService.downloadFile(this.url, type, (this.selectedQuality === '' ? null : this.selectedQuality),
customQualityConfiguration, customArgs, customOutput, youtubeUsername, youtubePassword, new_download.uid, cropFileSettings).subscribe(res => {
// update download object
new_download.downloading = false;
new_download.percent_complete = 100;
const container = res['container'];
const is_playlist = res['file_uids'].length > 1;
this.current_download = null;
this.downloadHelper(container, type, is_playlist, false, new_download);
}, error => { // can't access server
this.downloadingfile = false;
this.current_download = null;
new_download['downloading'] = false;
// removes download from list of downloads
const downloads_index = this.downloads.indexOf(new_download);
if (downloads_index !== -1) {
this.downloads.splice(downloads_index)
}
this.openSnackBar('Download failed!', 'OK.');
});
if (this.multiDownloadMode) {
this.url = '';
this.downloadingfile = false;
} }
} }
@@ -570,23 +500,26 @@ export class MainComponent implements OnInit {
} }
getSelectedAudioFormat() { getSelectedAudioFormat() {
if (this.selectedQuality === '') { return null }; if (this.selectedQuality === '') { return null; }
const cachedFormatsExists = this.cachedAvailableFormats[this.url] && this.cachedAvailableFormats[this.url]['formats']; const cachedFormatsExists = this.cachedAvailableFormats[this.url] && this.cachedAvailableFormats[this.url]['formats'];
if (cachedFormatsExists) { if (cachedFormatsExists) {
const audio_formats = this.cachedAvailableFormats[this.url]['formats']['audio']; const audio_formats = this.cachedAvailableFormats[this.url]['formats']['audio'];
return audio_formats[this.selectedQuality]['format_id']; return this.selectedQuality['format_id'];
} else { } else {
return null; return null;
} }
} }
getSelectedVideoFormat() { getSelectedVideoFormat() {
if (this.selectedQuality === '') { return null }; if (this.selectedQuality === '') { return null; }
const cachedFormatsExists = this.cachedAvailableFormats[this.url] && this.cachedAvailableFormats[this.url]['formats']; const cachedFormats = this.cachedAvailableFormats[this.url] && this.cachedAvailableFormats[this.url]['formats'];
if (cachedFormatsExists) { if (cachedFormats) {
const video_formats = this.cachedAvailableFormats[this.url]['formats']['video']; const video_formats = cachedFormats['video'];
if (video_formats['best_audio_format'] && this.selectedQuality !== '') { if (this.selectedQuality) {
return video_formats[this.selectedQuality]['format_id'] + '+' + video_formats['best_audio_format']; let selected_video_format = this.selectedQuality['format_id'];
// add in audio format if necessary
if (!this.selectedQuality['acodec'] && cachedFormats['best_audio_format']) selected_video_format += `+${cachedFormats['best_audio_format']}`;
return selected_video_format;
} }
} }
return null; return null;
@@ -614,41 +547,27 @@ export class MainComponent implements OnInit {
} }
} }
downloadAudioFile(name) { downloadFileFromServer(file, type) {
this.downloading_content['audio'][name] = true; const ext = type === 'audio' ? 'mp3' : 'mp4'
this.postsService.downloadFileFromServer(name, 'audio').subscribe(res => { this.downloading_content[type][file.id] = true;
this.downloading_content['audio'][name] = false; this.postsService.downloadFileFromServer(file.uid).subscribe(res => {
this.downloading_content[type][file.id] = false;
const blob: Blob = res; const blob: Blob = res;
saveAs(blob, decodeURIComponent(name) + '.mp3'); saveAs(blob, decodeURIComponent(file.id) + `.${ext}`);
if (!this.fileManagerEnabled) { if (!this.fileManagerEnabled) {
// tell server to delete the file once downloaded // tell server to delete the file once downloaded
this.postsService.deleteFile(name, 'video').subscribe(delRes => { this.postsService.deleteFile(file.uid).subscribe(delRes => {
}); });
} }
}); });
} }
downloadVideoFile(name) { downloadPlaylist(playlist) {
this.downloading_content['video'][name] = true; this.postsService.downloadPlaylistFromServer(playlist.id).subscribe(res => {
this.postsService.downloadFileFromServer(name, 'video').subscribe(res => { if (playlist.id) { this.downloading_content[playlist.type][playlist.id] = false };
this.downloading_content['video'][name] = false;
const blob: Blob = res; const blob: Blob = res;
saveAs(blob, decodeURIComponent(name) + '.mp4'); saveAs(blob, playlist.name + '.zip');
if (!this.fileManagerEnabled) {
// tell server to delete the file once downloaded
this.postsService.deleteFile(name, 'audio').subscribe(delRes => {
});
}
});
}
downloadPlaylist(fileNames, type, zipName = null, playlistID = null) {
this.postsService.downloadFileFromServer(fileNames, type, zipName).subscribe(res => {
if (playlistID) { this.downloading_content[type][playlistID] = false };
const blob: Blob = res;
saveAs(blob, zipName + '.zip');
}); });
} }
@@ -728,9 +647,8 @@ export class MainComponent implements OnInit {
this.errorFormats(url); this.errorFormats(url);
return; return;
} }
const parsed_infos = this.getAudioAndVideoFormats(infos.formats); this.cachedAvailableFormats[url]['formats'] = this.getAudioAndVideoFormats(infos.formats);
const available_formats = {audio: parsed_infos[0], video: parsed_infos[1]}; console.log(this.cachedAvailableFormats[url]['formats']);
this.cachedAvailableFormats[url]['formats'] = available_formats;
}, err => { }, err => {
this.errorFormats(url); this.errorFormats(url);
}); });
@@ -773,7 +691,7 @@ export class MainComponent implements OnInit {
if (audio_format) { if (audio_format) {
format_array.push('-f', audio_format); format_array.push('-f', audio_format);
} else if (this.selectedQuality) { } else if (this.selectedQuality) {
format_array.push('--audio-quality', this.selectedQuality); format_array.push('--audio-quality', this.selectedQuality['format_id']);
} }
// pushes formats // pushes formats
@@ -789,7 +707,7 @@ export class MainComponent implements OnInit {
if (video_format) { if (video_format) {
format_array = ['-f', video_format]; format_array = ['-f', video_format];
} else if (this.selectedQuality) { } else if (this.selectedQuality) {
format_array = [`bestvideo[height=${this.selectedQuality}]+bestaudio/best[height=${this.selectedQuality}]`]; format_array = [`bestvideo[height=${this.selectedQuality['format_id']}]+bestaudio/best[height=${this.selectedQuality}]`];
} }
// pushes formats // pushes formats
@@ -886,9 +804,11 @@ export class MainComponent implements OnInit {
} }
} }
getAudioAndVideoFormats(formats): any[] { getAudioAndVideoFormats(formats) {
const audio_formats = {}; const audio_formats: any = {};
const video_formats = {}; const video_formats: any = {};
console.log(formats);
for (let i = 0; i < formats.length; i++) { for (let i = 0; i < formats.length; i++) {
const format_obj = {type: null}; const format_obj = {type: null};
@@ -899,9 +819,12 @@ export class MainComponent implements OnInit {
format_obj.type = format_type; format_obj.type = format_type;
if (format_obj.type === 'audio' && format.abr) { if (format_obj.type === 'audio' && format.abr) {
const key = format.abr.toString() + 'K'; const key = format.abr.toString() + 'K';
format_obj['key'] = key;
format_obj['bitrate'] = format.abr; format_obj['bitrate'] = format.abr;
format_obj['format_id'] = format.format_id; format_obj['format_id'] = format.format_id;
format_obj['ext'] = format.ext; format_obj['ext'] = format.ext;
format_obj['label'] = key;
// don't overwrite if not m4a // don't overwrite if not m4a
if (audio_formats[key]) { if (audio_formats[key]) {
if (format.ext === 'm4a') { if (format.ext === 'm4a') {
@@ -912,11 +835,14 @@ export class MainComponent implements OnInit {
} }
} else if (format_obj.type === 'video') { } else if (format_obj.type === 'video') {
// check if video format is mp4 // check if video format is mp4
const key = format.format_note.replace('p', ''); const key = `${format.height}p${Math.round(format.fps)}`;
if (format.ext === 'mp4' || format.ext === 'mkv' || format.ext === 'webm') { if (format.ext === 'mp4' || format.ext === 'mkv' || format.ext === 'webm') {
format_obj['key'] = key;
format_obj['height'] = format.height; format_obj['height'] = format.height;
format_obj['acodec'] = format.acodec; format_obj['acodec'] = format.acodec;
format_obj['format_id'] = format.format_id; format_obj['format_id'] = format.format_id;
format_obj['label'] = key;
format_obj['fps'] = Math.round(format.fps);
// no acodec means no overwrite // no acodec means no overwrite
if (!(video_formats[key]) || format_obj['acodec'] !== 'none') { if (!(video_formats[key]) || format_obj['acodec'] !== 'none') {
@@ -926,9 +852,17 @@ export class MainComponent implements OnInit {
} }
} }
video_formats['best_audio_format'] = this.getBestAudioFormatForMp4(audio_formats); const parsed_formats: any = {};
return [audio_formats, video_formats] parsed_formats['best_audio_format'] = this.getBestAudioFormatForMp4(audio_formats);
parsed_formats['video'] = Object.values(video_formats);
parsed_formats['audio'] = Object.values(audio_formats);
parsed_formats['video'] = parsed_formats['video'].sort((a, b) => b.height - a.height || b.fps - a.fps);
parsed_formats['audio'] = parsed_formats['audio'].sort((a, b) => b.bitrate - a.bitrate);
return parsed_formats;
} }
getBestAudioFormatForMp4(audio_formats) { getBestAudioFormatForMp4(audio_formats) {

View File

@@ -9,7 +9,6 @@
.audio-styles { .audio-styles {
height: 50px; height: 50px;
background-color: transparent;
width: 100%; width: 100%;
} }

View File

@@ -1,14 +1,14 @@
<div style="height: 100%" *ngIf="playlist.length > 0 && show_player"> <div style="height: 100%" *ngIf="playlist.length > 0 && show_player">
<div style="height: 100%" [ngClass]="(type === 'audio') ? null : 'container-video'"> <div style="height: 100%" [ngClass]="(currentItem.type === 'audio/mp3') ? null : 'container-video'">
<div style="max-width: 100%; margin-left: 0px; height: 100%"> <div style="max-width: 100%; margin-left: 0px; height: 100%">
<mat-drawer-container style="height: 100%" class="example-container" autosize> <mat-drawer-container style="height: 100%" class="example-container" autosize>
<div style="height: fit-content" [ngClass]="(type === 'audio') ? 'audio-col' : 'video-col'"> <div style="height: fit-content" [ngClass]="(currentItem.type === 'audio/mp3') ? 'audio-col' : 'video-col'">
<vg-player style="height: fit-content; max-height: 75vh" (onPlayerReady)="onPlayerReady($event)" [style.background-color]="(type === 'audio') ? 'transparent' : 'black'"> <vg-player style="height: fit-content; max-height: 75vh" (onPlayerReady)="onPlayerReady($event)" [style.background-color]="(currentItem.type === 'audio/mp3') ? postsService.theme.drawer_color : 'black'">
<video [ngClass]="(type === 'audio') ? 'audio-styles' : 'video-styles'" #media class="video-player" [vgMedia]="media" [src]="currentItem.src" id="singleVideo" preload="auto" controls> <video [ngClass]="(currentItem.type === 'audio/mp3') ? 'audio-styles' : 'video-styles'" #media class="video-player" [vgMedia]="media" [src]="currentItem.src" id="singleVideo" preload="auto" controls>
</video> </video>
</vg-player> </vg-player>
</div> </div>
<div *ngIf="db_file" style="height: fit-content; width: 100%; margin-top: 10px;"> <div style="height: fit-content; width: 100%; margin-top: 10px;">
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-2 col-lg-1"> <div class="col-2 col-lg-1">
@@ -27,14 +27,14 @@
</ng-container> </ng-container>
</div> </div>
<div class="col-2"> <div class="col-2">
<ng-container *ngIf="playlist.length > 1"> <ng-container *ngIf="db_playlist">
<button (click)="downloadContent()" [disabled]="downloading" mat-icon-button><mat-icon>save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="35"></mat-spinner></button> <button (click)="downloadContent()" [disabled]="downloading" mat-icon-button><mat-icon>save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="35"></mat-spinner></button>
<button *ngIf="!id" color="accent" (click)="namePlaylistDialog()" mat-icon-button><mat-icon>favorite</mat-icon></button> <button *ngIf="(!postsService.isLoggedIn || postsService.permissions.includes('sharing')) && !auto" (click)="openShareDialog()" mat-icon-button><mat-icon>share</mat-icon></button>
<button *ngIf="!is_shared && id && (!postsService.isLoggedIn || postsService.permissions.includes('sharing'))" (click)="openShareDialog()" mat-icon-button><mat-icon>share</mat-icon></button>
</ng-container> </ng-container>
<ng-container *ngIf="playlist.length === 1"> <ng-container *ngIf="db_file">
<button (click)="downloadFile()" [disabled]="downloading" mat-icon-button><mat-icon>save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="35"></mat-spinner></button> <button (click)="downloadFile()" [disabled]="downloading" mat-icon-button><mat-icon>save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="35"></mat-spinner></button>
<button *ngIf="!is_shared && uid && uid !== 'false' && type !== 'subscription' && (!postsService.isLoggedIn || postsService.permissions.includes('sharing'))" (click)="openShareDialog()" mat-icon-button><mat-icon>share</mat-icon></button> <button *ngIf="type !== 'subscription' && (!postsService.isLoggedIn || postsService.permissions.includes('sharing'))" (click)="openShareDialog()" mat-icon-button><mat-icon>share</mat-icon></button>
<button (click)="openFileInfoDialog()" *ngIf="db_file" mat-icon-button><mat-icon>info</mat-icon></button>
</ng-container> </ng-container>
<button *ngIf="db_file && db_file.url.includes('twitch.tv/videos/') && postsService['config']['API']['use_twitch_API']" (click)="drawer.toggle()" mat-icon-button><mat-icon>chat</mat-icon></button> <button *ngIf="db_file && db_file.url.includes('twitch.tv/videos/') && postsService['config']['API']['use_twitch_API']" (click)="drawer.toggle()" mat-icon-button><mat-icon>chat</mat-icon></button>
</div> </div>
@@ -46,6 +46,9 @@
<mat-button-toggle cdkDrag *ngFor="let playlist_item of playlist; let i = index" [checked]="currentItem.title === playlist_item.title" (click)="onClickPlaylistItem(playlist_item, i)" class="toggle-button" [value]="playlist_item.title">{{playlist_item.label}}</mat-button-toggle> <mat-button-toggle cdkDrag *ngFor="let playlist_item of playlist; let i = index" [checked]="currentItem.title === playlist_item.title" (click)="onClickPlaylistItem(playlist_item, i)" class="toggle-button" [value]="playlist_item.title">{{playlist_item.label}}</mat-button-toggle>
</mat-button-toggle-group> </mat-button-toggle-group>
</div> </div>
<app-concurrent-stream *ngIf="db_file && api && postsService.config" (setPlaybackRate)="setPlaybackRate($event)" (togglePlayback)="togglePlayback($event)" (setPlaybackTimestamp)="setPlaybackTimestamp($event)" [playing]="api.state === 'playing'" [uid]="uid" [playback_timestamp]="api.time.current/1000" [server_mode]="!postsService.config.Advanced.multi_user_mode || postsService.isLoggedIn"></app-concurrent-stream>
<mat-drawer #drawer class="example-sidenav" mode="side" position="end" [opened]="db_file && db_file['chat_exists'] && postsService['config']['API']['use_twitch_API']"> <mat-drawer #drawer class="example-sidenav" mode="side" position="end" [opened]="db_file && db_file['chat_exists'] && postsService['config']['API']['use_twitch_API']">
<ng-container *ngIf="api_ready && db_file && db_file.url.includes('twitch.tv/videos/')"> <ng-container *ngIf="api_ready && db_file && db_file.url.includes('twitch.tv/videos/')">
<app-twitch-chat #twitchchat [db_file]="db_file" [current_timestamp]="api.currentTime" [sub]="subscription"></app-twitch-chat> <app-twitch-chat #twitchchat [db_file]="db_file" [current_timestamp]="api.currentTime" [sub]="subscription"></app-twitch-chat>

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, HostListener, EventEmitter, OnDestroy, AfterViewInit, ViewChild } from '@angular/core'; import { Component, OnInit, HostListener, EventEmitter, OnDestroy, AfterViewInit, ViewChild, ChangeDetectorRef } from '@angular/core';
import { VgApiService } from '@videogular/ngx-videogular/core'; import { VgApiService } from '@videogular/ngx-videogular/core';
import { PostsService } from 'app/posts.services'; import { PostsService } from 'app/posts.services';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
@@ -8,6 +8,7 @@ import { InputDialogComponent } from 'app/input-dialog/input-dialog.component';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { ShareMediaDialogComponent } from '../dialogs/share-media-dialog/share-media-dialog.component'; import { ShareMediaDialogComponent } from '../dialogs/share-media-dialog/share-media-dialog.component';
import { TwitchChatComponent } from 'app/components/twitch-chat/twitch-chat.component'; import { TwitchChatComponent } from 'app/components/twitch-chat/twitch-chat.component';
import { VideoInfoDialogComponent } from 'app/dialogs/video-info-dialog/video-info-dialog.component';
export interface IMedia { export interface IMedia {
title: string; title: string;
@@ -35,17 +36,16 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
api_ready = false; api_ready = false;
// params // params
fileNames: string[]; uids: string[];
type: string; type: string;
id = null; // used for playlists (not subscription) playlist_id = null; // used for playlists (not subscription)
uid = null; // used for non-subscription files (audio, video, playlist) uid = null; // used for non-subscription files (audio, video, playlist)
subscription = null; subscription = null;
subscriptionName = null; sub_id = null;
subPlaylist = null; subPlaylist = null;
uuid = null; // used for sharing in multi-user mode, uuid is the user that downloaded the video uuid = null; // used for sharing in multi-user mode, uuid is the user that downloaded the video
timestamp = null; timestamp = null;
auto = null;
is_shared = false;
db_playlist = null; db_playlist = null;
db_file = null; db_file = null;
@@ -55,8 +55,6 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
videoFolderPath = null; videoFolderPath = null;
subscriptionFolderPath = null; subscriptionFolderPath = null;
sharingEnabled = null;
// url-mode params // url-mode params
url = null; url = null;
name = null; name = null;
@@ -78,15 +76,14 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
ngOnInit(): void { ngOnInit(): void {
this.innerWidth = window.innerWidth; this.innerWidth = window.innerWidth;
this.type = this.route.snapshot.paramMap.get('type'); this.playlist_id = this.route.snapshot.paramMap.get('playlist_id');
this.id = this.route.snapshot.paramMap.get('id');
this.uid = this.route.snapshot.paramMap.get('uid'); this.uid = this.route.snapshot.paramMap.get('uid');
this.subscriptionName = this.route.snapshot.paramMap.get('subscriptionName'); this.sub_id = this.route.snapshot.paramMap.get('sub_id');
this.subPlaylist = this.route.snapshot.paramMap.get('subPlaylist');
this.url = this.route.snapshot.paramMap.get('url'); this.url = this.route.snapshot.paramMap.get('url');
this.name = this.route.snapshot.paramMap.get('name'); this.name = this.route.snapshot.paramMap.get('name');
this.uuid = this.route.snapshot.paramMap.get('uuid'); this.uuid = this.route.snapshot.paramMap.get('uuid');
this.timestamp = this.route.snapshot.paramMap.get('timestamp'); this.timestamp = this.route.snapshot.paramMap.get('timestamp');
this.auto = this.route.snapshot.paramMap.get('auto');
// loading config // loading config
if (this.postsService.initialized) { if (this.postsService.initialized) {
@@ -101,6 +98,7 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
} }
ngAfterViewInit() { ngAfterViewInit() {
this.cdr.detectChanges();
this.postsService.sidenav.close(); this.postsService.sidenav.close();
} }
@@ -110,7 +108,7 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
} }
constructor(public postsService: PostsService, private route: ActivatedRoute, private dialog: MatDialog, private router: Router, constructor(public postsService: PostsService, private route: ActivatedRoute, private dialog: MatDialog, private router: Router,
public snackBar: MatSnackBar) { public snackBar: MatSnackBar, private cdr: ChangeDetectorRef) {
} }
@@ -119,19 +117,14 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
this.audioFolderPath = this.postsService.config['Downloader']['path-audio']; this.audioFolderPath = this.postsService.config['Downloader']['path-audio'];
this.videoFolderPath = this.postsService.config['Downloader']['path-video']; this.videoFolderPath = this.postsService.config['Downloader']['path-video'];
this.subscriptionFolderPath = this.postsService.config['Subscriptions']['subscriptions_base_path']; this.subscriptionFolderPath = this.postsService.config['Subscriptions']['subscriptions_base_path'];
this.fileNames = this.route.snapshot.paramMap.get('fileNames') ? this.route.snapshot.paramMap.get('fileNames').split('|nvr|') : null;
if (!this.fileNames && !this.type) { if (this.sub_id) {
this.is_shared = true;
}
if (this.uid && !this.id) {
this.getFile();
} else if (this.id) {
this.getPlaylistFiles();
} else if (this.subscriptionName) {
this.getSubscription(); this.getSubscription();
} } else if (this.playlist_id) {
this.getPlaylistFiles();
} else if (this.uid) {
this.getFile();
}
if (this.url) { if (this.url) {
// if a url is given, just stream the URL // if a url is given, just stream the URL
@@ -146,14 +139,10 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
this.currentItem = this.playlist[0]; this.currentItem = this.playlist[0];
this.currentIndex = 0; this.currentIndex = 0;
this.show_player = true; this.show_player = true;
} else if (this.fileNames && !this.subscriptionName) {
this.show_player = true;
this.parseFileNames();
} }
} }
getFile() { getFile() {
const already_has_filenames = !!this.fileNames;
this.postsService.getFile(this.uid, null, this.uuid).subscribe(res => { this.postsService.getFile(this.uid, null, this.uuid).subscribe(res => {
this.db_file = res['file']; this.db_file = res['file'];
if (!this.db_file) { if (!this.db_file) {
@@ -164,57 +153,41 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
console.error('Failed to increment view count'); console.error('Failed to increment view count');
console.error(err); console.error(err);
}); });
this.sharingEnabled = this.db_file.sharingEnabled; // regular video/audio file (not playlist)
if (!this.fileNames) { this.uids = [this.db_file['uid']];
// means it's a shared video this.type = this.db_file['isAudio'] ? 'audio' : 'video';
if (!this.id) { this.parseFileNames();
// regular video/audio file (not playlist)
this.fileNames = [this.db_file['id']];
this.type = this.db_file['isAudio'] ? 'audio' : 'video';
if (!already_has_filenames) { this.parseFileNames(); }
}
}
if (this.db_file['sharingEnabled'] || !this.uuid) {
this.show_player = true;
} else if (!already_has_filenames) {
this.openSnackBar('Error: Sharing has been disabled for this video!', 'Dismiss');
}
}); });
} }
getSubscription() { getSubscription() {
this.postsService.getSubscription(null, this.subscriptionName).subscribe(res => { this.postsService.getSubscription(this.sub_id).subscribe(res => {
const subscription = res['subscription']; const subscription = res['subscription'];
this.subscription = subscription; this.subscription = subscription;
if (this.fileNames) { this.type === this.subscription.type;
subscription.videos.forEach(video => { subscription.videos.forEach(video => {
if (video['id'] === this.fileNames[0]) { if (video['uid'] === this.uid) {
this.db_file = video; this.db_file = video;
this.postsService.incrementViewCount(this.db_file['uid'], this.subscription['id'], this.uuid).subscribe(res => {}, err => { this.postsService.incrementViewCount(this.db_file['uid'], this.sub_id, this.uuid).subscribe(res => {}, err => {
console.error('Failed to increment view count'); console.error('Failed to increment view count');
console.error(err); console.error(err);
}); });
this.show_player = true; this.uids = [this.db_file['uid']];
this.parseFileNames(); this.show_player = true;
} this.parseFileNames();
}); }
} else { });
console.log('no file name specified');
}
}, err => { }, err => {
this.openSnackBar(`Failed to find subscription ${this.subscriptionName}`, 'Dismiss'); this.openSnackBar(`Failed to find subscription ${this.sub_id}`, 'Dismiss');
}); });
} }
getPlaylistFiles() { getPlaylistFiles() {
if (this.route.snapshot.paramMap.get('auto') === 'true') { this.postsService.getPlaylist(this.playlist_id, this.uuid, true).subscribe(res => {
this.show_player = true;
return;
}
this.postsService.getPlaylist(this.id, null, this.uuid).subscribe(res => {
if (res['playlist']) { if (res['playlist']) {
this.db_playlist = res['playlist']; this.db_playlist = res['playlist'];
this.fileNames = this.db_playlist['fileNames']; this.db_playlist['file_objs'] = res['file_objs'];
this.uids = this.db_playlist.uids;
this.type = res['type']; this.type = res['type'];
this.show_player = true; this.show_player = true;
this.parseFileNames(); this.parseFileNames();
@@ -226,69 +199,49 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
}); });
} }
parseFileNames() { parseFileNames() {
let fileType = null;
if (this.type === 'audio') {
fileType = 'audio/mp3';
} else if (this.type === 'video') {
fileType = 'video/mp4';
} else {
// error
console.error('Must have valid file type! Use \'audio\', \'video\', or \'subscription\'.');
}
this.playlist = []; this.playlist = [];
for (let i = 0; i < this.fileNames.length; i++) { for (let i = 0; i < this.uids.length; i++) {
const fileName = this.fileNames[i]; const uid = this.uids[i];
let baseLocation = null;
let fullLocation = null;
// adds user token if in multi-user-mode const file_obj = this.playlist_id ? this.db_playlist['file_objs'][i] : this.db_file;
const uuid_str = this.uuid ? `&uuid=${this.uuid}` : '';
const uid_str = (this.id || !this.db_file) ? '' : `&uid=${this.db_file.uid}`;
const type_str = (this.type || !this.db_file) ? `&type=${this.type}` : `&type=${this.db_file.type}`
const id_str = this.id ? `&id=${this.id}` : '';
const file_path_str = (!this.db_file) ? '' : `&file_path=${encodeURIComponent(this.db_file.path)}`;
if (!this.subscriptionName) { const mime_type = file_obj.isAudio ? 'audio/mp3' : 'video/mp4'
baseLocation = 'stream/';
fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName) + `?test=test${type_str}${file_path_str}`; let baseLocation = 'stream/';
} else { let fullLocation = this.baseStreamPath + baseLocation + `?test=test&uid=${file_obj['uid']}`;
// default to video but include subscription name param
baseLocation = 'stream/';
fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName) + '?subName=' + this.subscriptionName +
'&subPlaylist=' + this.subPlaylist + `${file_path_str}${type_str}`;
}
if (this.postsService.isLoggedIn) { if (this.postsService.isLoggedIn) {
fullLocation += (this.subscriptionName ? '&' : '&') + `jwt=${this.postsService.token}`; fullLocation += `&jwt=${this.postsService.token}`;
if (this.is_shared) { fullLocation += `${uuid_str}${uid_str}${type_str}${id_str}`; }
} else if (this.is_shared) {
fullLocation += (this.subscriptionName ? '&' : '?') + `test=test${uuid_str}${uid_str}${type_str}${id_str}`;
} }
// if it has a slash (meaning it's in a directory), only get the file name for the label
let label = null; if (this.uuid) {
const decodedName = decodeURIComponent(fileName); fullLocation += `&uuid=${this.uuid}`;
const hasSlash = decodedName.includes('/') || decodedName.includes('\\');
if (hasSlash) {
label = decodedName.replace(/^.*[\\\/]/, '');
} else {
label = decodedName;
} }
if (this.sub_id) {
fullLocation += `&sub_id=${this.sub_id}`;
} else if (this.playlist_id) {
fullLocation += `&playlist_id=${this.playlist_id}`;
}
const mediaObject: IMedia = { const mediaObject: IMedia = {
title: fileName, title: file_obj['title'],
src: fullLocation, src: fullLocation,
type: fileType, type: mime_type,
label: label label: file_obj['title']
} }
this.playlist.push(mediaObject); this.playlist.push(mediaObject);
} }
this.currentItem = this.playlist[this.currentIndex]; this.currentItem = this.playlist[this.currentIndex];
this.original_playlist = JSON.stringify(this.playlist); this.original_playlist = JSON.stringify(this.playlist);
this.show_player = true;
} }
onPlayerReady(api: VgApiService) { onPlayerReady(api: VgApiService) {
this.api = api; this.api = api;
this.api_ready = true; this.api_ready = true;
this.cdr.detectChanges();
// checks if volume has been previously set. if so, use that as default // checks if volume has been previously set. if so, use that as default
if (localStorage.getItem('player_volume')) { if (localStorage.getItem('player_volume')) {
@@ -353,15 +306,9 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
} }
downloadContent() { downloadContent() {
const fileNames = []; const zipName = this.db_playlist.name;
for (let i = 0; i < this.playlist.length; i++) {
fileNames.push(this.playlist[i].title);
}
const zipName = fileNames[0].split(' ')[0] + fileNames[1].split(' ')[0];
this.downloading = true; this.downloading = true;
this.postsService.downloadFileFromServer(fileNames, this.type, zipName, null, null, null, null, this.postsService.downloadPlaylistFromServer(this.playlist_id, this.uuid).subscribe(res => {
!this.uuid ? this.postsService.user.uid : this.uuid, this.id).subscribe(res => {
this.downloading = false; this.downloading = false;
const blob: Blob = res; const blob: Blob = res;
saveAs(blob, zipName + '.zip'); saveAs(blob, zipName + '.zip');
@@ -372,11 +319,10 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
} }
downloadFile() { downloadFile() {
const ext = (this.type === 'audio') ? '.mp3' : '.mp4';
const filename = this.playlist[0].title; const filename = this.playlist[0].title;
const ext = (this.playlist[0].type === 'audio/mp3') ? '.mp3' : '.mp4';
this.downloading = true; this.downloading = true;
this.postsService.downloadFileFromServer(filename, this.type, null, null, this.subscriptionName, this.subPlaylist, this.postsService.downloadFileFromServer(this.uid, this.uuid, this.sub_id).subscribe(res => {
this.is_shared ? this.db_file['uid'] : null, this.uuid).subscribe(res => {
this.downloading = false; this.downloading = false;
const blob: Blob = res; const blob: Blob = res;
saveAs(blob, filename + ext); saveAs(blob, filename + ext);
@@ -386,50 +332,10 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
}); });
} }
namePlaylistDialog() {
const done = new EventEmitter<any>();
const dialogRef = this.dialog.open(InputDialogComponent, {
width: '300px',
data: {
inputTitle: 'Name the playlist',
inputPlaceholder: 'Name',
submitText: 'Favorite',
doneEmitter: done
}
});
done.subscribe(name => {
// Eventually do additional checks on name
if (name) {
const fileNames = this.getFileNames();
this.postsService.createPlaylist(name, fileNames, this.type, null).subscribe(res => {
if (res['success']) {
dialogRef.close();
const new_playlist = res['new_playlist'];
this.db_playlist = new_playlist;
this.openSnackBar('Playlist \'' + name + '\' successfully created!', '')
this.playlistPostCreationHandler(new_playlist.id);
}
});
}
});
}
/*
createPlaylist(name) {
this.postsService.createPlaylist(name, this.fileNames, this.type, null).subscribe(res => {
if (res['success']) {
console.log('Success!');
}
});
}
*/
playlistPostCreationHandler(playlistID) { playlistPostCreationHandler(playlistID) {
// changes the route without moving from the current view or // changes the route without moving from the current view or
// triggering a navigation event // triggering a navigation event
this.id = playlistID; this.playlist_id = playlistID;
this.router.navigateByUrl(this.router.url + ';id=' + playlistID); this.router.navigateByUrl(this.router.url + ';id=' + playlistID);
} }
@@ -444,11 +350,11 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
updatePlaylist() { updatePlaylist() {
const fileNames = this.getFileNames(); const fileNames = this.getFileNames();
this.playlist_updating = true; this.playlist_updating = true;
this.postsService.updatePlaylistFiles(this.id, fileNames, this.type).subscribe(res => { this.postsService.updatePlaylistFiles(this.playlist_id, fileNames, this.type).subscribe(res => {
this.playlist_updating = false; this.playlist_updating = false;
if (res['success']) { if (res['success']) {
const fileNamesEncoded = fileNames.join('|nvr|'); const fileNamesEncoded = fileNames.join('|nvr|');
this.router.navigate(['/player', {fileNames: fileNamesEncoded, type: this.type, id: this.id}]); this.router.navigate(['/player', {fileNames: fileNamesEncoded, type: this.type, id: this.playlist_id}]);
this.openSnackBar('Successfully updated playlist.', ''); this.openSnackBar('Successfully updated playlist.', '');
this.original_playlist = JSON.stringify(this.playlist); this.original_playlist = JSON.stringify(this.playlist);
} else { } else {
@@ -460,10 +366,9 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
openShareDialog() { openShareDialog() {
const dialogRef = this.dialog.open(ShareMediaDialogComponent, { const dialogRef = this.dialog.open(ShareMediaDialogComponent, {
data: { data: {
uid: this.id ? this.id : this.uid, uid: this.playlist_id ? this.playlist_id : this.uid,
type: this.type, sharing_enabled: this.playlist_id ? this.db_playlist.sharingEnabled : this.db_file.sharingEnabled,
sharing_enabled: this.id ? this.db_playlist.sharingEnabled : this.db_file.sharingEnabled, is_playlist: !!this.playlist_id,
is_playlist: !!this.id,
uuid: this.postsService.isLoggedIn ? this.postsService.user.uid : null, uuid: this.postsService.isLoggedIn ? this.postsService.user.uid : null,
current_timestamp: this.api.time.current current_timestamp: this.api.time.current
}, },
@@ -471,13 +376,38 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
}); });
dialogRef.afterClosed().subscribe(res => { dialogRef.afterClosed().subscribe(res => {
if (!this.id) { if (!this.playlist_id) {
this.getFile(); this.getFile();
} else { } else {
this.getPlaylistFiles(); this.getPlaylistFiles();
} }
}); });
} }
openFileInfoDialog() {
this.dialog.open(VideoInfoDialogComponent, {
data: {
file: this.db_file,
},
minWidth: '50vw'
})
}
setPlaybackTimestamp(time) {
this.api.seekTime(time);
}
togglePlayback(to_play) {
if (to_play) {
this.api.play();
} else {
this.api.pause();
}
}
setPlaybackRate(speed) {
this.api.playbackRate = speed;
}
// snackbar helper // snackbar helper
public openSnackBar(message: string, action: string) { public openSnackBar(message: string, action: string) {

View File

@@ -171,33 +171,39 @@ export class PostsService implements CanActivate {
} }
// tslint:disable-next-line: max-line-length // tslint:disable-next-line: max-line-length
makeMP3(url: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, customOutput: string = null, youtubeUsername: string = null, youtubePassword: string = null, ui_uid = null) { downloadFile(url: string, type: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, customOutput: string = null, youtubeUsername: string = null, youtubePassword: string = null, ui_uid = null, cropFileSettings = null) {
return this.http.post(this.path + 'tomp3', {url: url, return this.http.post(this.path + 'downloadFile', {url: url,
maxBitrate: selectedQuality,
customQualityConfiguration: customQualityConfiguration,
customArgs: customArgs,
customOutput: customOutput,
youtubeUsername: youtubeUsername,
youtubePassword: youtubePassword,
ui_uid: ui_uid}, this.httpOptions);
}
// tslint:disable-next-line: max-line-length
makeMP4(url: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, customOutput: string = null, youtubeUsername: string = null, youtubePassword: string = null, ui_uid = null) {
return this.http.post(this.path + 'tomp4', {url: url,
selectedHeight: selectedQuality, selectedHeight: selectedQuality,
customQualityConfiguration: customQualityConfiguration, customQualityConfiguration: customQualityConfiguration,
customArgs: customArgs, customArgs: customArgs,
customOutput: customOutput, customOutput: customOutput,
youtubeUsername: youtubeUsername, youtubeUsername: youtubeUsername,
youtubePassword: youtubePassword, youtubePassword: youtubePassword,
ui_uid: ui_uid}, this.httpOptions); ui_uid: ui_uid,
type: type,
cropFileSettings: cropFileSettings}, this.httpOptions);
}
getDBInfo() {
return this.http.post(this.path + 'getDBInfo', {}, this.httpOptions);
}
transferDB(local_to_remote) {
return this.http.post(this.path + 'transferDB', {local_to_remote: local_to_remote}, this.httpOptions);
}
testConnectionString() {
return this.http.post(this.path + 'testConnectionString', {}, this.httpOptions);
} }
killAllDownloads() { killAllDownloads() {
return this.http.post(this.path + 'killAllDownloads', {}, this.httpOptions); return this.http.post(this.path + 'killAllDownloads', {}, this.httpOptions);
} }
restartServer() {
return this.http.post(this.path + 'restartServer', {}, this.httpOptions);
}
loadNavItems() { loadNavItems() {
if (isDevMode()) { if (isDevMode()) {
return this.http.get('./assets/default.json'); return this.http.get('./assets/default.json');
@@ -214,8 +220,8 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'setConfig', {new_config_file: config}, this.httpOptions); return this.http.post(this.path + 'setConfig', {new_config_file: config}, this.httpOptions);
} }
deleteFile(uid: string, type: string, blacklistMode = false) { deleteFile(uid: string, blacklistMode = false) {
return this.http.post(this.path + 'deleteFile', {uid: uid, type: type, blacklistMode: blacklistMode}, this.httpOptions); return this.http.post(this.path + 'deleteFile', {uid: uid, blacklistMode: blacklistMode}, this.httpOptions);
} }
getMp3s() { getMp3s() {
@@ -242,22 +248,43 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'downloadTwitchChatByVODID', {id: id, type: type, vodId: vodId, uuid: uuid, sub: sub}, this.httpOptions); return this.http.post(this.path + 'downloadTwitchChatByVODID', {id: id, type: type, vodId: vodId, uuid: uuid, sub: sub}, this.httpOptions);
} }
downloadFileFromServer(fileName, type, outputName = null, fullPathProvided = null, subscriptionName = null, subPlaylist = null, downloadFileFromServer(uid, uuid = null, sub_id = null, is_playlist = null) {
uid = null, uuid = null, id = null) { return this.http.post(this.path + 'downloadFileFromServer', {
return this.http.post(this.path + 'downloadFile', {fileNames: fileName,
type: type,
zip_mode: Array.isArray(fileName),
outputName: outputName,
fullPathProvided: fullPathProvided,
subscriptionName: subscriptionName,
subPlaylist: subPlaylist,
uuid: uuid,
uid: uid, uid: uid,
id: id uuid: uuid,
sub_id: sub_id,
is_playlist: is_playlist
}, },
{responseType: 'blob', params: this.httpOptions.params}); {responseType: 'blob', params: this.httpOptions.params});
} }
downloadPlaylistFromServer(playlist_id, uuid = null) {
return this.http.post(this.path + 'downloadFileFromServer', {
uuid: uuid,
playlist_id: playlist_id
},
{responseType: 'blob', params: this.httpOptions.params});
}
downloadSubFromServer(sub_id, uuid = null) {
return this.http.post(this.path + 'downloadFileFromServer', {
uuid: uuid,
sub_id: sub_id
},
{responseType: 'blob', params: this.httpOptions.params});
}
checkConcurrentStream(uid) {
return this.http.post(this.path + 'checkConcurrentStream', {uid: uid}, this.httpOptions);
}
updateConcurrentStream(uid, playback_timestamp, unix_timestamp, playing) {
return this.http.post(this.path + 'updateConcurrentStream', {uid: uid,
playback_timestamp: playback_timestamp,
unix_timestamp: unix_timestamp,
playing: playing}, this.httpOptions);
}
uploadCookiesFile(fileFormData) { uploadCookiesFile(fileFormData) {
return this.http.post(this.path + 'uploadCookies', fileFormData, this.httpOptions); return this.http.post(this.path + 'uploadCookies', fileFormData, this.httpOptions);
} }
@@ -282,29 +309,29 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'generateNewAPIKey', {}, this.httpOptions); return this.http.post(this.path + 'generateNewAPIKey', {}, this.httpOptions);
} }
enableSharing(uid, type, is_playlist) { enableSharing(uid, is_playlist) {
return this.http.post(this.path + 'enableSharing', {uid: uid, type: type, is_playlist: is_playlist}, this.httpOptions); return this.http.post(this.path + 'enableSharing', {uid: uid, is_playlist: is_playlist}, this.httpOptions);
}
disableSharing(uid, is_playlist) {
return this.http.post(this.path + 'disableSharing', {uid: uid, is_playlist: is_playlist}, this.httpOptions);
} }
incrementViewCount(file_uid, sub_id, uuid) { incrementViewCount(file_uid, sub_id, uuid) {
return this.http.post(this.path + 'incrementViewCount', {file_uid: file_uid, sub_id: sub_id, uuid: uuid}, this.httpOptions); return this.http.post(this.path + 'incrementViewCount', {file_uid: file_uid, sub_id: sub_id, uuid: uuid}, this.httpOptions);
} }
disableSharing(uid, type, is_playlist) { createPlaylist(playlistName, uids, type, thumbnailURL) {
return this.http.post(this.path + 'disableSharing', {uid: uid, type: type, is_playlist: is_playlist}, this.httpOptions);
}
createPlaylist(playlistName, fileNames, type, thumbnailURL, duration = null) {
return this.http.post(this.path + 'createPlaylist', {playlistName: playlistName, return this.http.post(this.path + 'createPlaylist', {playlistName: playlistName,
fileNames: fileNames, uids: uids,
type: type, type: type,
thumbnailURL: thumbnailURL, thumbnailURL: thumbnailURL}, this.httpOptions);
duration: duration}, this.httpOptions);
} }
getPlaylist(playlistID, type, uuid = null) { getPlaylist(playlist_id, uuid = null, include_file_metadata = false) {
return this.http.post(this.path + 'getPlaylist', {playlistID: playlistID, return this.http.post(this.path + 'getPlaylist', {playlist_id: playlist_id,
type: type, uuid: uuid}, this.httpOptions); uuid: uuid,
include_file_metadata: include_file_metadata}, this.httpOptions);
} }
updatePlaylist(playlist) { updatePlaylist(playlist) {
@@ -357,14 +384,17 @@ export class PostsService implements CanActivate {
} }
updateSubscription(subscription) { updateSubscription(subscription) {
delete subscription['videos'];
return this.http.post(this.path + 'updateSubscription', {subscription: subscription}, this.httpOptions); return this.http.post(this.path + 'updateSubscription', {subscription: subscription}, this.httpOptions);
} }
unsubscribe(sub, deleteMode = false) { unsubscribe(sub, deleteMode = false) {
delete sub['videos'];
return this.http.post(this.path + 'unsubscribe', {sub: sub, deleteMode: deleteMode}, this.httpOptions) return this.http.post(this.path + 'unsubscribe', {sub: sub, deleteMode: deleteMode}, this.httpOptions)
} }
deleteSubscriptionFile(sub, file, deleteForever, file_uid) { deleteSubscriptionFile(sub, file, deleteForever, file_uid) {
delete sub['videos'];
return this.http.post(this.path + 'deleteSubscriptionFile', {sub: sub, file: file, deleteForever: deleteForever, return this.http.post(this.path + 'deleteSubscriptionFile', {sub: sub, file: file, deleteForever: deleteForever,
file_uid: file_uid}, this.httpOptions) file_uid: file_uid}, this.httpOptions)
} }

View File

@@ -159,7 +159,7 @@ export const isoLangs = {
}, },
'nl': { 'nl': {
'name': 'Dutch', 'name': 'Dutch',
'nativeName': 'Nederlands, Vlaams' 'nativeName': 'Nederlands'
}, },
'en': { 'en': {
'name': 'English', 'name': 'English',

View File

@@ -140,7 +140,7 @@
<mat-divider></mat-divider> <mat-divider></mat-divider>
<div *ngIf="new_config" class="container-fluid"> <div *ngIf="new_config" class="container-fluid">
<div class="row"> <div class="row">
<div class="col-12 mt-3 mb-2"> <div class="col-12 mt-3">
<h6 i18n="Categories">Categories</h6> <h6 i18n="Categories">Categories</h6>
<div cdkDropList class="category-list" (cdkDropListDropped)="dropCategory($event)"> <div cdkDropList class="category-list" (cdkDropListDropped)="dropCategory($event)">
<div class="category-box" *ngFor="let category of postsService.categories" cdkDrag> <div class="category-box" *ngFor="let category of postsService.categories" cdkDrag>
@@ -154,6 +154,9 @@
</div> </div>
<button style="margin-top: 10px;" mat-mini-fab (click)="openAddCategoryDialog()"><mat-icon>add</mat-icon></button> <button style="margin-top: 10px;" mat-mini-fab (click)="openAddCategoryDialog()"><mat-icon>add</mat-icon></button>
</div> </div>
<div class="col-12 mt-2 mb-2">
<mat-checkbox [(ngModel)]="new_config['Extra']['allow_playlist_categorization']" matTooltip="With this setting enabled, if a single video matches a category, the entire playlist will receive that category." i18n-matTooltip="Allow playlist categorization setting tooltip"><ng-container i18n="Allow playlist categorization setting label">Allow playlist categorization</ng-container></mat-checkbox>
</div>
</div> </div>
</div> </div>
<mat-divider></mat-divider> <mat-divider></mat-divider>
@@ -216,7 +219,7 @@
<div class="enable-api-key-div"> <div class="enable-api-key-div">
<mat-form-field class="text-field" color="accent"> <mat-form-field class="text-field" color="accent">
<input [disabled]="!new_config['API']['use_API_key']" [(ngModel)]="new_config['API']['API_key']" matInput placeholder="Public API Key" i18n-placeholder="Public API Key setting placeholder" required> <input [disabled]="!new_config['API']['use_API_key']" [(ngModel)]="new_config['API']['API_key']" matInput placeholder="Public API Key" i18n-placeholder="Public API Key setting placeholder" required>
<mat-hint><a target="_blank" href="https://stoplight.io/p/docs/gh/tzahi12345/youtubedl-material"><ng-container i18n="View API docs setting hint">View documentation</ng-container></a></mat-hint> <mat-hint><a target="_blank" href="https://youtubedl-material.stoplight.io/docs/youtubedl-material/Public%20API%20v1.yaml"><ng-container i18n="View API docs setting hint">View documentation</ng-container></a></mat-hint>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="api-key-div"> <div class="api-key-div">
@@ -277,6 +280,43 @@
</div> </div>
</ng-template> </ng-template>
</mat-tab> </mat-tab>
<!-- Database -->
<mat-tab label="Database" i18n-label="Database settings label">
<ng-template matTabContent>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12 mt-3">
<div *ngIf="db_info">
<h5 i18n="Database info title">Database Info</h5>
<p><ng-container i18n="Database location label">Database location:</ng-container>&nbsp;<strong>{{db_info['using_local_db'] ? 'Local' : 'MongoDB'}}</strong></p>
<h6 i18n="Records per table label">Records per table</h6>
<mat-list style="padding-top: 0px">
<mat-list-item style="height: 28px" *ngFor="let table_stats of db_info['stats_by_table'] | keyvalue">
{{table_stats.key}}: {{table_stats.value.records_count}}
</mat-list-item>
</mat-list>
<mat-form-field style="width: 100%; margin-top: 15px; margin-bottom: 10px" color="accent">
<input [(ngModel)]="new_config['Database']['mongodb_connection_string']" matInput placeholder="MongoDB Connection String" i18n-placeholder="MongoDB Connection String" required>
<mat-hint><ng-container i18n="MongoDB Connection String setting hint AKA preamble">Example:</ng-container>&nbsp;mongodb://127.0.0.1:27017/?compressors=zlib</mat-hint>
</mat-form-field>
<br>
<button (click)="testConnectionString()" [disabled]="testing_connection_string" mat-flat-button color="accent"><ng-container i18n="Test connection string button">Test connection string</ng-container></button>
<br>
<button class="transfer-db-button" [disabled]="db_transferring" color="accent" (click)="transferDB()" mat-raised-button><ng-container i18n="Transfer DB button">Transfer DB to </ng-container>{{db_info['using_local_db'] ? 'MongoDB' : 'Local'}}</button>
</div>
<div *ngIf="!db_info">
<ng-container i18n="Database info not retrieved error message">Database information could not be retrieved. Check the server logs for more information.</ng-container>
</div>
</div>
</div>
</div>
</ng-template>
</mat-tab>
<!-- Advanced --> <!-- Advanced -->
<mat-tab label="Advanced" i18n-label="Host settings label"> <mat-tab label="Advanced" i18n-label="Host settings label">
<ng-template matTabContent> <ng-template matTabContent>
@@ -350,6 +390,14 @@
<div *ngIf="new_config" class="container-fluid mt-1"> <div *ngIf="new_config" class="container-fluid mt-1">
<app-updater></app-updater> <app-updater></app-updater>
</div> </div>
<mat-divider></mat-divider>
<div *ngIf="new_config" class="container">
<div class="row">
<div class="col-12 mt-4">
<button (click)="restartServer()" mat-stroked-button color="warn"><ng-container i18n="Restart server button">Restart server</ng-container></button>
</div>
</div>
</div>
</ng-template> </ng-template>
</mat-tab> </mat-tab>
<mat-tab *ngIf="postsService.config && postsService.config.Advanced.multi_user_mode" label="Users" i18n-label="Users settings label"> <mat-tab *ngIf="postsService.config && postsService.config.Advanced.multi_user_mode" label="Users" i18n-label="Users settings label">

View File

@@ -77,8 +77,13 @@
} }
.category-custom-placeholder { .category-custom-placeholder {
background: #ccc; background: #ccc;
border: dotted 3px #999; border: dotted 3px #999;
min-height: 60px; min-height: 60px;
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.transfer-db-button {
margin-top: 10px;
margin-bottom: 10px;
} }

View File

@@ -20,7 +20,7 @@ import { EditCategoryDialogComponent } from 'app/dialogs/edit-category-dialog/ed
}) })
export class SettingsComponent implements OnInit { export class SettingsComponent implements OnInit {
all_locales = isoLangs; all_locales = isoLangs;
supported_locales = ['en', 'es', 'de', 'fr', 'zh', 'nb', 'en-GB']; supported_locales = ['en', 'es', 'de', 'fr', 'nl', 'zh', 'nb', 'it', 'en-GB'];
initialLocale = localStorage.getItem('locale'); initialLocale = localStorage.getItem('locale');
initial_config = null; initial_config = null;
@@ -29,6 +29,10 @@ export class SettingsComponent implements OnInit {
generated_bookmarklet_code = null; generated_bookmarklet_code = null;
bookmarkletAudioOnly = false; bookmarkletAudioOnly = false;
db_info = null;
db_transferring = false;
testing_connection_string = false;
_settingsSame = true; _settingsSame = true;
latestGithubRelease = null; latestGithubRelease = null;
@@ -48,6 +52,7 @@ export class SettingsComponent implements OnInit {
ngOnInit() { ngOnInit() {
this.getConfig(); this.getConfig();
this.getDBInfo();
this.generated_bookmarklet_code = this.sanitizer.bypassSecurityTrustUrl(this.generateBookmarkletCode()); this.generated_bookmarklet_code = this.sanitizer.bypassSecurityTrustUrl(this.generateBookmarkletCode());
@@ -255,6 +260,68 @@ export class SettingsComponent implements OnInit {
}); });
} }
restartServer() {
this.postsService.restartServer().subscribe(res => {
this.postsService.openSnackBar('Restarting!');
}, err => {
this.postsService.openSnackBar('Failed to restart the server.');
});
}
getDBInfo() {
this.postsService.getDBInfo().subscribe(res => {
this.db_info = res['db_info'];
});
}
transferDB() {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
dialogTitle: 'Transfer DB',
dialogText: `Are you sure you want to transfer the DB?`,
submitText: 'Transfer',
}
});
dialogRef.afterClosed().subscribe(confirmed => {
if (confirmed) {
this._transferDB();
}
});
}
_transferDB() {
this.db_transferring = true;
this.postsService.transferDB(this.db_info['using_local_db']).subscribe(res => {
this.db_transferring = false;
const success = res['success'];
if (success) {
this.openSnackBar('Successfully transfered DB! Reloading info...');
this.getDBInfo();
} else {
this.openSnackBar('Failed to transfer DB -- transfer was aborted. Error: ' + res['error']);
}
}, err => {
this.db_transferring = false;
this.openSnackBar('Failed to transfer DB -- API call failed. See browser logs for details.');
console.error(err);
});
}
testConnectionString() {
this.testing_connection_string = true;
this.postsService.testConnectionString().subscribe(res => {
this.testing_connection_string = false;
if (res['success']) {
this.postsService.openSnackBar('Connection successful!');
} else {
this.postsService.openSnackBar('Connection failed! Error: ' + res['error']);
}
}, err => {
this.testing_connection_string = false;
this.postsService.openSnackBar('Connection failed! Error: Server error. See logs for more info.');
});
}
// snackbar helper // snackbar helper
public openSnackBar(message: string, action: string = '') { public openSnackBar(message: string, action: string = '') {
this.snackBar.open(message, action, { this.snackBar.open(message, action, {

View File

@@ -42,7 +42,7 @@ export class SubscriptionFileCardComponent implements OnInit {
goToFile() { goToFile() {
const emit_obj = { const emit_obj = {
name: this.file.id, uid: this.file.uid,
url: this.file.requested_formats ? this.file.requested_formats[0].url : this.file.url url: this.file.requested_formats ? this.file.requested_formats[0].url : this.file.url
} }
this.goToFileEmit.emit(emit_obj); this.goToFileEmit.emit(emit_obj);

View File

@@ -103,15 +103,14 @@ export class SubscriptionComponent implements OnInit, OnDestroy {
} }
goToFile(emit_obj) { goToFile(emit_obj) {
const name = emit_obj['name']; const uid = emit_obj['uid'];
const url = emit_obj['url']; const url = emit_obj['url'];
localStorage.setItem('player_navigator', this.router.url); localStorage.setItem('player_navigator', this.router.url);
if (this.subscription.streamingOnly) { if (this.subscription.streamingOnly) {
this.router.navigate(['/player', {name: name, url: url}]); this.router.navigate(['/player', {uid: uid, url: url}]);
} else { } else {
this.router.navigate(['/player', {fileNames: name, this.router.navigate(['/player', {uid: uid,
type: this.subscription.type ? this.subscription.type : 'video', subscriptionName: this.subscription.name, sub_id: this.subscription.id}]);
subPlaylist: this.subscription.isPlaylist}]);
} }
} }
@@ -154,7 +153,7 @@ export class SubscriptionComponent implements OnInit, OnDestroy {
} }
this.downloading = true; this.downloading = true;
this.postsService.downloadFileFromServer(fileNames, 'video', this.subscription.name, true).subscribe(res => { this.postsService.downloadSubFromServer(this.subscription.id).subscribe(res => {
this.downloading = false; this.downloading = false;
const blob: Blob = res; const blob: Blob = res;
saveAs(blob, this.subscription.name + '.zip'); saveAs(blob, this.subscription.name + '.zip');

View File

@@ -20,7 +20,8 @@
"download_only_mode": false, "download_only_mode": false,
"allow_multi_download_mode": true, "allow_multi_download_mode": true,
"settings_pin_required": false, "settings_pin_required": false,
"enable_downloads_manager": true "enable_downloads_manager": true,
"allow_playlist_categorization": true
}, },
"API": { "API": {
"use_API_key": false, "use_API_key": false,
@@ -28,7 +29,8 @@
"use_youtube_API": false, "use_youtube_API": false,
"youtube_API_key": "", "youtube_API_key": "",
"use_twitch_API": false, "use_twitch_API": false,
"twitch_API_key": "" "twitch_API_key": "",
"twitch_auto_download_chat": true
}, },
"Themes": { "Themes": {
"default_theme": "default", "default_theme": "default",
@@ -37,7 +39,7 @@
"Subscriptions": { "Subscriptions": {
"allow_subscriptions": true, "allow_subscriptions": true,
"subscriptions_base_path": "subscriptions/", "subscriptions_base_path": "subscriptions/",
"subscriptions_check_interval": "300", "subscriptions_check_interval": "86400",
"subscriptions_use_youtubedl_archive": true "subscriptions_use_youtubedl_archive": true
}, },
"Users": { "Users": {
@@ -52,6 +54,10 @@
"searchFilter": "(uid={{username}})" "searchFilter": "(uid={{username}})"
} }
}, },
"Database": {
"mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib",
"use_local_db": false
},
"Advanced": { "Advanced": {
"use_default_downloading_agent": true, "use_default_downloading_agent": true,
"custom_downloading_agent": "", "custom_downloading_agent": "",
@@ -60,7 +66,7 @@
"jwt_expiration": 86400, "jwt_expiration": 86400,
"logger_level": "debug", "logger_level": "debug",
"use_cookies": false, "use_cookies": false,
"default_downloader": "youtube-dlc" "default_downloader": "youtube-dl"
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -241,5 +241,31 @@
"3697f8583ea42868aa269489ad366103d94aece7": "Editando", "3697f8583ea42868aa269489ad366103d94aece7": "Editando",
"0c43af932e6a4ee85500e28f01b3538b4eb27bc4": "Nivel de registro", "0c43af932e6a4ee85500e28f01b3538b4eb27bc4": "Nivel de registro",
"a8b7b9c168fd936a75e500806a8c0d7755ef1198": "NOTA: La carga de cookies nuevas anulará las cookies anteriores y las cookies son para toda la instancia, no por usuario.", "a8b7b9c168fd936a75e500806a8c0d7755ef1198": "NOTA: La carga de cookies nuevas anulará las cookies anteriores y las cookies son para toda la instancia, no por usuario.",
"511b600ae4cf037e4eb3b7a58410842cd5727490": "Agregar más contenido" "511b600ae4cf037e4eb3b7a58410842cd5727490": "Agregar más contenido",
"56a2a773fbd5a6b9ac2e6b89d29d70a2ed0f3227": "Ver menos.",
"ddc31f2885b1b33a7651963254b0c197f2a64086": "Ver más.",
"c776eb4992b6c98f58cd89b20c1ea8ac37888521": "Seleccione un agente de descarga",
"5fb1e0083c9b2a40ac8ae7dcb2618311c291b8b9": "Descarga automática de Twitch Chat",
"84ffcebac2709ca0785f4a1d5ba274433b5beabc": "También conocido como ID de cliente.",
"8ae23bc4302a479f687f4b20a84c276182e2519c": "Clave de API de Twitch",
"d162f9fcd6a7187b391e004f072ab3da8377c47d": "Usar la API de Twitch",
"04201f9d27abd7d6f58a4328ab98063ce1072006": "Categorías",
"ef418d4ece7c844f3a5e431da1aa59bedd88da7b": "Args personalizados globales",
"1148fd45287ff09955b938756bc302042bcb29c7": "La ruta es relativa a las rutas de descarga anteriores. No incluya la extensión.",
"cfe829634b1144bc44b6d38cf5584ea65db9804f": "Salida de archivo predeterminada",
"3d1a47dc18b7bd8b5d9e1eb44b235ed9c4a2b513": "Volver a descargar nuevas cargas",
"13759b09a7f4074ceee8fa2f968f9815fdf63295": "A veces, los videos nuevos se descargan antes de procesarse por completo. Esta configuración significará que los nuevos videos se verificarán para una versión de mayor calidad al día siguiente.",
"dad95154dcef3509b8cc705046061fd24994bbb7": "vistas",
"792dc6a57f28a1066db283f2e736484f066005fd": "Descargar Twitch Chat",
"e4eeb9106dbcbc91ca1ac3fb4068915998a70f37": "Añadir nueva regla",
"2489eefea00931942b91f4a1ae109514b591e2e1": "Reglas",
"c3b0b86523f1d10e84a71f9b188d54913a11af3b": "Editando la categoría",
"07db550ae114d9faad3a0cbb68bcc16ab6cd31fc": "Pausado",
"73423607944a694ce6f9e55cfee329681bb4d9f9": "No se encontraron vídeos.",
"29376982b1205d9d6ea3d289e8e2f8e1ac2839b1": "Orden inverso",
"33026f57ea65cd9c8a5d917a08083f71a718933a": "Orden normal",
"5caadefa4143cf6766a621b0f54f91f373a1f164": "Añadir contenido",
"0cc1dec590ecd74bef71a865fb364779bc42a749": "Categoría:",
"303e45ffae995c9817e510e38cb969e6bb3adcbf": "(Pausado)",
"d641b8fa5ac5e85114c733b1f7de6976bd091f70": "Calidad máxima"
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,249 @@
{
"17f0ea5d2d7a262b0e875acc70475f102aee84e6": "Crea una scaletta",
"cff1428d10d59d14e45edec3c735a27b5482db59": "Nome",
"f0baeb8b69d120073b6d60d34785889b0c3232c8": "Audio",
"2d1ea268a6a9f483dbc2cbfe19bf4256a57a6af4": "Video",
"f61c6867295f3b53d23557021f2f4e0aa1d0b8fc": "Tipo",
"f47e2d56dd8a145b2e9599da9730c049d52962a2": "File audio",
"a52dae09be10ca3a65da918533ced3d3f4992238": "Video",
"d9e83ac17026e70ef6e9c0f3240a3b2450367f40": "Modifica i parametri di youtube-dl",
"7fc1946abe2b40f60059c6cd19975d677095fd19": "Simula i nuovi parametri",
"0b71824ae71972f236039bed43f8d2323e8fd570": "Aggiungi un'impostazione",
"c8b0e59eb491f2ac7505f0fbab747062e6b32b23": "Cerca per categoria",
"9eeb91caef5a50256dd87e1c4b7b3e8216479377": "Usa valore impostato",
"25d8ad5eba2ec24e68295a27d6a4bb9b49e3dacd": "Valore impostato",
"7de2451ed3fb8d8b847979bd3f0c740b970f167b": "Aggiungi impostazione",
"d7b35c384aecd25a516200d6921836374613dfe7": "Annulla",
"b2623aee44b70c9a4ba1fce16c8a593b0a4c7974": "Modifica",
"a38ae1082fec79ba1f379978337385a539a28e73": "Qualità",
"4be966a9dcfbc9b54dfcc604b831c0289f847fa4": "Utilizza URL",
"d3f02f845e62cebd75fde451ab8479d2a8ad784d": "Visualizza",
"4a9889d36910edc8323d7bab60858ab3da6d91df": "Solo audio",
"96a01fafe135afc58b0f8071a4ab00234495ce18": "Modalità download multiplo",
"6a21ba5fb0ac804a525bf9ab168038c3ee88e661": "Scarica",
"6a3777f913cf3f288664f0632b9f24794fdcc24e": "Annulla",
"322ed150e02666fe2259c5b4614eac7066f4ffa0": "Avanzato",
"b7ffe7c6586d6f3f18a9246806a7c7d5538ab43e": "Comando simulato:",
"4e4c721129466be9c3862294dc40241b64045998": "Usa parametri personalizzati",
"ad2f8ac8b7de7945b80c8e424484da94e597125f": "Parametri personalizzati",
"a6911c2157f1b775284bbe9654ce5eb30cf45d7f": "Non è necessario includere l'URL, solo ciò che viene dopo. I parametri sono delimitati da due vigole: ,,",
"3a92a3443c65a52f37ca7efb8f453b35dbefbf29": "Usa output personalizzato",
"d9c02face477f2f9cdaae318ccee5f89856851fb": "Output personalizzata",
"fcfd4675b4c90f08d18d3abede9a9a4dff4cfdc7": "Documentazione",
"19d1ae64d94d28a29b2c57ae8671aace906b5401": "Il percorso è relativo al percorso di download configurato. Non includere l'estensione.",
"8fad10737d3e3735a6699a4d89cbf6c20f6bb55f": "Utilizza l'autenticazione",
"08c74dc9762957593b91f6eb5d65efdfc975bf48": "Nome utente",
"c32ef07f8803a223a83ed17024b38e8d82292407": "Password",
"616e206cb4f25bd5885fc35925365e43cf5fb929": "Nome:",
"c52db455cca9109ee47e1a612c3f4117c09eb71b": "URL:",
"c6eb45d085384903e53ab001a3513d1de6a1dbac": "Caricato da:",
"109c6f4a5e46efb933612ededfaf52a13178b7e0": "Dimensioni file:",
"bd630d8669b16e5f264ec4649d9b469fe03e5ff4": "Percorso:",
"a67e7d843cef735c79d5ef1c8ba4af3e758912bb": "Data di caricamento:",
"f4e529ae5ffd73001d1ff4bbdeeb0a72e342e5c8": "Chiudi",
"4f389e41e4592f7f9bb76abdd8af4afdfb13f4f1": "Modifica playlist",
"511b600ae4cf037e4eb3b7a58410842cd5727490": "Aggiungi più contenuto",
"52c9a103b812f258bcddc3d90a6e3f46871d25fe": "Salva",
"ca3dbbc7f3e011bffe32a10a3ea45cc84f30ecf1": "ID:",
"e684046d73bcee88e82f7ff01e2852789a05fc32": "Conteggio:",
"28f86ffd419b869711aa13f5e5ff54be6d70731c": "Modifica",
"826b25211922a1b46436589233cb6f1a163d89b7": "Elimina",
"321e4419a943044e674beb55b8039f42a9761ca5": "Informazioni",
"34504b488c24c27e68089be549f0eeae6ebaf30b": "Elimina e aggiungi alla lista nera",
"ebadf946ae90f13ecd0c70f09edbc0f983af8a0f": "Carica nuovi cookie",
"98a8a42e5efffe17ab786636ed0139b4c7032d0e": "Trascina e rilascia",
"a8b7b9c168fd936a75e500806a8c0d7755ef1198": "NOTA: il caricamento di nuovi cookie sovrascriverà i cookie precedenti. Inoltre tieni presente che i cookie sono a livello di processo, non per utente.",
"121cc5391cd2a5115bc2b3160379ee5b36cd7716": "Impostazioni",
"801b98c6f02fe3b32f6afa3ee854c99ed83474e6": "URL",
"54c512cca1923ab72faf1a0bd98d3d172469629a": "URL con cui si accederà a questa applicazione, senza porta.",
"cb2741a46e3560f6bc6dfd99d385e86b08b26d72": "Porta",
"22e8f1d0423a3b784fe40fab187b92c06541b577": "Porta personalizzata. La predefinita è 17442.",
"d4477669a560750d2064051a510ef4d7679e2f3e": "Modalità multiutente",
"2eb03565fcdce7a7a67abc277a936a32fcf51557": "Percorso profili utente",
"a64505c41150663968e277ec9b3ddaa5f4838798": "Percorso per profili utente e per video scaricati da ognuno.",
"4e3120311801c4acd18de7146add2ee4a4417773": "Consenti iscrizioni",
"4bee2a4bef2d26d37c9b353c278e24e5cd309ce3": "Percorso salvataggio playlist sottoscritte",
"bc9892814ee2d119ae94378c905ea440a249b84a": "Percorso salvataggio per i video dei canali e delle playlist sottoscritte. È relativo alla cartella principale di YTDL-Material.",
"5bef4b25ba680da7fff06b86a91b1fc7e6a926e3": "Intervallo di verifica",
"0f56a7449b77630c114615395bbda4cab398efd8": "Unità in secondi, inserire solo numeri.",
"27a56aad79d8b61269ed303f11664cc78bcc2522": "Tema",
"ff7cee38a2259526c519f878e71b964f41db4348": "Predefinito",
"adb4562d2dbd3584370e44496969d58c511ecb63": "Scuro",
"7a6bacee4c31cb5c0ac2d24274fb4610d8858602": "Consenti variazione tema",
"fe46ccaae902ce974e2441abe752399288298619": "Lingua",
"82421c3e46a0453a70c42900eab51d58d79e6599": "Principale",
"ab2756805742e84ad0cc0468f4be2d8aa9f855a5": "Percorso della cartella audio",
"c2c89cdf45d46ea64d2ed2f9ac15dfa4d77e26ca": "Percorso per download solo audio. È relativo alla cartella principale di YTDL-Material.",
"46826331da1949bd6fb74624447057099c9d20cd": "Percorso cartella Video",
"17c92e6d47a213fa95b5aa344b3f258147123f93": "Percorso per il download di video. È relativo alla cartella principale di YTDL-Material.",
"6b995e7130b4d667eaab6c5f61b362ace486d26d": "Parametri personalizzati generali per i download sulla home page. I parametri sono delimitati da due virgole: ,,",
"78e49b7339b4fa7184dd21bcaae107ce9b7076f6": "Usa l'archivio youtube-dl",
"ffc19f32b1cba0daefc0e5668f89346db1db83ad": "Includi anteprima",
"384de8f8f112c9e6092eb2698706d391553f3e8d": "Includi metadati",
"fb35145bfb84521e21b6385363d59221f436a573": "Interrompi tutti i download",
"0ba25ad86a240576c4f20a2fada4722ebba77b1e": "Scaricato da",
"61f8fd90b5f8cb20c70371feb2ee5e1fac5a9095": "Titolo della barra superiore",
"78d3531417c0d4ba4c90f0d4ae741edc261ec8df": "Abilita il file manager",
"a5a1be0a5df07de9eec57f5d2a86ed0204b2e75a": "Abilita il download manager",
"c33bd5392b39dbed36b8e5a1145163a15d45835f": "Consenti la selezione della qualità",
"bda5508e24e0d77debb28bcd9194d8fefb1cfb92": "Modalità solo download",
"09d31c803a7252658694e1e3176b97f5655a3fe3": "Consenti la modalità di download multiplo",
"1c4dbce56d96b8974aac24a02f7ab2ee81415014": "Abilita l'API Pubblica",
"23bd81dcc30b74d06279a26d7a42e8901c1b124e": "Chiave API Pubblica",
"41016a73d8ad85e6cb26dffa0a8fab9fe8f60d8e": "Visualizza la documentazione",
"1b258b258b4cc475ceb2871305b61756b0134f4a": "Genera",
"00a94f58d9eb2e3aa561440eabea616d0c937fa2": "Cancellerai la tua chiave API precedente!",
"d5d7c61349f3b0859336066e6d453fc35d334fe5": "Usa l'API di YouTube",
"ce10d31febb3d9d60c160750570310f303a22c22": "Chiave API YouTube",
"8602e313cdfa7c4cc475ccbe86459fce3c3fd986": "Generare una chiave è facile!",
"9b3cedfa83c6d7acb3210953289d1be4aab115c7": "Premi qui",
"7f09776373995003161235c0c8d02b7f91dbc4df": "per scaricare manualmente l'estensione ufficiale per Chrome di YoutubeDL-Material.",
"5b5296423906ab3371fdb2b5a5aaa83acaa2ee52": "È necessario installare manualmente l'estensione e modificarne le impostazioni inserendo l'URL della pagina principale.",
"9a2ec6da48771128384887525bdcac992632c863": "per installare l'estensione ufficiale YoutubeDL-Material per Firefox direttamente dalla pagina delle estensioni di Firefox.",
"eb81be6b49e195e5307811d1d08a19259d411f37": "Istruzioni dettagliate per la configurazione.",
"cb17ff8fe3961cf90f44bee97c88a3f3347a7e55": "Viene richiesto semplicemente di modificare le impostazioni dell'estensione, inserendo l'URL della pagina principale.",
"61b81b11aad0b9d970ece2fce18405f07eac69c2": "Trascina il link qui sotto tra tuoi preferiti e sei a posto! Vai al video YouTube che desideri scaricare e fai clic sul preferito.",
"c505d6c5de63cc700f0aaf8a4b31fae9e18024e5": "Genera un preferito \"solo audio\"",
"d5f69691f9f05711633128b5a3db696783266b58": "Extra",
"5fab47f146b0a4b809dcebf3db9da94df6299ea1": "Usa agente di download predefinito",
"ec71e08aee647ea4a71fd6b7510c54d84a797ca6": "Seleziona un metodo di download",
"0c43af932e6a4ee85500e28f01b3538b4eb27bc4": "Livello di Log",
"db6c192032f4cab809aad35215f0aa4765761897": "Scadenza accesso",
"dc3d990391c944d1fbfc7cfb402f7b5e112fb3a8": "Consenti download avanzato",
"431e5f3a0dde88768d1074baedd65266412b3f02": "Usa i cookie",
"80651a7ad1229ea6613557d3559f702cfa5aecf5": "Imposta i cookie",
"bc2e854e111ecf2bd7db170da5e3c2ed08181d88": "Avanzate",
"37224420db54d4bc7696f157b779a7225f03ca9d": "Consenti registrazione utente",
"4f56ced9d6b85aeb1d4346433361d47ea72dac1a": "Interno",
"e3d7c5f019e79a3235a28ba24df24f11712c7627": "LDAP",
"fa548cee6ea11c160a416cac3e6bdec0363883dc": "Metodo di autenticazione",
"1db9789b93069861019bd0ccaa5d4706b00afc61": "URL LDAP",
"f50fa6c09c8944aed504f6325f2913ee6c7a296a": "Associa DN",
"080cc6abcba236390fc22e79792d0d3443a3bd2a": "Associa credenziali",
"cfa67d14d84fe0e9fadf251dc51ffc181173b662": "Base di ricerca",
"e01d54ecc1a0fcf9525a3c100ed8b83d94e61c23": "Filtro di ricerca",
"4d13a9cd5ed3dcee0eab22cb25198d43886942be": "Utenti",
"eb3d5aefff38a814b76da74371cbf02c0789a1ef": "Registri",
"fe8fd36dbf5deee1d56564965787a782a66eba44": "{VAR_SELECT, seleziona, vero {Close} falso {Cancel} altro {otha}}",
"cec82c0a545f37420d55a9b6c45c20546e82f94e": "Informazioni su YoutubeDL-Material",
"199c17e5d6a419313af3c325f06dcbb9645ca618": "è un downloader di YouTube open source costruito secondo le specifiche Material Design di Google. Puoi scaricare agevolmente i tuoi video preferiti come file video o solo audio e persino iscriverti ai tuoi canali e playlist preferiti per tenerti aggiornato con i nuovi video pubblicati.",
"bc0ad0ee6630acb7fcb7802ec79f5a0ee943c1a7": "ha alcune fantastiche funzionalità incluse! Una solida API, supporto Docker e supporto per la localizzazione (traduzione). Leggi tutte le funzionalità supportate cliccando sull'icona GitHub in alto.",
"a45e3b05f0529dc5246d70ef62304c94426d4c81": "Versione installata:",
"e22f3a5351944f3a1a10cfc7da6f65dfbe0037fe": "Verifica aggiornamenti in corso...",
"a16e92385b4fd9677bb830a4b796b8b79c113290": "Aggiornamento disponibile",
"189b28aaa19b3c51c6111ad039c4fd5e2a22e370": "È possibile eseguire l'aggiornamento dal menù impostazioni.",
"b33536f59b94ec935a16bd6869d836895dc5300c": "Hai trovato un errore o hai un suggerimento?",
"e1f398f38ff1534303d4bb80bd6cece245f24016": "per segnalare un problema!",
"42ff677ec14f111e88bd6cdd30145378e994d1bf": "Il tuo profilo",
"ac9d09de42edca1296371e4d801349c9096ac8de": "UID:",
"a5ed099ffc9e96f6970df843289ade8a7d20ab9f": "Creato:",
"fa96f2137af0a24e6d6d54c598c0af7d5d5ad344": "Non hai eseguito l'accesso.",
"6765b4c916060f6bc42d9bb69e80377dbcb5e4e9": "Accedi",
"bb694b49d408265c91c62799c2b3a7e3151c824d": "Esci",
"a1dbca87b9f36d2b06a5cbcffb5814c4ae9b798a": "Crea un account amministratore",
"2d2adf3ca26a676bca2269295b7455a26fd26980": "Nessun account amministratore predefinito rilevato. Verrà creato e impostata la password per un account amministratore con il nome utente \"admin\".",
"70a67e04629f6d412db0a12d51820b480788d795": "Crea",
"994363f08f9fbfa3b3994ff7b35c6904fdff18d8": "Profilo",
"004b222ff9ef9dd4771b777950ca1d0e4cd4348a": "Informazioni",
"92eee6be6de0b11c924e3ab27db30257159c0a7c": "Pagina principale",
"357064ca9d9ac859eb618e28e8126fa32be049e2": "Iscrizioni",
"822fab38216f64e8166d368b59fe756ca39d301b": "Download",
"a249a5ae13e0835383885aaf697d2890cc3e53e9": "Condividi playlist",
"15da89490e04496ca9ea1e1b3d44fb5efd4a75d9": "Condividi il video",
"1d540dcd271b316545d070f9d182c372d923aadd": "Condividi l'audio",
"1f6d14a780a37a97899dc611881e6bc971268285": "Abilita la condivisione",
"6580b6a950d952df847cb3d8e7176720a740adc8": "Usa data e ora",
"4f2ed9e71a7c981db3e50ae2fedb28aff2ec4e6c": "Secondi",
"3a6e5a6aa78ca864f6542410c5dafb6334538106": "Copia negli appunti",
"5b3075e8dc3f3921ec316b0bd83b6d14a06c1a4f": "Salva le modifiche",
"4d8a18b04a1f785ecd8021ac824e0dfd5881dbfc": "Il download è riuscito",
"348cc5d553b18e862eb1c1770e5636f6b05ba130": "Si è verificato un errore",
"4f8b2bb476981727ab34ed40fde1218361f92c45": "Dettagli",
"e9aff8e6df2e2bf6299ea27bb2894c70bc48bd4d": "Si è verificato un errore:",
"77b0c73840665945b25bd128709aa64c8f017e1c": "Inizio download:",
"08ff9375ec078065bcdd7637b7ea65fce2979266": "Termine download:",
"ad127117f9471612f47d01eae09709da444a36a4": "Percorso(i) file:",
"a9806cf78ce00eb2613eeca11354a97e033377b8": "Iscriviti alla playlist o al canale",
"93efc99ae087fc116de708ecd3ace86ca237cf30": "URL della playlist o del canale",
"08f5d0ef937ae17feb1b04aff15ad88911e87baf": "Nome personalizzato",
"ea30873bd3f0d5e4fb2378eec3f0a1db77634a28": "Scarica tutti i file caricati",
"28a678e9cabf86e44c32594c43fa0e890135c20f": "Scarica i video caricati negli ultimi",
"c76a955642714b8949ff3e4b4990864a2e2cac95": "Modalità solo audio",
"408ca4911457e84a348cecf214f02c69289aa8f1": "Modalità solo streaming",
"f432e1a8d6adb12e612127978ce2e0ced933959c": "Questi vengono aggiunti dopo ai parametri standard.",
"98b6ec9ec138186d663e64770267b67334353d63": "File di uscita personalizzato",
"d0336848b0c375a1c25ba369b3481ee383217a4f": "Iscriviti",
"e78c0d60ac39787f62c9159646fe0b3c1ed55a1d": "Tipo:",
"a44d86aa1e6c20ced07aca3a7c081d8db9ded1c6": "Archivio:",
"8efc77bf327659c0fec1f518cf48a98cdcd9dddf": "Esporta archivio",
"3042bd3ad8dffcfeca5fd1ae6159fd1047434e95": "Annulla l'iscrizione",
"e2319dec5b4ccfb6ed9f55ccabd63650a8fdf547": "I tuoi abbonamenti",
"807cf11e6ac1cde912496f764c176bdfdd6b7e19": "Canali",
"29b89f751593e1b347eef103891b7a1ff36ec03f": "Nome non disponibile. Recupero del canale in corso.",
"4636cd4a1379c50d471e98786098c4d39e1e82ad": "Non sei iscritto a nessun canale.",
"47546e45bbb476baaaad38244db444c427ddc502": "Scalette",
"2e0a410652cb07d069f576b61eab32586a18320d": "Nome non disponibile. Recupero playlist in corso.",
"587b57ced54965d8874c3fd0e9dfedb987e5df04": "Non sei iscritto a nessuna playlist.",
"3697f8583ea42868aa269489ad366103d94aece7": "Modifica",
"7e892ba15f2c6c17e83510e273b3e10fc32ea016": "Cerca",
"2054791b822475aeaea95c0119113de3200f5e1c": "Durata:",
"94e01842dcee90531caa52e4147f70679bac87fe": "Elimina e scarica di nuovo",
"2031adb51e07a41844e8ba7704b054e98345c9c1": "Elimina definitivamente",
"91ecce65f1d23f9419d1c953cd6b7bc7f91c110e": "Aggiornato da",
"1372e61c5bd06100844bd43b98b016aabc468f62": "Seleziona una versione:",
"cfc2f436ec2beffb042e7511a73c89c372e86a6c": "Registrati",
"a1ad8b1be9be43b5183bd2c3186d4e19496f2a0b": "ID sessione:",
"eb98135e35af26a9a326ee69bd8ff104d36dd8ec": "(attuale)",
"b6c453e0e61faea184bbaf5c5b0a1e164f4de2a2": "Cancella tutti i download",
"7117fc42f860e86d983bfccfcf2654e5750f3406": "Nessun download disponibile!",
"b7ff2e2b909c53abe088fe60b9f4b6ac7757247f": "Registra un utente",
"024886ca34a6f309e3e51c2ed849320592c3faaa": "Nome utente",
"2bd201aea09e43fbfd3cd15ec0499b6755302329": "Gestisci utente",
"29c97c8e76763bb15b6d515648fa5bd1eb0f7510": "UID utente:",
"e70e209561583f360b1e9cefd2cbb1fe434b6229": "Nuova password",
"6498fa1b8f563988f769654a75411bb8060134b9": "Imposta nuova password",
"544e09cdc99a8978f48521d45f62db0da6dcf742": "Usa il ruolo predefinito",
"4f20f2d5a6882190892e58b85f6ccbedfa737952": "Sì",
"3d3ae7deebc5949b0c1c78b9847886a94321d9fd": "No",
"57c6c05d8ebf4ef1180c2705033c044f655bb2c4": "Gestisci ruolo",
"746f64ddd9001ac456327cd9a3d5152203a4b93c": "Nome utente",
"52c1447c1ec9570a2a3025c7e566557b8d19ed92": "Ruolo",
"59a8c38db3091a63ac1cb9590188dc3a972acfb3": "Azioni",
"632e8b20c98e8eec4059a605a4b011bb476137af": "Modifica utente",
"95b95a9c79e4fd9ed41f6855e37b3b06af25bcab": "Elimina utente",
"4d92a0395dd66778a931460118626c5794a3fc7a": "Aggiungi utenti",
"b0d7dd8a1b0349622d6e0c6e643e24a9ea0efa1d": "Modifica ruolo",
"5009630cdf32ab4f1c78737b9617b8773512c05a": "Linee:",
"8a0bda4c47f10b2423ff183acefbf70d4ab52ea2": "Cancella i registri",
"ccf5ea825526ac490974336cb5c24352886abc07": "Apri il file",
"5656a06f17c24b2d7eae9c221567b209743829a9": "Apri il file in una nuova scheda",
"a0720c36ee1057e5c54a86591b722485c62d7b1a": "Vai alle iscrizioni",
"d02888c485d3aeab6de628508f4a00312a722894": "I miei video",
"ef418d4ece7c844f3a5e431da1aa59bedd88da7b": "Parametri personalizzati generali",
"56a2a773fbd5a6b9ac2e6b89d29d70a2ed0f3227": "Nascondi.",
"84ffcebac2709ca0785f4a1d5ba274433b5beabc": "Conosciuto anche come Client ID.",
"cfe829634b1144bc44b6d38cf5584ea65db9804f": "File output predefinito",
"3d1a47dc18b7bd8b5d9e1eb44b235ed9c4a2b513": "Riscarica i nuovi contenuti",
"13759b09a7f4074ceee8fa2f968f9815fdf63295": "A volte i nuovi video vengono scaricati prima di essere completamente elaborati. Questa impostazione significa che per i nuovi video verrà effettuata una verifica il giorno successivo per la ricerca di versioni di qualità superiore.",
"ddc31f2885b1b33a7651963254b0c197f2a64086": "Vedi altro.",
"24dc3ecf7ec2c2144910c4f3d38343828be03a4c": "Generato automaticamente",
"c776eb4992b6c98f58cd89b20c1ea8ac37888521": "Seleziona un agente di download",
"5fb1e0083c9b2a40ac8ae7dcb2618311c291b8b9": "Scarica automaticamente le Chat Twitch",
"8ae23bc4302a479f687f4b20a84c276182e2519c": "Chiave dell'API Twitch",
"d162f9fcd6a7187b391e004f072ab3da8377c47d": "Usa l'API Twitch",
"04201f9d27abd7d6f58a4328ab98063ce1072006": "Categorie",
"1148fd45287ff09955b938756bc302042bcb29c7": "Il percorso è riferito ai percorsi di download sopra. Non includere l'estensione.",
"dad95154dcef3509b8cc705046061fd24994bbb7": "visualizzazioni",
"792dc6a57f28a1066db283f2e736484f066005fd": "Scarica Chat Twitch",
"e4eeb9106dbcbc91ca1ac3fb4068915998a70f37": "Aggiungi nuova regola",
"2489eefea00931942b91f4a1ae109514b591e2e1": "Regole",
"c3b0b86523f1d10e84a71f9b188d54913a11af3b": "Categoria in modifica",
"07db550ae114d9faad3a0cbb68bcc16ab6cd31fc": "In pausa",
"73423607944a694ce6f9e55cfee329681bb4d9f9": "Nessun video trovato.",
"29376982b1205d9d6ea3d289e8e2f8e1ac2839b1": "Ordine inverso",
"33026f57ea65cd9c8a5d917a08083f71a718933a": "Ordine normale",
"5caadefa4143cf6766a621b0f54f91f373a1f164": "Aggiungi contenuto",
"0cc1dec590ecd74bef71a865fb364779bc42a749": "Categoria:",
"303e45ffae995c9817e510e38cb969e6bb3adcbf": "(In pausa)",
"d641b8fa5ac5e85114c733b1f7de6976bd091f70": "Qualità massima"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,248 @@
{
"004b222ff9ef9dd4771b777950ca1d0e4cd4348a": "Over",
"994363f08f9fbfa3b3994ff7b35c6904fdff18d8": "Profiel",
"adb4562d2dbd3584370e44496969d58c511ecb63": "Donker",
"121cc5391cd2a5115bc2b3160379ee5b36cd7716": "Instellingen",
"92eee6be6de0b11c924e3ab27db30257159c0a7c": "Overzicht",
"6765b4c916060f6bc42d9bb69e80377dbcb5e4e9": "Inloggen",
"357064ca9d9ac859eb618e28e8126fa32be049e2": "Abonnementen",
"822fab38216f64e8166d368b59fe756ca39d301b": "Downloads",
"4a9889d36910edc8323d7bab60858ab3da6d91df": "Alleen audio",
"6a21ba5fb0ac804a525bf9ab168038c3ee88e661": "Downloaden",
"a38ae1082fec79ba1f379978337385a539a28e73": "Kwaliteit",
"4be966a9dcfbc9b54dfcc604b831c0289f847fa4": "URL gebruiken",
"d3f02f845e62cebd75fde451ab8479d2a8ad784d": "Bekijken",
"96a01fafe135afc58b0f8071a4ab00234495ce18": "Meerdere video's downloaden",
"6a3777f913cf3f288664f0632b9f24794fdcc24e": "Afbreken",
"322ed150e02666fe2259c5b4614eac7066f4ffa0": "Geavanceerd",
"4e4c721129466be9c3862294dc40241b64045998": "Aanvullende opties toekennen",
"ad2f8ac8b7de7945b80c8e424484da94e597125f": "Aanvullende opties",
"a6911c2157f1b775284bbe9654ce5eb30cf45d7f": "Je hoeft alleen de aanvullende opties op te geven, dus niet de url. Je kunt de opties scheiden met twee komma's: ,,",
"3a92a3443c65a52f37ca7efb8f453b35dbefbf29": "Aangepaste uitvoer gebruiken",
"d9c02face477f2f9cdaae318ccee5f89856851fb": "Aangepaste uitvoer",
"fcfd4675b4c90f08d18d3abede9a9a4dff4cfdc7": "Documentatie",
"19d1ae64d94d28a29b2c57ae8671aace906b5401": "Het pad is relatief aan het ingestelde downloadpad. Laat de extensie achterwege.",
"b7ffe7c6586d6f3f18a9246806a7c7d5538ab43e": "Geteste opdracht:",
"8fad10737d3e3735a6699a4d89cbf6c20f6bb55f": "Authenticatie gebruiken",
"08c74dc9762957593b91f6eb5d65efdfc975bf48": "Gebruikersnaam",
"c32ef07f8803a223a83ed17024b38e8d82292407": "Wachtwoord",
"17f0ea5d2d7a262b0e875acc70475f102aee84e6": "Afspeellijst maken",
"cff1428d10d59d14e45edec3c735a27b5482db59": "Naam",
"f61c6867295f3b53d23557021f2f4e0aa1d0b8fc": "Soort",
"f0baeb8b69d120073b6d60d34785889b0c3232c8": "Audio",
"2d1ea268a6a9f483dbc2cbfe19bf4256a57a6af4": "Video",
"f47e2d56dd8a145b2e9599da9730c049d52962a2": "Audiobestanden",
"a52dae09be10ca3a65da918533ced3d3f4992238": "Video's",
"a9806cf78ce00eb2613eeca11354a97e033377b8": "Abonneren op afspeellijst of kanaal",
"801b98c6f02fe3b32f6afa3ee854c99ed83474e6": "URL",
"93efc99ae087fc116de708ecd3ace86ca237cf30": "De url van de afspeellijst of het kanaal",
"08f5d0ef937ae17feb1b04aff15ad88911e87baf": "Aangepaste naam",
"ea30873bd3f0d5e4fb2378eec3f0a1db77634a28": "Alle uploads downloaden",
"d641b8fa5ac5e85114c733b1f7de6976bd091f70": "Maximumkwaliteit",
"c76a955642714b8949ff3e4b4990864a2e2cac95": "Audiomodus",
"408ca4911457e84a348cecf214f02c69289aa8f1": "Streamingmodus",
"f432e1a8d6adb12e612127978ce2e0ced933959c": "Deze worden toegevoegd ná de standaardopties.",
"98b6ec9ec138186d663e64770267b67334353d63": "Aangepaste bestandsuitvoer",
"d7b35c384aecd25a516200d6921836374613dfe7": "Annuleren",
"d0336848b0c375a1c25ba369b3481ee383217a4f": "Abonneren",
"28a678e9cabf86e44c32594c43fa0e890135c20f": "Video's downloaden die geüpload zijn in de afgelopen",
"e78c0d60ac39787f62c9159646fe0b3c1ed55a1d": "Soort:",
"c52db455cca9109ee47e1a612c3f4117c09eb71b": "URL:",
"ca3dbbc7f3e011bffe32a10a3ea45cc84f30ecf1": "ID:",
"f4e529ae5ffd73001d1ff4bbdeeb0a72e342e5c8": "Sluiten",
"8efc77bf327659c0fec1f518cf48a98cdcd9dddf": "Archief exporteren",
"3042bd3ad8dffcfeca5fd1ae6159fd1047434e95": "De-abonneren",
"303e45ffae995c9817e510e38cb969e6bb3adcbf": "(onderbroken)",
"a44d86aa1e6c20ced07aca3a7c081d8db9ded1c6": "Archief:",
"616e206cb4f25bd5885fc35925365e43cf5fb929": "Naam:",
"c6eb45d085384903e53ab001a3513d1de6a1dbac": "Uploader:",
"109c6f4a5e46efb933612ededfaf52a13178b7e0": "Bestandsgrootte:",
"bd630d8669b16e5f264ec4649d9b469fe03e5ff4": "Pad:",
"a67e7d843cef735c79d5ef1c8ba4af3e758912bb": "Uploaddatum:",
"0cc1dec590ecd74bef71a865fb364779bc42a749": "Categorie:",
"d9e83ac17026e70ef6e9c0f3240a3b2450367f40": "youtube-dl-opties aanpassen",
"7fc1946abe2b40f60059c6cd19975d677095fd19": "Geteste nieuwe aanvullende opties",
"0b71824ae71972f236039bed43f8d2323e8fd570": "Optie toevoegen",
"c8b0e59eb491f2ac7505f0fbab747062e6b32b23": "Zoeken op categorie",
"9eeb91caef5a50256dd87e1c4b7b3e8216479377": "Optiewaarde gebruiken",
"7de2451ed3fb8d8b847979bd3f0c740b970f167b": "Optie toevoegen",
"b2623aee44b70c9a4ba1fce16c8a593b0a4c7974": "Aanpassen",
"25d8ad5eba2ec24e68295a27d6a4bb9b49e3dacd": "Optiewaarde",
"91ecce65f1d23f9419d1c953cd6b7bc7f91c110e": "Updater",
"b7ff2e2b909c53abe088fe60b9f4b6ac7757247f": "Gebruikersregistratie",
"024886ca34a6f309e3e51c2ed849320592c3faaa": "Gebruikersnaam",
"cfc2f436ec2beffb042e7511a73c89c372e86a6c": "Registreren",
"ebadf946ae90f13ecd0c70f09edbc0f983af8a0f": "Nieuwe cookies uploaden",
"a8b7b9c168fd936a75e500806a8c0d7755ef1198": "Let op: de nieuwe cookies overschrijven de oude. Daarnaast zijn de cookies procesgebonden en niet gebruikersgebonden.",
"98a8a42e5efffe17ab786636ed0139b4c7032d0e": "Slepen-en-neerzetten",
"4f389e41e4592f7f9bb76abdd8af4afdfb13f4f1": "Afspeellijst aanpassen",
"5caadefa4143cf6766a621b0f54f91f373a1f164": "Inhoud toevoegen",
"52c9a103b812f258bcddc3d90a6e3f46871d25fe": "Opslaan",
"33026f57ea65cd9c8a5d917a08083f71a718933a": "Normale volgorde",
"29376982b1205d9d6ea3d289e8e2f8e1ac2839b1": "Omgekeerde volgorde",
"d02888c485d3aeab6de628508f4a00312a722894": "Mijn video's",
"7e892ba15f2c6c17e83510e273b3e10fc32ea016": "Zoeken",
"73423607944a694ce6f9e55cfee329681bb4d9f9": "Geen video's gevonden.",
"3697f8583ea42868aa269489ad366103d94aece7": "Bewerken",
"07db550ae114d9faad3a0cbb68bcc16ab6cd31fc": "Onderbroken",
"c3b0b86523f1d10e84a71f9b188d54913a11af3b": "Categorie bewerken",
"2489eefea00931942b91f4a1ae109514b591e2e1": "Regels",
"e4eeb9106dbcbc91ca1ac3fb4068915998a70f37": "Regel toevoegen",
"792dc6a57f28a1066db283f2e736484f066005fd": "Twitch-chatgesprek downloaden",
"28f86ffd419b869711aa13f5e5ff54be6d70731c": "Aanpassen",
"826b25211922a1b46436589233cb6f1a163d89b7": "Verwijderen",
"321e4419a943044e674beb55b8039f42a9761ca5": "Informatie",
"e684046d73bcee88e82f7ff01e2852789a05fc32": "Aantal:",
"34504b488c24c27e68089be549f0eeae6ebaf30b": "Verwijderen en op zwarte lijst plaatsen",
"dad95154dcef3509b8cc705046061fd24994bbb7": "weergaven",
"5b3075e8dc3f3921ec316b0bd83b6d14a06c1a4f": "Aanpassingen opslaan",
"4d8a18b04a1f785ecd8021ac824e0dfd5881dbfc": "Het downloaden is voltooid",
"348cc5d553b18e862eb1c1770e5636f6b05ba130": "Er is een fout opgetreden",
"4f8b2bb476981727ab34ed40fde1218361f92c45": "Details",
"e9aff8e6df2e2bf6299ea27bb2894c70bc48bd4d": "Er is een fout opgetreden:",
"77b0c73840665945b25bd128709aa64c8f017e1c": "Gestart om:",
"08ff9375ec078065bcdd7637b7ea65fce2979266": "Afgerond om:",
"ad127117f9471612f47d01eae09709da444a36a4": "Bestandspad(en):",
"e2319dec5b4ccfb6ed9f55ccabd63650a8fdf547": "Mijn abonnementen",
"807cf11e6ac1cde912496f764c176bdfdd6b7e19": "Kanalen",
"47546e45bbb476baaaad38244db444c427ddc502": "Afspeellijsten",
"29b89f751593e1b347eef103891b7a1ff36ec03f": "De naam is niet beschikbaar omdat het kanaal nog wordt opgehaald.",
"4636cd4a1379c50d471e98786098c4d39e1e82ad": "Je hebt geen abonnementen.",
"2e0a410652cb07d069f576b61eab32586a18320d": "De naam is niet beschikbaar omdat de afspeellijst nog wordt opgehaald.",
"587b57ced54965d8874c3fd0e9dfedb987e5df04": "Je hebt geen abonnementen.",
"82421c3e46a0453a70c42900eab51d58d79e6599": "Algemeen",
"0ba25ad86a240576c4f20a2fada4722ebba77b1e": "Downloader",
"d5f69691f9f05711633128b5a3db696783266b58": "Diversen",
"bc2e854e111ecf2bd7db170da5e3c2ed08181d88": "Geavanceerd",
"4d13a9cd5ed3dcee0eab22cb25198d43886942be": "Gebruikers",
"eb3d5aefff38a814b76da74371cbf02c0789a1ef": "Logboeken",
"fe8fd36dbf5deee1d56564965787a782a66eba44": "{VAR_SELECT, select, true {Close} false {Cancel} other {otha}}",
"54c512cca1923ab72faf1a0bd98d3d172469629a": "De url waarvan deze app wordt geladen, zonder het poortnummer.",
"cb2741a46e3560f6bc6dfd99d385e86b08b26d72": "Poort",
"22e8f1d0423a3b784fe40fab187b92c06541b577": "Het gewenste poortnummer (standaard: 17442).",
"d4477669a560750d2064051a510ef4d7679e2f3e": "Meerdere gebruikers",
"2eb03565fcdce7a7a67abc277a936a32fcf51557": "Gebruikersbasispad",
"a64505c41150663968e277ec9b3ddaa5f4838798": "Het basispad voor gebruikers en hun gedownloade video's.",
"4e3120311801c4acd18de7146add2ee4a4417773": "Abonnementen toestaan",
"4bee2a4bef2d26d37c9b353c278e24e5cd309ce3": "Abonnementenbasispad",
"bc9892814ee2d119ae94378c905ea440a249b84a": "Het basispad voor video's van afspeellijsten en kanalen uit je abonnementen. Dit is relatief aan YTDL-Material's hoofdmap.",
"5bef4b25ba680da7fff06b86a91b1fc7e6a926e3": "Controletussenpoos",
"0f56a7449b77630c114615395bbda4cab398efd8": "In seconden (alleen cijfers).",
"13759b09a7f4074ceee8fa2f968f9815fdf63295": "Soms worden nieuwe video's gedownload voordat ze volledig verwerkt zijn. Met deze instelling wordt de volgende dag gecontroleerd of er een hogere kwaliteit beschikbaar is.",
"3d1a47dc18b7bd8b5d9e1eb44b235ed9c4a2b513": "Nieuwe uploads opnieuw downloaden",
"27a56aad79d8b61269ed303f11664cc78bcc2522": "Thema",
"ff7cee38a2259526c519f878e71b964f41db4348": "Standaard",
"7a6bacee4c31cb5c0ac2d24274fb4610d8858602": "Themawijziging toestaan",
"fe46ccaae902ce974e2441abe752399288298619": "Taal",
"ab2756805742e84ad0cc0468f4be2d8aa9f855a5": "Audiopad",
"c2c89cdf45d46ea64d2ed2f9ac15dfa4d77e26ca": "Het pad voor audiodownloads. Dit is relatief aan YTDL-Material's hoofdmap.",
"46826331da1949bd6fb74624447057099c9d20cd": "Videomap",
"17c92e6d47a213fa95b5aa344b3f258147123f93": "Het pad voor videodownloads. Dit is relatief aan YTDL-Material's hoofdmap.",
"cfe829634b1144bc44b6d38cf5584ea65db9804f": "Standaard bestandsuitvoer",
"1148fd45287ff09955b938756bc302042bcb29c7": "Dit pad is relatief aan bovenstaande downloadpaden. Laat de extensie achterwege.",
"ef418d4ece7c844f3a5e431da1aa59bedd88da7b": "Algemene aanvullende opties",
"6b995e7130b4d667eaab6c5f61b362ace486d26d": "Algemene aanvullende opties voor downloads op de overzichtspagina. Scheidt deze met komma's: ,,",
"04201f9d27abd7d6f58a4328ab98063ce1072006": "Categorieën",
"78e49b7339b4fa7184dd21bcaae107ce9b7076f6": "youtube-dl-archief gebruiken",
"ffc19f32b1cba0daefc0e5668f89346db1db83ad": "Miniatuurvoorbeeld opslaan",
"384de8f8f112c9e6092eb2698706d391553f3e8d": "Metagegevens opslaan",
"fb35145bfb84521e21b6385363d59221f436a573": "Alle downloads afbreken",
"61f8fd90b5f8cb20c70371feb2ee5e1fac5a9095": "Boventitel",
"78d3531417c0d4ba4c90f0d4ae741edc261ec8df": "Bestandsbeheer ingeschakeld",
"a5a1be0a5df07de9eec57f5d2a86ed0204b2e75a": "Downloadbeheer ingeschakeld",
"c33bd5392b39dbed36b8e5a1145163a15d45835f": "Kwaliteitskeuze toestaan",
"bda5508e24e0d77debb28bcd9194d8fefb1cfb92": "Downloadmodus",
"09d31c803a7252658694e1e3176b97f5655a3fe3": "Meerdere downloads toestaan",
"1c4dbce56d96b8974aac24a02f7ab2ee81415014": "Openbare api gebruiken",
"23bd81dcc30b74d06279a26d7a42e8901c1b124e": "Openbare api-sleutel",
"41016a73d8ad85e6cb26dffa0a8fab9fe8f60d8e": "Documentatie bekijken",
"00a94f58d9eb2e3aa561440eabea616d0c937fa2": "Let op: hiermee verwijder je je oude api-sleutel!",
"1b258b258b4cc475ceb2871305b61756b0134f4a": "Genereren",
"d5d7c61349f3b0859336066e6d453fc35d334fe5": "YouTube-api gebruiken",
"ce10d31febb3d9d60c160750570310f303a22c22": "YouTube-api-sleutel",
"8602e313cdfa7c4cc475ccbe86459fce3c3fd986": "Het genereren van een sleutel is eenvoudig.",
"d162f9fcd6a7187b391e004f072ab3da8377c47d": "Twitch-api gebruiken",
"8ae23bc4302a479f687f4b20a84c276182e2519c": "Twitch-api-sleutel",
"84ffcebac2709ca0785f4a1d5ba274433b5beabc": "Ook wel de client-id.",
"5fb1e0083c9b2a40ac8ae7dcb2618311c291b8b9": "Twitch-chatgesprekken automatisch downloaden",
"9b3cedfa83c6d7acb3210953289d1be4aab115c7": "Klik hier",
"7f09776373995003161235c0c8d02b7f91dbc4df": "om de officiële Chrome-extensie van YouTubeDL-Material te downloaden.",
"5b5296423906ab3371fdb2b5a5aaa83acaa2ee52": "Hiervoor dien je de extensie handmatig te laden en de frontend-url op te geven in de instellingen.",
"9a2ec6da48771128384887525bdcac992632c863": "om de officiële Firefox-extensie van YouTubeDL-Material te installeren.",
"eb81be6b49e195e5307811d1d08a19259d411f37": "Uitgebreide installatiehandleiding.",
"cb17ff8fe3961cf90f44bee97c88a3f3347a7e55": "Je hoeft alleen de frontend-url op te geven in de instellingen.",
"61b81b11aad0b9d970ece2fce18405f07eac69c2": "Sleep de link naar je bladwijzers en klaar is Kees! Ga vervolgens naar een YouTube-video en klik op de bladwijzer.",
"c505d6c5de63cc700f0aaf8a4b31fae9e18024e5": "Audio-bookmarklet genereren",
"ec71e08aee647ea4a71fd6b7510c54d84a797ca6": "Kies een downloader",
"5fab47f146b0a4b809dcebf3db9da94df6299ea1": "Standaard downloadagent gebruiken",
"c776eb4992b6c98f58cd89b20c1ea8ac37888521": "Kies een downloadagent",
"0c43af932e6a4ee85500e28f01b3538b4eb27bc4": "Logniveau",
"db6c192032f4cab809aad35215f0aa4765761897": "Inlogverloopdatum",
"dc3d990391c944d1fbfc7cfb402f7b5e112fb3a8": "Geavanceerd downloaden toestaan",
"431e5f3a0dde88768d1074baedd65266412b3f02": "Cookies gebruiken",
"80651a7ad1229ea6613557d3559f702cfa5aecf5": "Cookies instellen",
"37224420db54d4bc7696f157b779a7225f03ca9d": "Gebruikersregistratie toestaan",
"fa548cee6ea11c160a416cac3e6bdec0363883dc": "Authenticatiemethode",
"4f56ced9d6b85aeb1d4346433361d47ea72dac1a": "Intern",
"e3d7c5f019e79a3235a28ba24df24f11712c7627": "LDAP",
"1db9789b93069861019bd0ccaa5d4706b00afc61": "LDAP-url",
"f50fa6c09c8944aed504f6325f2913ee6c7a296a": "Bind DN",
"080cc6abcba236390fc22e79792d0d3443a3bd2a": "Bind-inloggegevens",
"cfa67d14d84fe0e9fadf251dc51ffc181173b662": "Zoekdatabank",
"e01d54ecc1a0fcf9525a3c100ed8b83d94e61c23": "Zoekfilter",
"cec82c0a545f37420d55a9b6c45c20546e82f94e": "Over YouTubeDL-Material",
"199c17e5d6a419313af3c325f06dcbb9645ca618": "is een opensource YouTube-downloader, gebouwd volgens Google's Material Design-specificaties. Je kunt naadloos je favoriete video's downloaden als audio- of videobestanden of abonneren op je favoriete kanalen of afspeellijsten om altijd de nieuwste video's binnen te halen.",
"bc0ad0ee6630acb7fcb7802ec79f5a0ee943c1a7": "bevat een aantal handige functies, zoals een uitgebreide api, Docker-ondersteuning en is volledig vertaalbaar. Meer functies zijn te vinden op onze GitHub-pagina (klik op het GitHub-pictogram).",
"a45e3b05f0529dc5246d70ef62304c94426d4c81": "Geïnstalleerde versie:",
"b33536f59b94ec935a16bd6869d836895dc5300c": "Heb je een bug aangetroffen of een idee?",
"e1f398f38ff1534303d4bb80bd6cece245f24016": "om een 'issue' te openen!",
"e22f3a5351944f3a1a10cfc7da6f65dfbe0037fe": "Bezig met controleren op updates...",
"a16e92385b4fd9677bb830a4b796b8b79c113290": "Update beschikbaar",
"189b28aaa19b3c51c6111ad039c4fd5e2a22e370": "Je kunt de update installeren via het instellingenmenu.",
"1372e61c5bd06100844bd43b98b016aabc468f62": "Kies een versie:",
"1f6d14a780a37a97899dc611881e6bc971268285": "Delen toestaan",
"6580b6a950d952df847cb3d8e7176720a740adc8": "Tijdstempel gebruiken",
"4f2ed9e71a7c981db3e50ae2fedb28aff2ec4e6c": "seconden",
"3a6e5a6aa78ca864f6542410c5dafb6334538106": "Kopiëren naar klembord",
"a249a5ae13e0835383885aaf697d2890cc3e53e9": "Afspeellijst delen",
"15da89490e04496ca9ea1e1b3d44fb5efd4a75d9": "Video delen",
"1d540dcd271b316545d070f9d182c372d923aadd": "Audio delen",
"a1ad8b1be9be43b5183bd2c3186d4e19496f2a0b": "Sessie-id:",
"b6c453e0e61faea184bbaf5c5b0a1e164f4de2a2": "Alle downloads wissen",
"eb98135e35af26a9a326ee69bd8ff104d36dd8ec": "(huidig)",
"7117fc42f860e86d983bfccfcf2654e5750f3406": "Geen downloads beschikbaar!",
"42ff677ec14f111e88bd6cdd30145378e994d1bf": "Mijn profiel",
"bb694b49d408265c91c62799c2b3a7e3151c824d": "Uitloggen",
"ac9d09de42edca1296371e4d801349c9096ac8de": "UID:",
"a5ed099ffc9e96f6970df843289ade8a7d20ab9f": "Aangemaakt:",
"fa96f2137af0a24e6d6d54c598c0af7d5d5ad344": "Je bent niet ingelogd.",
"a1dbca87b9f36d2b06a5cbcffb5814c4ae9b798a": "Beheerdersaccount aanmaken",
"2d2adf3ca26a676bca2269295b7455a26fd26980": "Er zijn geen beheerdersaccounts aangetroffen. Hiermee maak je een beheerdersaccount met wachtwoord aan - de gebruikersnaam is 'admin'.",
"70a67e04629f6d412db0a12d51820b480788d795": "Aanmaken",
"4d92a0395dd66778a931460118626c5794a3fc7a": "Gebruikers toevoegen",
"b0d7dd8a1b0349622d6e0c6e643e24a9ea0efa1d": "Rol aanpassen",
"746f64ddd9001ac456327cd9a3d5152203a4b93c": "Gebruikersnaam",
"52c1447c1ec9570a2a3025c7e566557b8d19ed92": "Rol",
"59a8c38db3091a63ac1cb9590188dc3a972acfb3": "Acties",
"2bd201aea09e43fbfd3cd15ec0499b6755302329": "Gebruiker beheren",
"95b95a9c79e4fd9ed41f6855e37b3b06af25bcab": "Gebruiker verwijderen",
"632e8b20c98e8eec4059a605a4b011bb476137af": "Gebruiker bewerken",
"29c97c8e76763bb15b6d515648fa5bd1eb0f7510": "Gebruikers-uid:",
"e70e209561583f360b1e9cefd2cbb1fe434b6229": "Nieuw wachtwoord",
"6498fa1b8f563988f769654a75411bb8060134b9": "Nieuw wachtwoord instellen",
"544e09cdc99a8978f48521d45f62db0da6dcf742": "Standaardrol gebruiken",
"4f20f2d5a6882190892e58b85f6ccbedfa737952": "Ja",
"3d3ae7deebc5949b0c1c78b9847886a94321d9fd": "Nee",
"57c6c05d8ebf4ef1180c2705033c044f655bb2c4": "Rol beheren",
"5009630cdf32ab4f1c78737b9617b8773512c05a": "Aantal regels:",
"8a0bda4c47f10b2423ff183acefbf70d4ab52ea2": "Logboeken wissen",
"24dc3ecf7ec2c2144910c4f3d38343828be03a4c": "Automatisch gegenereerd",
"ccf5ea825526ac490974336cb5c24352886abc07": "Bestand openen",
"5656a06f17c24b2d7eae9c221567b209743829a9": "Bestand openen op nieuw tabblad",
"a0720c36ee1057e5c54a86591b722485c62d7b1a": "Ga naar abonnement",
"94e01842dcee90531caa52e4147f70679bac87fe": "Verwijderen en opnieuw downloaden",
"2031adb51e07a41844e8ba7704b054e98345c9c1": "Permanent verwijderen",
"ddc31f2885b1b33a7651963254b0c197f2a64086": "Meer tonen.",
"56a2a773fbd5a6b9ac2e6b89d29d70a2ed0f3227": "Minder tonen.",
"2054791b822475aeaea95c0119113de3200f5e1c": "Duur:"
}

File diff suppressed because it is too large Load Diff

View File

@@ -113,7 +113,7 @@
"61b81b11aad0b9d970ece2fce18405f07eac69c2": "只需将下面的链接拖放到书签栏中。在YouTube页面上您只需单击书签即可下载视频。", "61b81b11aad0b9d970ece2fce18405f07eac69c2": "只需将下面的链接拖放到书签栏中。在YouTube页面上您只需单击书签即可下载视频。",
"c505d6c5de63cc700f0aaf8a4b31fae9e18024e5": "生成“仅音频”书签", "c505d6c5de63cc700f0aaf8a4b31fae9e18024e5": "生成“仅音频”书签",
"d5f69691f9f05711633128b5a3db696783266b58": "额外", "d5f69691f9f05711633128b5a3db696783266b58": "额外",
"5fab47f146b0a4b809dcebf3db9da94df6299ea1": "使用默认下载代理", "5fab47f146b0a4b809dcebf3db9da94df6299ea1": "使用默认下载程序",
"ec71e08aee647ea4a71fd6b7510c54d84a797ca6": "选择下载器", "ec71e08aee647ea4a71fd6b7510c54d84a797ca6": "选择下载器",
"00e274c496b094a019f0679c3fab3945793f3335": "选择日志级别", "00e274c496b094a019f0679c3fab3945793f3335": "选择日志级别",
"dc3d990391c944d1fbfc7cfb402f7b5e112fb3a8": "开启高级下载选项", "dc3d990391c944d1fbfc7cfb402f7b5e112fb3a8": "开启高级下载选项",
@@ -124,7 +124,7 @@
"4d13a9cd5ed3dcee0eab22cb25198d43886942be": "用户", "4d13a9cd5ed3dcee0eab22cb25198d43886942be": "用户",
"eb3d5aefff38a814b76da74371cbf02c0789a1ef": "日志", "eb3d5aefff38a814b76da74371cbf02c0789a1ef": "日志",
"52c9a103b812f258bcddc3d90a6e3f46871d25fe": "保存", "52c9a103b812f258bcddc3d90a6e3f46871d25fe": "保存",
"fe8fd36dbf5deee1d56564965787a782a66eba44": "{VAR_SELECT, select, true {关} false {取消} }", "fe8fd36dbf5deee1d56564965787a782a66eba44": "{VAR_SELECT, select, true {关} false {取消} other {其他} }",
"cec82c0a545f37420d55a9b6c45c20546e82f94e": "关于 YoutubeDL-Material", "cec82c0a545f37420d55a9b6c45c20546e82f94e": "关于 YoutubeDL-Material",
"199c17e5d6a419313af3c325f06dcbb9645ca618": "是根据Google的Material Design规范构建的开源YouTube下载器。您可以将喜欢的视频下载为视频或音频文件并且可以订阅喜欢的频道和播放列表以便及时下载他们的新视频。", "199c17e5d6a419313af3c325f06dcbb9645ca618": "是根据Google的Material Design规范构建的开源YouTube下载器。您可以将喜欢的视频下载为视频或音频文件并且可以订阅喜欢的频道和播放列表以便及时下载他们的新视频。",
"bc0ad0ee6630acb7fcb7802ec79f5a0ee943c1a7": "包含很多很棒的功能支持APIDocker和本地化。在Github上查找所有受支持的功能。", "bc0ad0ee6630acb7fcb7802ec79f5a0ee943c1a7": "包含很多很棒的功能支持APIDocker和本地化。在Github上查找所有受支持的功能。",
@@ -238,5 +238,32 @@
"cfa67d14d84fe0e9fadf251dc51ffc181173b662": "搜索起点", "cfa67d14d84fe0e9fadf251dc51ffc181173b662": "搜索起点",
"544e09cdc99a8978f48521d45f62db0da6dcf742": "使用角色预设", "544e09cdc99a8978f48521d45f62db0da6dcf742": "使用角色预设",
"3697f8583ea42868aa269489ad366103d94aece7": "编辑中", "3697f8583ea42868aa269489ad366103d94aece7": "编辑中",
"fb35145bfb84521e21b6385363d59221f436a573": "取消所有下载" "fb35145bfb84521e21b6385363d59221f436a573": "取消所有下载",
"56a2a773fbd5a6b9ac2e6b89d29d70a2ed0f3227": "查看更少",
"c776eb4992b6c98f58cd89b20c1ea8ac37888521": "选择一个下载程序",
"d641b8fa5ac5e85114c733b1f7de6976bd091f70": "最高画质",
"ddc31f2885b1b33a7651963254b0c197f2a64086": "查看更多...",
"5fb1e0083c9b2a40ac8ae7dcb2618311c291b8b9": "自动下载Twitch弹幕",
"84ffcebac2709ca0785f4a1d5ba274433b5beabc": "也称为客户ID",
"8ae23bc4302a479f687f4b20a84c276182e2519c": "Twitch API 密钥",
"d162f9fcd6a7187b391e004f072ab3da8377c47d": "使用Twitch API",
"04201f9d27abd7d6f58a4328ab98063ce1072006": "分类",
"ef418d4ece7c844f3a5e431da1aa59bedd88da7b": "全局自定义变量",
"1148fd45287ff09955b938756bc302042bcb29c7": "路径相对于上述下载路径,不包括扩展名。",
"cfe829634b1144bc44b6d38cf5584ea65db9804f": "默认输出文件夹",
"3d1a47dc18b7bd8b5d9e1eb44b235ed9c4a2b513": "重新下载新上传的内容",
"13759b09a7f4074ceee8fa2f968f9815fdf63295": "有时新视频会在完全处理前下载。这项设置指新视频会在第二天检查视频是否有更高画质。",
"dad95154dcef3509b8cc705046061fd24994bbb7": "浏览",
"792dc6a57f28a1066db283f2e736484f066005fd": "下载Twitch弹幕",
"e4eeb9106dbcbc91ca1ac3fb4068915998a70f37": "添加新规则",
"2489eefea00931942b91f4a1ae109514b591e2e1": "规则",
"c3b0b86523f1d10e84a71f9b188d54913a11af3b": "编辑类别",
"07db550ae114d9faad3a0cbb68bcc16ab6cd31fc": "暂停",
"73423607944a694ce6f9e55cfee329681bb4d9f9": "找不到视频",
"29376982b1205d9d6ea3d289e8e2f8e1ac2839b1": "倒序",
"33026f57ea65cd9c8a5d917a08083f71a718933a": "正序",
"5caadefa4143cf6766a621b0f54f91f373a1f164": "添加内容",
"0cc1dec590ecd74bef71a865fb364779bc42a749": "类别:",
"303e45ffae995c9817e510e38cb969e6bb3adcbf": "(暂停)",
"24dc3ecf7ec2c2144910c4f3d38343828be03a4c": "自动生成的"
} }

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,6 @@
<link href="https://fonts.googleapis.com/css?family=Roboto&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css?family=Roboto&display=swap" rel="stylesheet">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous"> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined" rel="stylesheet"/>
</head> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>

View File

@@ -1,5 +1,7 @@
/* You can add global styles to this file, and also import other style files */ /* You can add global styles to this file, and also import other style files */
@import '~material-icons/iconfont/material-icons.css';
@import '@angular/material/prebuilt-themes/indigo-pink.css'; @import '@angular/material/prebuilt-themes/indigo-pink.css';
//@import './app-theme'; //@import './app-theme';

View File

@@ -5,6 +5,7 @@ const THEMES_CONFIG = {
'alternate_color': 'gray', 'alternate_color': 'gray',
'ghost_primary': '#f9f9f9', 'ghost_primary': '#f9f9f9',
'ghost_secondary': '#ecebeb', 'ghost_secondary': '#ecebeb',
'drawer_color': '#fafafa',
'css_label': 'default-theme', 'css_label': 'default-theme',
'social_theme': 'material-light' 'social_theme': 'material-light'
}, },
@@ -14,6 +15,7 @@ const THEMES_CONFIG = {
'alternate_color': '#695959', 'alternate_color': '#695959',
'ghost_primary': '#444444', 'ghost_primary': '#444444',
'ghost_secondary': '#141414', 'ghost_secondary': '#141414',
'drawer_color': '#303030',
'css_label': 'dark-theme', 'css_label': 'dark-theme',
'social_theme': 'material-dark' 'social_theme': 'material-dark'
}, },

View File

@@ -10,16 +10,19 @@
"moduleResolution": "node", "moduleResolution": "node",
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"target": "es2015", "target": "es2019",
"resolveJsonModule": true, "resolveJsonModule": true,
"esModuleInterop": true, "esModuleInterop": true,
"typeRoots": [ "typeRoots": [
"node_modules/@types" "node_modules/@types"
], ],
"lib": [ "lib": [
"es2016", "es2019",
"dom" "dom"
], ],
"module": "es2020" "module": "es2020"
} },
"exclude": [
"assets/default.json"
],
} }