Compare commits

..

46 Commits

Author SHA1 Message Date
Isaac Abadi
d08fee1223 Added v1 of chat sidebar for Twitch VODs 2020-11-29 03:18:28 -05:00
Isaac Abadi
8938844ffa Added ability to select the max quality for a subscription. It defaults to 'best' which will get the best native mp4 video 2020-11-28 00:45:47 -05:00
Tzahi12345
9895d77e01 Merge pull request #258 from diveflo/fix/dockerreadme
Cleanup & clarify README.md
2020-11-27 15:06:46 -05:00
Florian Gabsteiger
27437a615f Cleanup README and clarify docker port usage 2020-11-27 12:32:51 +01:00
Isaac Abadi
b730bc5adc Added option to set a default file output - custom file output in the advanced expansion panel will override this 2020-11-27 00:25:31 -05:00
Isaac Abadi
d15d262b87 Fixed bug that resulted in the "download videos in the last X days" timerange in edit subscriptions to come up blank 2020-11-26 15:49:14 -05:00
Tzahi12345
1aade1202d Merge pull request #259 from diveflo/feat/cibuildandrelease
Automated build & release via GitHub Actions
2020-11-25 15:38:50 -05:00
Isaac Abadi
2f541a49df Thumbnails now load using a faster method with a dedicated API route rather than sending blobs directly.
- In cases of lots of files, loading should be significantly faster
2020-11-25 15:36:00 -05:00
Florian Gabsteiger
d93481640c automated release creation for tagged commits 2020-11-24 11:45:27 +01:00
Florian Gabsteiger
71814cbdc9 build via github actions 2020-11-24 11:43:04 +01:00
Isaac Abadi
09832ad15b Multi download mode and download-only mode now reloads recent videos 2020-11-24 03:39:30 -05:00
Tzahi12345
cc78091403 Merge pull request #262 from diveflo/fix/dockerci
do not push new docker images for pull requests
2020-11-23 15:09:58 -05:00
Florian Gabsteiger
cb88c7bc7c do not push new docker images for pull requests 2020-11-23 14:39:16 +01:00
Tzahi12345
98f4828db4 Merge pull request #257 from diveflo/feat/multiarchdockerci
Multi-arch docker image build via GitHub Actions
2020-11-22 17:15:08 -05:00
Tzahi12345
8f0739c0f9 Removes extra line
Co-authored-by: Sandro <sandro.jaeckel@gmail.com>
2020-11-22 00:40:53 -05:00
Tzahi12345
ab355d62a0 GitHub autobuild now uses nightly tag
Co-authored-by: Sandro <sandro.jaeckel@gmail.com>
2020-11-21 23:13:58 -05:00
Florian Gabsteiger
4d2d9a6b10 change docker image tag name to align with upstream 2020-11-21 20:58:58 +01:00
Florian Gabsteiger
89dfac1249 update job name to better reflect what it's actually doing 2020-11-21 20:55:08 +01:00
Florian Gabsteiger
d4f81eb0ab add platform emulator 2020-11-21 20:16:49 +01:00
Florian Gabsteiger
6b7d0681d2 add automated multi-arch docker image build and push to dockerhub 2020-11-21 20:15:12 +01:00
Isaac Abadi
b32fdb2445 Tab title now matches the top title set in the settings 2020-11-20 17:39:44 -05:00
Tzahi12345
b059c7ed5e Update README.md 2020-11-18 01:55:49 -05:00
Isaac Abadi
8d87cbb08d Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material 2020-11-14 16:55:12 -05:00
Isaac Abadi
1bb2f54eba Fixed bug where updating a subscription would not work correctly 2020-11-14 16:54:20 -05:00
Tzahi12345
7392338d6e Merge pull request #247 from hwalker928/master
Update README.md
2020-11-14 04:04:03 -05:00
hwalker928
82df92a72d Update README.md 2020-11-14 09:00:45 +00:00
Isaac Abadi
9e4b328f91 Default youtube downloader switched back to youtube-dl after testing
Fixed bug that caused some non-youtube downloads from failing
2020-11-01 20:21:36 -05:00
Isaac Abadi
3a049a99ac Fixed bug where non-youtube downloads would fail 2020-11-01 19:38:22 -05:00
Isaac Abadi
b323b548ca Added ability to use youtube-dl forks
Downloader now defaults to youtube-dlc because of the recent DMCA requests
2020-11-01 19:16:41 -05:00
Tzahi12345
568463487f Merge pull request #236 from Tzahi12345/categories
Adds rule-based categories
2020-10-24 01:13:26 -04:00
Isaac Abadi
3318ac364d Code cleanup and changed proposed handling of existing tags for suggestions 2020-10-24 00:29:42 -04:00
Isaac Abadi
1ce85813fb Saving a category will now cause the UI to refresh the cache of categories 2020-10-24 00:20:39 -04:00
Isaac Abadi
6ea4176d63 Added missing code that makes category paths relative to the root dir 2020-10-24 00:15:47 -04:00
Isaac Abadi
3aa08e1817 Added scaffolding for tags 2020-10-23 02:44:24 -04:00
Isaac Abadi
727b047c39 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into categories 2020-10-23 02:00:57 -04:00
Isaac Abadi
d659a7614f updated default.json 2020-10-23 01:49:27 -04:00
Tzahi12345
6ad9d5ea8e Merge pull request #235 from Tzahi12345/locale-based-dates
File cards now use the locale to format dates
2020-10-23 01:40:48 -04:00
Isaac Abadi
0189d292a8 Fixed bug that prevented categorized files from being deletes and simplified the two delete file API calls into one 2020-10-18 02:20:06 -04:00
Isaac Abadi
deac54e8d6 Fixed bug in goToPlaylist 2020-10-15 17:00:56 -04:00
Isaac Abadi
d4e5082039 Confirm dialog can now optionally use warn colors (used for deletion or breaking changes)
Category re-ordering is fixed

Category deletion in settings is now functional
2020-10-15 17:00:48 -04:00
Isaac Abadi
6f089491a5 Updated player component to support categories 2020-10-15 16:59:33 -04:00
Isaac Abadi
0a38b01971 Updated posts service to allow for category deletion and subscription retrieval based on name 2020-10-15 16:59:14 -04:00
Isaac Abadi
fe7303a191 Replaced /audio and /video APIs with /stream that now requires a type parameter to simplify future code changes
getSubscription can now accept a subscription name instead of just an ID

Added API call to delete a category

Categories can now have a custom path

Minor code cleanup
2020-10-15 16:57:45 -04:00
Isaac Abadi
dff4b141b0 Blobs are now only included in getAllFiles() if the config option for including thumbnail is set to true 2020-10-12 22:47:11 -04:00
Isaac Abadi
fed0a54145 Updated styling on edit category dialog 2020-10-12 22:46:23 -04:00
Isaac Abadi
8595864118 Added basic categorization functionality in the server & UI 2020-09-17 03:14:24 -04:00
44 changed files with 1544 additions and 276 deletions

92
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,92 @@
name: continuous integration
on:
push:
branches: [master, feat/*]
tags:
- v*
pull_request:
branches: [master]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: checkout code
uses: actions/checkout@v2
- name: setup node
uses: actions/setup-node@v1
- name: install dependencies
run: |
npm install
cd backend
npm install
sudo npm install -g @angular/cli
- name: build
run: ng build --prod
- name: prepare artifact upload
shell: pwsh
run: |
New-Item -Name build -ItemType Directory
New-Item -Path build -Name youtubedl-material -ItemType Directory
Copy-Item -Path ./backend/appdata -Recurse -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/audio -Recurse -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/authentication -Recurse -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/public -Recurse -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/subscriptions -Recurse -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/video -Recurse -Destination ./build/youtubedl-material
New-Item -Path ./build/youtubedl-material -Name users
Copy-Item -Path ./backend/*.js -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/*.json -Destination ./build/youtubedl-material
- name: upload build artifact
uses: actions/upload-artifact@v1
with:
name: youtubedl-material
path: build
release:
runs-on: ubuntu-latest
needs: build
if: contains(github.ref, '/tags/v')
steps:
- name: checkout code
uses: actions/checkout@v2
- name: create release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: YoutubeDL-Material ${{ github.ref }}
body: |
# New features
# Minor additions
# Bug fixes
draft: true
prerelease: false
- name: download build artifact
uses: actions/download-artifact@v1
with:
name: youtubedl-material
path: ${{runner.temp}}/youtubedl-material
- name: prepare release asset
shell: pwsh
run: Compress-Archive -Path ${{runner.temp}}/youtubedl-material -DestinationPath youtubedl-material-${{ github.ref }}.zip
- name: upload build asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./youtubedl-material-${{ github.ref }}.zip
asset_name: youtubedl-material-${{ github.ref }}.zip
asset_content_type: application/zip
- name: upload docker-compose asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./docker-compose.yml
asset_name: docker-compose.yml
asset_content_type: text/plain

29
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: docker
on:
push:
branches: [master]
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: tzahi12345/youtubedl-material:nightly

View File

@@ -1,10 +1,10 @@
# YoutubeDL-Material # YoutubeDL-Material
[![](https://img.shields.io/docker/pulls/tzahi12345/youtubedl-material.svg)](https://hub.docker.com/r/tzahi12345/youtubedl-material)
[![](https://img.shields.io/docker/image-size/tzahi12345/youtubedl-material?sort=date)](https://hub.docker.com/r/tzahi12345/youtubedl-material)
[![](https://img.shields.io/badge/%E2%86%91_Deploy_to-Heroku-7056bf.svg)](https://heroku.com/deploy?template=https://github.com/Tzahi12345/YoutubeDL-Material)
[![](https://img.shields.io/github/issues/Tzahi12345/YoutubeDL-Material)](https://github.com/Tzahi12345/YoutubeDL-Material/issues)
[![](https://img.shields.io/github/license/Tzahi12345/YoutubeDL-Material)](https://github.com/Tzahi12345/YoutubeDL-Material/blob/master/LICENSE.md)
[![Docker pulls badge](https://img.shields.io/docker/pulls/tzahi12345/youtubedl-material.svg)](https://hub.docker.com/r/tzahi12345/youtubedl-material)
[![Docker image size badge](https://img.shields.io/docker/image-size/tzahi12345/youtubedl-material?sort=date)](https://hub.docker.com/r/tzahi12345/youtubedl-material)
[![Heroku deploy badge](https://img.shields.io/badge/%E2%86%91_Deploy_to-Heroku-7056bf.svg)](https://heroku.com/deploy?template=https://github.com/Tzahi12345/YoutubeDL-Material)
[![GitHub issues badge](https://img.shields.io/github/issues/Tzahi12345/YoutubeDL-Material)](https://github.com/Tzahi12345/YoutubeDL-Material/issues)
[![License badge](https://img.shields.io/github/license/Tzahi12345/YoutubeDL-Material)](https://github.com/Tzahi12345/YoutubeDL-Material/blob/master/LICENSE.md)
YoutubeDL-Material is a Material Design frontend for [youtube-dl](https://rg3.github.io/youtube-dl/). It's coded using [Angular 9](https://angular.io/) for the frontend, and [Node.js](https://nodejs.org/) on the backend. YoutubeDL-Material is a Material Design frontend for [youtube-dl](https://rg3.github.io/youtube-dl/). It's coded using [Angular 9](https://angular.io/) for the frontend, and [Node.js](https://nodejs.org/) on the backend.
@@ -30,13 +30,25 @@ Dark mode:
NOTE: If you would like to use Docker, you can skip down to the [Docker](#Docker) section for a setup guide. NOTE: If you would like to use Docker, you can skip down to the [Docker](#Docker) section for a setup guide.
Make sure you have these dependencies installed on your system: nodejs and youtube-dl. If you don't, run this command: Debian/Ubuntu:
```bash
sudo apt-get install nodejs youtube-dl ffmpeg
``` ```
sudo apt-get install nodejs youtube-dl
CentOS 7:
```bash
sudo yum install epel-release
sudo yum localinstall --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-7.noarch.rpm
sudo yum install centos-release-scl-rh
sudo yum install rh-nodejs12
scl enable rh-nodejs12 bash
sudo yum install nodejs youtube-dl ffmpeg ffmpeg-devel
``` ```
Optional dependencies: Optional dependencies:
* AtomicParsley (for embedding thumbnails, package name `atomicparsley`) * AtomicParsley (for embedding thumbnails, package name `atomicparsley`)
### Installing ### Installing
@@ -75,14 +87,16 @@ If you are looking to setup YoutubeDL-Material with Docker, this section is for
1. Run `curl -L https://github.com/Tzahi12345/YoutubeDL-Material/releases/latest/download/docker-compose.yml -o docker-compose.yml` to download the latest Docker Compose, or go to the [releases](https://github.com/Tzahi12345/YoutubeDL-Material/releases/) page to grab the version you'd like. 1. Run `curl -L https://github.com/Tzahi12345/YoutubeDL-Material/releases/latest/download/docker-compose.yml -o docker-compose.yml` to download the latest Docker Compose, or go to the [releases](https://github.com/Tzahi12345/YoutubeDL-Material/releases/) page to grab the version you'd like.
2. Run `docker-compose pull`. This will download the official YoutubeDL-Material docker image. 2. Run `docker-compose pull`. This will download the official YoutubeDL-Material docker image.
3. Run `docker-compose up` to start it up. If successful, it should say "HTTP(S): Started on port 8998" or something similar. 3. Run `docker-compose up` to start it up. If successful, it should say "HTTP(S): Started on port 17443" or something similar. This tells you the *container-internal* port of the application. Please check your `docker-compose.yml` file for the *external* port. If you downloaded the file as described above, it defaults to **8998**.
4. Make sure you can connect to the specified URL + port, and if so, you are done! 4. Make sure you can connect to the specified URL + *external* port, and if so, you are done!
NOTE: It is currently recommended that you use the `nightly` tag on Docker. To do so, simply update the docker-compose.yml `image` field so that it points to `tzahi12345/youtubedl-material:nightly`.
### Custom UID/GID ### Custom UID/GID
By default, the Docker container runs as non-root with UID=1000 and GID=1000. To set this to your own UID/GID, simply update the `environment` section in your `docker-compose.yml` like so: By default, the Docker container runs as non-root with UID=1000 and GID=1000. To set this to your own UID/GID, simply update the `environment` section in your `docker-compose.yml` like so:
``` ```yml
environment: environment:
UID: YOUR_UID UID: YOUR_UID
GID: YOUR_GID GID: YOUR_GID
@@ -109,6 +123,7 @@ If you're interested in translating the app into a new language, check out the [
* **Isaac Grynsztein** (me!) - *Initial work* * **Isaac Grynsztein** (me!) - *Initial work*
Official translators: Official translators:
* Spanish - tzahi12345 * Spanish - tzahi12345
* German - UnlimitedCookies * German - UnlimitedCookies
* Chinese - TyRoyal * Chinese - TyRoyal

View File

@@ -26,6 +26,8 @@ const shortid = require('shortid')
const url_api = require('url'); const url_api = require('url');
var config_api = require('./config.js'); var config_api = require('./config.js');
var subscriptions_api = require('./subscriptions') var subscriptions_api = require('./subscriptions')
var categories_api = require('./categories');
var twitch_api = require('./twitch');
const CONSTS = require('./consts') const CONSTS = require('./consts')
const { spawn } = require('child_process') const { spawn } = require('child_process')
const read_last_lines = require('read-last-lines'); const read_last_lines = require('read-last-lines');
@@ -36,7 +38,8 @@ const is_windows = process.platform === 'win32';
var app = express(); var app = express();
// database setup // database setup
const FileSync = require('lowdb/adapters/FileSync') const FileSync = require('lowdb/adapters/FileSync');
const config = require('./config.js');
const adapter = new FileSync('./appdata/db.json'); const adapter = new FileSync('./appdata/db.json');
const db = low(adapter) const db = low(adapter)
@@ -79,8 +82,7 @@ config_api.initialize(logger);
auth_api.initialize(users_db, logger); auth_api.initialize(users_db, logger);
db_api.initialize(db, users_db, logger); db_api.initialize(db, users_db, logger);
subscriptions_api.initialize(db, users_db, logger, db_api); subscriptions_api.initialize(db, users_db, logger, db_api);
categories_api.initialize(db, users_db, logger, db_api);
// var GithubContent = require('github-content');
// Set some defaults // Set some defaults
db.defaults( db.defaults(
@@ -155,8 +157,8 @@ if (just_restarted) {
fs.unlinkSync('restart.json'); fs.unlinkSync('restart.json');
} }
// updates & starts youtubedl // updates & starts youtubedl (commented out b/c of repo takedown)
startYoutubeDL(); // startYoutubeDL();
var validDownloadingAgents = [ var validDownloadingAgents = [
'aria2c', 'aria2c',
@@ -173,7 +175,6 @@ const subscription_timeouts = {};
// don't overwrite config if it already happened.. NOT // don't overwrite config if it already happened.. NOT
// let alreadyWritten = db.get('configWriteFlag').value(); // let alreadyWritten = db.get('configWriteFlag').value();
let writeConfigMode = process.env.write_ytdl_config; let writeConfigMode = process.env.write_ytdl_config;
var config = null;
// checks if config exists, if not, a config is auto generated // checks if config exists, if not, a config is auto generated
config_api.configExistsCheck(); config_api.configExistsCheck();
@@ -559,6 +560,9 @@ async function loadConfig() {
// creates archive path if missing // creates archive path if missing
await fs.ensureDir(archivePath); await fs.ensureDir(archivePath);
// now this is done here due to youtube-dl's repo takedown
await startYoutubeDL();
// get subscriptions // get subscriptions
if (allowSubscriptions) { if (allowSubscriptions) {
// runs initially, then runs every ${subscriptionCheckInterval} seconds // runs initially, then runs every ${subscriptionCheckInterval} seconds
@@ -1077,6 +1081,7 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) {
var is_audio = type === 'audio'; var is_audio = type === 'audio';
var ext = is_audio ? '.mp3' : '.mp4'; var ext = is_audio ? '.mp3' : '.mp4';
var fileFolderPath = type === 'audio' ? audioFolderPath : videoFolderPath; var fileFolderPath = type === 'audio' ? audioFolderPath : videoFolderPath;
let category = null;
// prepend with user if needed // prepend with user if needed
let multiUserMode = null; let multiUserMode = null;
@@ -1093,7 +1098,7 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) {
} }
options.downloading_method = 'exec'; options.downloading_method = 'exec';
const downloadConfig = await generateArgs(url, type, options); let downloadConfig = await generateArgs(url, type, options);
// adds download to download helper // adds download to download helper
const download_uid = uuid(); const download_uid = uuid();
@@ -1115,11 +1120,22 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) {
updateDownloads(); updateDownloads();
// get video info prior to download // get video info prior to download
const info = await getVideoInfoByURL(url, downloadConfig, download); let info = await getVideoInfoByURL(url, downloadConfig, download);
if (!info) { if (!info) {
resolve(false); resolve(false);
return; return;
} else { } else {
// check if it fits into a category. If so, then get info again using new downloadConfig
category = await categories_api.categorize(info);
// set custom output if the category has one and re-retrieve info so the download manager has the right file name
if (category && category['custom_output']) {
options.customOutput = category['custom_output'];
options.noRelativePath = true;
downloadConfig = await generateArgs(url, type, options);
info = await getVideoInfoByURL(url, downloadConfig, download);
}
// store info in download for future use // store info in download for future use
download['_filename'] = info['_filename']; download['_filename'] = info['_filename'];
download['filesize'] = utils.getExpectedFileSize(info); download['filesize'] = utils.getExpectedFileSize(info);
@@ -1161,7 +1177,7 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) {
} catch(e) { } catch(e) {
output_json = null; output_json = null;
} }
var modified_file_name = output_json ? output_json['title'] : null;
if (!output_json) { if (!output_json) {
continue; continue;
} }
@@ -1172,6 +1188,13 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) {
var full_file_path = filepath_no_extension + ext; var full_file_path = filepath_no_extension + ext;
var file_name = filepath_no_extension.substring(fileFolderPath.length, filepath_no_extension.length); var file_name = filepath_no_extension.substring(fileFolderPath.length, filepath_no_extension.length);
if (type === 'video' && url.includes('twitch.tv/videos/') && url.split('twitch.tv/videos/').length > 1
&& config.getConfigItem('ytdl_use_twitch_api') && config.getConfigItem('ytdl_twitch_auto_download_chat')) {
let vodId = url.split('twitch.tv/videos/')[1];
vodId = vodId.split('?')[0];
downloadTwitchChatByVODID(vodId, file_name, type, options.user);
}
// renames file if necessary due to bug // renames file if necessary due to bug
if (!fs.existsSync(output_json['_filename'] && fs.existsSync(output_json['_filename'] + '.webm'))) { if (!fs.existsSync(output_json['_filename'] && fs.existsSync(output_json['_filename'] + '.webm'))) {
try { try {
@@ -1190,8 +1213,11 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) {
if (!success) logger.error('Failed to apply ID3 tag to audio file ' + output_json['_filename']); if (!success) logger.error('Failed to apply ID3 tag to audio file ' + output_json['_filename']);
} }
const file_path = options.noRelativePath ? path.basename(full_file_path) : full_file_path.substring(fileFolderPath.length, full_file_path.length);
const customPath = options.noRelativePath ? path.dirname(full_file_path).split(path.sep).pop() : null;
// registers file in DB // registers file in DB
file_uid = db_api.registerFileDB(full_file_path.substring(fileFolderPath.length, full_file_path.length), type, multiUserMode); file_uid = db_api.registerFileDB(file_path, type, multiUserMode, null, customPath);
if (file_name) file_names.push(file_name); if (file_name) file_names.push(file_name);
} }
@@ -1362,7 +1388,7 @@ async function downloadFileByURL_normal(url, type, options, sessionID = null) {
} }
async function generateArgs(url, type, options) { async function generateArgs(url, type, options) {
var videopath = '%(title)s'; var videopath = config_api.getConfigItem('ytdl_default_file_output') ? config_api.getConfigItem('ytdl_default_file_output') : '%(title)s';
var globalArgs = config_api.getConfigItem('ytdl_custom_args'); var globalArgs = config_api.getConfigItem('ytdl_custom_args');
let useCookies = config_api.getConfigItem('ytdl_use_cookies'); let useCookies = config_api.getConfigItem('ytdl_use_cookies');
var is_audio = type === 'audio'; var is_audio = type === 'audio';
@@ -1406,7 +1432,8 @@ async function generateArgs(url, type, options) {
} }
if (customOutput) { if (customOutput) {
downloadConfig = ['-o', path.join(fileFolderPath, customOutput) + ".%(ext)s", '--write-info-json', '--print-json']; customOutput = options.noRelativePath ? customOutput : path.join(fileFolderPath, customOutput);
downloadConfig = ['-o', `${customOutput}.%(ext)s`, '--write-info-json', '--print-json'];
} else { } else {
downloadConfig = ['-o', path.join(fileFolderPath, videopath + (is_audio ? '.%(ext)s' : '.mp4')), '--write-info-json', '--print-json']; downloadConfig = ['-o', path.join(fileFolderPath, videopath + (is_audio ? '.%(ext)s' : '.mp4')), '--write-info-json', '--print-json'];
} }
@@ -1577,6 +1604,8 @@ function checkDownloadPercent(download) {
const filename = path.format(path.parse(download['_filename'].substring(0, download['_filename'].length-4))); const filename = path.format(path.parse(download['_filename'].substring(0, download['_filename'].length-4)));
const resulting_file_size = download['filesize']; const resulting_file_size = download['filesize'];
if (!resulting_file_size) return;
glob(`${filename}*`, (err, files) => { glob(`${filename}*`, (err, files) => {
let sum_size = 0; let sum_size = 0;
files.forEach(file => { files.forEach(file => {
@@ -1598,12 +1627,16 @@ function checkDownloadPercent(download) {
async function startYoutubeDL() { async function startYoutubeDL() {
// auto update youtube-dl // auto update youtube-dl
if (!debugMode) await autoUpdateYoutubeDL(); await autoUpdateYoutubeDL();
} }
// auto updates the underlying youtube-dl binary, not YoutubeDL-Material // auto updates the underlying youtube-dl binary, not YoutubeDL-Material
async function autoUpdateYoutubeDL() { async function autoUpdateYoutubeDL() {
return new Promise(resolve => { return new Promise(async resolve => {
const default_downloader = config_api.getConfigItem('ytdl_default_downloader');
const using_youtube_dlc = default_downloader === 'youtube-dlc';
const youtube_dl_tags_url = 'https://api.github.com/repos/ytdl-org/youtube-dl/tags'
const youtube_dlc_tags_url = 'https://api.github.com/repos/blackjack4494/yt-dlc/tags'
// get current version // get current version
let current_app_details_path = 'node_modules/youtube-dl/bin/details'; let current_app_details_path = 'node_modules/youtube-dl/bin/details';
let current_app_details_exists = fs.existsSync(current_app_details_path); let current_app_details_exists = fs.existsSync(current_app_details_path);
@@ -1630,42 +1663,77 @@ async function autoUpdateYoutubeDL() {
} }
// got version, now let's check the latest version from the youtube-dl API // got version, now let's check the latest version from the youtube-dl API
let youtubedl_api_path = 'https://api.github.com/repos/ytdl-org/youtube-dl/tags'; let youtubedl_api_path = using_youtube_dlc ? youtube_dlc_tags_url : youtube_dl_tags_url;
if (default_downloader === 'youtube-dl') {
await downloadLatestYoutubeDLBinary('unknown', 'unknown');
resolve(true);
return;
}
fetch(youtubedl_api_path, {method: 'Get'}) fetch(youtubedl_api_path, {method: 'Get'})
.then(async res => res.json()) .then(async res => res.json())
.then(async (json) => { .then(async (json) => {
// check if the versions are different // check if the versions are different
if (!json || !json[0]) { if (!json || !json[0]) {
logger.error(`Failed to check ${default_downloader} version for an update.`)
resolve(false); resolve(false);
return false; return false;
} }
const latest_update_version = json[0]['name']; const latest_update_version = json[0]['name'];
if (current_version !== latest_update_version) { if (current_version !== latest_update_version) {
let binary_path = 'node_modules/youtube-dl/bin';
// versions different, download new update // versions different, download new update
logger.info('Found new update for youtube-dl. Updating binary...'); logger.info(`Found new update for ${default_downloader}. Updating binary...`);
try { try {
await checkExistsWithTimeout(stored_binary_path, 10000); await checkExistsWithTimeout(stored_binary_path, 10000);
} catch(e) { } catch(e) {
logger.error(`Failed to update youtube-dl - ${e}`); logger.error(`Failed to update ${default_downloader} - ${e}`);
} }
downloader(binary_path, function error(err, done) { if (using_youtube_dlc) await downloadLatestYoutubeDLCBinary(latest_update_version);
if (err) { else await downloadLatestYoutubeDLBinary(current_version, latest_update_version);
logger.error(err);
resolve(false); resolve(true);
} } else {
logger.info(`Binary successfully updated: ${current_version} -> ${latest_update_version}`); resolve(false);
resolve(true);
});
} }
}) })
.catch(err => { .catch(err => {
logger.error('Failed to check youtube-dl version for an update.') logger.error(`Failed to check ${default_downloader} version for an update.`)
logger.error(err) logger.error(err)
}); });
}); });
} }
async function downloadLatestYoutubeDLBinary(current_version, new_version) {
return new Promise(resolve => {
let binary_path = 'node_modules/youtube-dl/bin';
downloader(binary_path, function error(err, done) {
if (err) {
logger.error(`youtube-dl failed to update. Restart the server to try again.`);
logger.error(err);
resolve(false);
}
logger.info(`youtube-dl successfully updated!`);
resolve(true);
});
});
}
async function downloadLatestYoutubeDLCBinary(new_version) {
const file_ext = is_windows ? '.exe' : '';
const download_url = `https://github.com/blackjack4494/yt-dlc/releases/latest/download/youtube-dlc${file_ext}`;
const output_path = `node_modules/youtube-dl/bin/youtube-dl${file_ext}`;
await fetchFile(download_url, output_path, `youtube-dlc ${new_version}`);
const details_path = 'node_modules/youtube-dl/bin/details';
const details_json = fs.readJSONSync('node_modules/youtube-dl/bin/details');
details_json['version'] = new_version;
fs.writeJSONSync(details_path, details_json);
}
async function checkExistsWithTimeout(filePath, timeout) { async function checkExistsWithTimeout(filePath, timeout) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
@@ -1700,6 +1768,42 @@ function removeFileExtension(filename) {
return filename_parts.join('.'); return filename_parts.join('.');
} }
async function getTwitchChatByFileID(id, type, user_uid, uuid) {
let file_path = null;
if (user_uid) {
file_path = path.join('users', user_uid, type, id + '.twitch_chat.json');
} else {
file_path = path.join(type, id + '.twitch_chat.json');
}
var chat_file = null;
if (fs.existsSync(file_path)) {
chat_file = fs.readJSONSync(file_path);
}
return chat_file;
}
async function downloadTwitchChatByVODID(vodId, id, type, user_uid) {
const twitch_api_key = config_api.getConfigItem('ytdl_twitch_api_key');
const chat = await twitch_api.getCommentsForVOD(twitch_api_key, vodId);
// save file if needec params are included
if (id && type) {
let file_path = null;
if (user_uid) {
file_path = path.join('users', user_uid, type, id + '.twitch_chat.json');
} else {
file_path = path.join(type, id + '.twitch_chat.json');
}
if (chat) fs.writeJSONSync(file_path, chat);
}
return chat;
}
app.use(function(req, res, next) { app.use(function(req, res, next) {
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization"); res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization");
res.header("Access-Control-Allow-Origin", getOrigin()); res.header("Access-Control-Allow-Origin", getOrigin());
@@ -1715,13 +1819,9 @@ app.use(function(req, res, next) {
next(); next();
} else if (req.query.apiKey === admin_token) { } else if (req.query.apiKey === admin_token) {
next(); next();
} else if (req.query.apiKey && config_api.getConfigItem('ytdl_use_api_key')) { } else if (req.query.apiKey && config_api.getConfigItem('ytdl_use_api_key') && req.query.apiKey === config_api.getConfigItem('ytdl_api_key')) {
if (req.query.apiKey === config_api.getConfigItem('ytdl_api_key')) { next();
next(); } else if (req.path.includes('/api/stream/') || req.path.includes('/api/thumbnail/')) {
} else {
res.status(401).send('Invalid API key');
}
} else if (req.path.includes('/api/video/') || req.path.includes('/api/audio/')) {
next(); next();
} else { } else {
logger.verbose(`Rejecting request - invalid API use for endpoint: ${req.path}. API key received: ${req.query.apiKey}`); logger.verbose(`Rejecting request - invalid API use for endpoint: ${req.path}. API key received: ${req.query.apiKey}`);
@@ -1734,8 +1834,7 @@ app.use(compression());
const optionalJwt = function (req, res, next) { const optionalJwt = function (req, res, next) {
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode'); const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
if (multiUserMode && ((req.body && req.body.uuid) || (req.query && req.query.uuid)) && (req.path.includes('/api/getFile') || if (multiUserMode && ((req.body && req.body.uuid) || (req.query && req.query.uuid)) && (req.path.includes('/api/getFile') ||
req.path.includes('/api/audio') || req.path.includes('/api/stream') ||
req.path.includes('/api/video') ||
req.path.includes('/api/downloadFile'))) { req.path.includes('/api/downloadFile'))) {
// check if shared video // check if shared video
const using_body = req.body && req.body.uuid; const using_body = req.body && req.body.uuid;
@@ -1875,8 +1974,11 @@ app.get('/api/getMp3s', optionalJwt, async function(req, res) {
mp3s = JSON.parse(JSON.stringify(mp3s)); mp3s = JSON.parse(JSON.stringify(mp3s));
// add thumbnails if present if (config_api.getConfigItem('ytdl_include_thumbnail')) {
await addThumbnails(mp3s); // add thumbnails if present
// await addThumbnails(mp3s);
}
res.send({ res.send({
mp3s: mp3s, mp3s: mp3s,
@@ -1899,8 +2001,10 @@ app.get('/api/getMp4s', optionalJwt, async function(req, res) {
mp4s = JSON.parse(JSON.stringify(mp4s)); mp4s = JSON.parse(JSON.stringify(mp4s));
// add thumbnails if present if (config_api.getConfigItem('ytdl_include_thumbnail')) {
await addThumbnails(mp4s); // add thumbnails if present
// await addThumbnails(mp4s);
}
res.send({ res.send({
mp4s: mp4s, mp4s: mp4s,
@@ -1988,8 +2092,10 @@ app.post('/api/getAllFiles', optionalJwt, async function (req, res) {
files = JSON.parse(JSON.stringify(files)); files = JSON.parse(JSON.stringify(files));
// add thumbnails if present if (config_api.getConfigItem('ytdl_include_thumbnail')) {
await addThumbnails(files); // add thumbnails if present
// await addThumbnails(files);
}
res.send({ res.send({
files: files, files: files,
@@ -1997,6 +2103,54 @@ app.post('/api/getAllFiles', optionalJwt, async function (req, res) {
}); });
}); });
app.post('/api/getFullTwitchChat', optionalJwt, async (req, res) => {
var id = req.body.id;
var type = req.body.type;
var uuid = req.body.uuid;
var user_uid = null;
if (req.isAuthenticated()) user_uid = req.user.uid;
const chat_file = await getTwitchChatByFileID(id, type, user_uid, uuid);
res.send({
chat: chat_file
});
});
app.post('/api/downloadTwitchChatByVODID', optionalJwt, async (req, res) => {
var id = req.body.id;
var type = req.body.type;
var vodId = req.body.vodId;
var uuid = req.body.uuid;
var user_uid = null;
if (req.isAuthenticated()) user_uid = req.user.uid;
// check if file already exists. if so, send that instead
const file_exists_check = await getTwitchChatByFileID(id, type, user_uid, uuid);
if (file_exists_check) {
res.send({chat: file_exists_check});
return;
}
const full_chat = await downloadTwitchChatByVODID(vodId);
let file_path = null;
if (user_uid) {
file_path = path.join('users', req.user.uid, type, id + '.twitch_chat.json');
} else {
file_path = path.join(type, id + '.twitch_chat.json');
}
if (full_chat) fs.writeJSONSync(file_path, full_chat);
res.send({
chat: full_chat
});
});
// video sharing // video sharing
app.post('/api/enableSharing', optionalJwt, function(req, res) { app.post('/api/enableSharing', optionalJwt, function(req, res) {
var type = req.body.type; var type = req.body.type;
@@ -2084,9 +2238,58 @@ app.post('/api/disableSharing', optionalJwt, function(req, res) {
}); });
}); });
// categories
app.post('/api/getAllCategories', optionalJwt, async (req, res) => {
const categories = db.get('categories').value();
res.send({categories: categories});
});
app.post('/api/createCategory', optionalJwt, async (req, res) => {
const name = req.body.name;
const new_category = {
name: name,
uid: uuid(),
rules: [],
custom_putput: ''
};
db.get('categories').push(new_category).write();
res.send({
new_category: new_category,
success: !!new_category
});
});
app.post('/api/deleteCategory', optionalJwt, async (req, res) => {
const category_uid = req.body.category_uid;
db.get('categories').remove({uid: category_uid}).write();
res.send({
success: true
});
});
app.post('/api/updateCategory', optionalJwt, async (req, res) => {
const category = req.body.category;
db.get('categories').find({uid: category.uid}).assign(category).write();
res.send({success: true});
});
app.post('/api/updateCategories', optionalJwt, async (req, res) => {
const categories = req.body.categories;
db.get('categories').assign(categories).write();
res.send({success: true});
});
// subscriptions
app.post('/api/subscribe', optionalJwt, async (req, res) => { app.post('/api/subscribe', optionalJwt, async (req, res) => {
let name = req.body.name; let name = req.body.name;
let url = req.body.url; let url = req.body.url;
let maxQuality = req.body.maxQuality;
let timerange = req.body.timerange; let timerange = req.body.timerange;
let streamingOnly = req.body.streamingOnly; let streamingOnly = req.body.streamingOnly;
let audioOnly = req.body.audioOnly; let audioOnly = req.body.audioOnly;
@@ -2096,6 +2299,7 @@ app.post('/api/subscribe', optionalJwt, async (req, res) => {
const new_sub = { const new_sub = {
name: name, name: name,
url: url, url: url,
maxQuality: maxQuality,
id: uuid(), id: uuid(),
streamingOnly: streamingOnly, streamingOnly: streamingOnly,
user_uid: user_uid, user_uid: user_uid,
@@ -2168,10 +2372,17 @@ app.post('/api/deleteSubscriptionFile', optionalJwt, async (req, res) => {
app.post('/api/getSubscription', optionalJwt, async (req, res) => { app.post('/api/getSubscription', optionalJwt, async (req, res) => {
let subID = req.body.id; let subID = req.body.id;
let subName = req.body.name; // if included, subID is optional
let user_uid = req.isAuthenticated() ? req.user.uid : null; let user_uid = req.isAuthenticated() ? req.user.uid : null;
// get sub from db // get sub from db
let subscription = subscriptions_api.getSubscription(subID, user_uid); let subscription = null;
if (subID) {
subscription = subscriptions_api.getSubscription(subID, user_uid)
} else if (subName) {
subscription = subscriptions_api.getSubscriptionByName(subName, user_uid)
}
if (!subscription) { if (!subscription) {
// failed to get subscription from db, send 400 error // failed to get subscription from db, send 400 error
@@ -2401,56 +2612,25 @@ app.post('/api/deletePlaylist', optionalJwt, async (req, res) => {
}) })
}); });
// deletes mp3 file // deletes non-subscription files
app.post('/api/deleteMp3', optionalJwt, async (req, res) => { app.post('/api/deleteFile', optionalJwt, async (req, res) => {
// var name = req.body.name;
var uid = req.body.uid; var uid = req.body.uid;
var type = req.body.type;
var blacklistMode = req.body.blacklistMode; var blacklistMode = req.body.blacklistMode;
if (req.isAuthenticated()) { if (req.isAuthenticated()) {
let success = await auth_api.deleteUserFile(req.user.uid, uid, 'audio', blacklistMode); let success = auth_api.deleteUserFile(req.user.uid, uid, type, blacklistMode);
res.send(success); res.send(success);
return; return;
} }
var audio_obj = db.get('files.audio').find({uid: uid}).value(); var file_obj = db.get(`files.${type}`).find({uid: uid}).value();
var name = audio_obj.id; var name = file_obj.id;
var fullpath = audioFolderPath + name + ".mp3"; var fullpath = file_obj ? file_obj.path : null;
var wasDeleted = false; var wasDeleted = false;
if (await fs.pathExists(fullpath)) if (await fs.pathExists(fullpath))
{ {
deleteAudioFile(name, null, blacklistMode); wasDeleted = type === 'audio' ? await deleteAudioFile(name, path.basename(fullpath), blacklistMode) : await deleteVideoFile(name, path.basename(fullpath), blacklistMode);
db.get('files.audio').remove({uid: uid}).write();
wasDeleted = true;
res.send(wasDeleted);
} else if (audio_obj) {
db.get('files.audio').remove({uid: uid}).write();
wasDeleted = true;
res.send(wasDeleted);
} else {
wasDeleted = false;
res.send(wasDeleted);
}
});
// deletes mp4 file
app.post('/api/deleteMp4', optionalJwt, async (req, res) => {
var uid = req.body.uid;
var blacklistMode = req.body.blacklistMode;
if (req.isAuthenticated()) {
let success = await auth_api.deleteUserFile(req.user.uid, uid, 'video', blacklistMode);
res.send(success);
return;
}
var video_obj = db.get('files.video').find({uid: uid}).value();
var name = video_obj.id;
var fullpath = videoFolderPath + name + ".mp4";
var wasDeleted = false;
if (await fs.pathExists(fullpath))
{
wasDeleted = await deleteVideoFile(name, null, blacklistMode);
db.get('files.video').remove({uid: uid}).write(); db.get('files.video').remove({uid: uid}).write();
// wasDeleted = true; // wasDeleted = true;
res.send(wasDeleted); res.send(wasDeleted);
@@ -2517,17 +2697,6 @@ app.post('/api/downloadFile', optionalJwt, async (req, res) => {
}); });
}); });
app.post('/api/deleteFile', async (req, res) => {
let fileName = req.body.fileName;
let type = req.body.type;
if (type === 'audio') {
deleteAudioFile(fileName);
} else if (type === 'video') {
deleteVideoFile(fileName);
}
res.send({});
});
app.post('/api/downloadArchive', async (req, res) => { app.post('/api/downloadArchive', async (req, res) => {
let sub = req.body.sub; let sub = req.body.sub;
let archive_dir = sub.archive; let archive_dir = sub.archive;
@@ -2595,25 +2764,33 @@ app.post('/api/generateNewAPIKey', function (req, res) {
// Streaming API calls // Streaming API calls
app.get('/api/video/:id', optionalJwt, function(req , res){ app.get('/api/stream/:id', optionalJwt, (req, res) => {
const type = req.query.type;
const ext = type === 'audio' ? '.mp3' : '.mp4';
const mimetype = type === 'audio' ? 'audio/mp3' : 'video/mp4';
var head; var head;
let optionalParams = url_api.parse(req.url,true).query; let optionalParams = url_api.parse(req.url,true).query;
let id = decodeURIComponent(req.params.id); let id = decodeURIComponent(req.params.id);
let file_path = videoFolderPath + id + '.mp4'; let file_path = req.query.file_path ? decodeURIComponent(req.query.file_path) : null;
if (req.isAuthenticated() || req.can_watch) { if (!file_path && (req.isAuthenticated() || req.can_watch)) {
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
if (optionalParams['subName']) { if (optionalParams['subName']) {
const isPlaylist = optionalParams['subPlaylist']; const isPlaylist = optionalParams['subPlaylist'];
file_path = path.join(usersFileFolder, req.user.uid, 'subscriptions', (isPlaylist === 'true' ? 'playlists/' : 'channels/'),optionalParams['subName'], id + '.mp4') file_path = path.join(usersFileFolder, req.user.uid, 'subscriptions', (isPlaylist === 'true' ? 'playlists/' : 'channels/'),optionalParams['subName'], id + ext)
} else { } else {
file_path = path.join(usersFileFolder, req.query.uuid ? req.query.uuid : req.user.uid, 'video', id + '.mp4'); file_path = path.join(usersFileFolder, req.query.uuid ? req.query.uuid : req.user.uid, type, id + ext);
} }
} else if (optionalParams['subName']) { } else if (!file_path && optionalParams['subName']) {
let basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); let basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
const isPlaylist = optionalParams['subPlaylist']; const isPlaylist = optionalParams['subPlaylist'];
basePath += (isPlaylist === 'true' ? 'playlists/' : 'channels/'); basePath += (isPlaylist === 'true' ? 'playlists/' : 'channels/');
file_path = basePath + optionalParams['subName'] + '/' + id + '.mp4'; file_path = basePath + optionalParams['subName'] + '/' + id + ext;
} }
if (!file_path) {
file_path = path.join(videoFolderPath, id + ext);
}
const stat = fs.statSync(file_path) const stat = fs.statSync(file_path)
const fileSize = stat.size const fileSize = stat.size
const range = req.headers.range const range = req.headers.range
@@ -2636,76 +2813,25 @@ app.get('/api/video/:id', optionalJwt, function(req , res){
'Content-Range': `bytes ${start}-${end}/${fileSize}`, 'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes', 'Accept-Ranges': 'bytes',
'Content-Length': chunksize, 'Content-Length': chunksize,
'Content-Type': 'video/mp4', 'Content-Type': mimetype,
} }
res.writeHead(206, head); res.writeHead(206, head);
file.pipe(res); file.pipe(res);
} else { } else {
head = { head = {
'Content-Length': fileSize, 'Content-Length': fileSize,
'Content-Type': 'video/mp4', 'Content-Type': mimetype,
} }
res.writeHead(200, head) res.writeHead(200, head)
fs.createReadStream(file_path).pipe(res) fs.createReadStream(file_path).pipe(res)
} }
}); });
app.get('/api/audio/:id', optionalJwt, function(req , res){ app.get('/api/thumbnail/:path', optionalJwt, async (req, res) => {
var head; let file_path = decodeURIComponent(req.params.path);
let id = decodeURIComponent(req.params.id); if (fs.existsSync(file_path)) path.isAbsolute(file_path) ? res.sendFile(file_path) : res.sendFile(path.join(__dirname, file_path));
let file_path = "audio/" + id + '.mp3'; else res.sendStatus(404);
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); });
let optionalParams = url_api.parse(req.url,true).query;
if (req.isAuthenticated()) {
if (optionalParams['subName']) {
const isPlaylist = optionalParams['subPlaylist'];
file_path = path.join(usersFileFolder, req.user.uid, 'subscriptions', (isPlaylist === 'true' ? 'playlists/' : 'channels/'),optionalParams['subName'], id + '.mp3')
} else {
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
file_path = path.join(usersFileFolder, req.user.uid, 'audio', id + '.mp3');
}
} else if (optionalParams['subName']) {
let basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
const isPlaylist = optionalParams['subPlaylist'];
basePath += (isPlaylist === 'true' ? 'playlists/' : 'channels/');
file_path = basePath + optionalParams['subName'] + '/' + id + '.mp3';
}
file_path = file_path.replace(/\"/g, '\'');
const stat = fs.statSync(file_path)
const fileSize = stat.size
const range = req.headers.range
if (range) {
const parts = range.replace(/bytes=/, "").split("-")
const start = parseInt(parts[0], 10)
const end = parts[1]
? parseInt(parts[1], 10)
: fileSize-1
const chunksize = (end-start)+1
const file = fs.createReadStream(file_path, {start, end});
if (config_api.descriptors[id]) config_api.descriptors[id].push(file);
else config_api.descriptors[id] = [file];
file.on('close', function() {
let index = config_api.descriptors[id].indexOf(file);
config_api.descriptors[id].splice(index, 1);
logger.debug('Successfully closed stream and removed file reference.');
});
head = {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': 'audio/mp3',
}
res.writeHead(206, head);
file.pipe(res);
} else {
head = {
'Content-Length': fileSize,
'Content-Type': 'audio/mp3',
}
res.writeHead(200, head)
fs.createReadStream(file_path).pipe(res)
}
});
// Downloads management // Downloads management

View File

@@ -7,6 +7,7 @@
"Downloader": { "Downloader": {
"path-audio": "audio/", "path-audio": "audio/",
"path-video": "video/", "path-video": "video/",
"default_file_output": "",
"use_youtubedl_archive": false, "use_youtubedl_archive": false,
"custom_args": "", "custom_args": "",
"safe_download_override": false, "safe_download_override": false,
@@ -25,7 +26,10 @@
"use_API_key": false, "use_API_key": false,
"API_key": "", "API_key": "",
"use_youtube_API": false, "use_youtube_API": false,
"youtube_API_key": "" "youtube_API_key": "",
"use_twitch_API": false,
"twitch_API_key": "",
"twitch_auto_download_chat": false
}, },
"Themes": { "Themes": {
"default_theme": "default", "default_theme": "default",
@@ -49,6 +53,7 @@
} }
}, },
"Advanced": { "Advanced": {
"default_downloader": "youtube-dl",
"use_default_downloading_agent": true, "use_default_downloading_agent": true,
"custom_downloading_agent": "", "custom_downloading_agent": "",
"multi_user_mode": false, "multi_user_mode": false,

123
backend/categories.js Normal file
View File

@@ -0,0 +1,123 @@
const config_api = require('./config');
var logger = null;
var db = null;
var users_db = null;
var 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 setLogger(input_logger) { logger = input_logger; }
function initialize(input_db, input_users_db, input_logger, input_db_api) {
setDB(input_db, input_users_db, input_db_api);
setLogger(input_logger);
}
/*
Categories:
Categories are a way to organize videos based on dynamic rules set by the user. Categories are universal (so not per-user).
Categories, besides rules, have an optional custom output. This custom output can help users create their
desired directory structure.
Rules:
A category rule consists of a property, a comparison, and a value. For example, "uploader includes 'VEVO'"
Rules are stored as an object with the above fields. In addition to those fields, it also has a preceding_operator, which
is either OR or AND, and signifies whether the rule should be ANDed with the previous rules, or just ORed. For the first
rule, this field is null.
Ex. (title includes 'Rihanna' OR title includes 'Beyonce' AND uploader includes 'VEVO')
*/
async function categorize(file_json) {
let selected_category = null;
const categories = getCategories();
if (!categories) {
logger.warn('Categories could not be found. Initializing categories...');
db.assign({categories: []}).write();
return null;
return;
}
for (let i = 0; i < categories.length; i++) {
const category = categories[i];
const rules = category['rules'];
// if rules for current category apply, then that is the selected category
if (applyCategoryRules(file_json, rules, category['name'])) {
selected_category = category;
logger.verbose(`Selected category ${category['name']} for ${file_json['webpage_url']}`);
return selected_category;
}
}
return selected_category;
}
function getCategories() {
const categories = db.get('categories').value();
return categories ? categories : null;
}
function applyCategoryRules(file_json, rules, category_name) {
let rules_apply = false;
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
let rule_applies = null;
let preceding_operator = rule['preceding_operator'];
switch (rule['comparator']) {
case 'includes':
rule_applies = file_json[rule['property']].includes(rule['value']);
break;
case 'not_includes':
rule_applies = !(file_json[rule['property']].includes(rule['value']));
break;
case 'equals':
rule_applies = file_json[rule['property']] === rule['value'];
break;
case 'not_equals':
rule_applies = file_json[rule['property']] !== rule['value'];
break;
default:
logger.warn(`Invalid comparison used for category ${category_name}`)
break;
}
// OR the first rule with rules_apply, which will be initially false
if (i === 0) preceding_operator = 'or';
// update rules_apply based on current rule
if (preceding_operator === 'or')
rules_apply = rules_apply || rule_applies;
else
rules_apply = rules_apply && rule_applies;
}
return rules_apply;
}
async function addTagToVideo(tag, video, user_uid) {
// TODO: Implement
}
async function removeTagFromVideo(tag, video, user_uid) {
// TODO: Implement
}
// adds tag to list of existing tags (used for tag suggestions)
async function addTagToExistingTags(tag) {
const existing_tags = db.get('tags').value();
if (!existing_tags.includes(tag)) {
db.get('tags').push(tag).write();
}
}
module.exports = {
initialize: initialize,
categorize: categorize,
}

View File

@@ -184,6 +184,7 @@ DEFAULT_CONFIG = {
"Downloader": { "Downloader": {
"path-audio": "audio/", "path-audio": "audio/",
"path-video": "video/", "path-video": "video/",
"default_file_output": "",
"use_youtubedl_archive": false, "use_youtubedl_archive": false,
"custom_args": "", "custom_args": "",
"safe_download_override": false, "safe_download_override": false,
@@ -202,7 +203,10 @@ DEFAULT_CONFIG = {
"use_API_key": false, "use_API_key": false,
"API_key": "", "API_key": "",
"use_youtube_API": false, "use_youtube_API": false,
"youtube_API_key": "" "youtube_API_key": "",
"use_twitch_API": false,
"twitch_API_key": "",
"twitch_auto_download_chat": false
}, },
"Themes": { "Themes": {
"default_theme": "default", "default_theme": "default",
@@ -226,6 +230,7 @@ DEFAULT_CONFIG = {
} }
}, },
"Advanced": { "Advanced": {
"default_downloader": "youtube-dl",
"use_default_downloading_agent": true, "use_default_downloading_agent": true,
"custom_downloading_agent": "", "custom_downloading_agent": "",
"multi_user_mode": false, "multi_user_mode": false,

View File

@@ -18,6 +18,10 @@ let CONFIG_ITEMS = {
'key': 'ytdl_video_folder_path', 'key': 'ytdl_video_folder_path',
'path': 'YoutubeDLMaterial.Downloader.path-video' 'path': 'YoutubeDLMaterial.Downloader.path-video'
}, },
'ytdl_default_file_output': {
'key': 'ytdl_default_file_output',
'path': 'YoutubeDLMaterial.Downloader.default_file_output'
},
'ytdl_use_youtubedl_archive': { 'ytdl_use_youtubedl_archive': {
'key': 'ytdl_use_youtubedl_archive', 'key': 'ytdl_use_youtubedl_archive',
'path': 'YoutubeDLMaterial.Downloader.use_youtubedl_archive' 'path': 'YoutubeDLMaterial.Downloader.use_youtubedl_archive'
@@ -82,6 +86,18 @@ let CONFIG_ITEMS = {
'key': 'ytdl_youtube_api_key', 'key': 'ytdl_youtube_api_key',
'path': 'YoutubeDLMaterial.API.youtube_API_key' 'path': 'YoutubeDLMaterial.API.youtube_API_key'
}, },
'ytdl_use_twitch_api': {
'key': 'ytdl_use_twitch_api',
'path': 'YoutubeDLMaterial.API.use_twitch_API'
},
'ytdl_twitch_api_key': {
'key': 'ytdl_twitch_api_key',
'path': 'YoutubeDLMaterial.API.twitch_API_key'
},
'ytdl_twitch_auto_download_chat': {
'key': 'ytdl_twitch_auto_download_chat',
'path': 'YoutubeDLMaterial.API.twitch_auto_download_chat'
},
// Themes // Themes
'ytdl_default_theme': { 'ytdl_default_theme': {
@@ -130,6 +146,10 @@ let CONFIG_ITEMS = {
}, },
// Advanced // Advanced
'ytdl_default_downloader': {
'key': 'ytdl_default_downloader',
'path': 'YoutubeDLMaterial.Advanced.default_downloader'
},
'ytdl_use_default_downloading_agent': { 'ytdl_use_default_downloading_agent': {
'key': 'ytdl_use_default_downloading_agent', 'key': 'ytdl_use_default_downloading_agent',
'path': 'YoutubeDLMaterial.Advanced.use_default_downloading_agent' 'path': 'YoutubeDLMaterial.Advanced.use_default_downloading_agent'

View File

@@ -15,10 +15,10 @@ function initialize(input_db, input_users_db, input_logger) {
setLogger(input_logger); setLogger(input_logger);
} }
function registerFileDB(file_path, type, multiUserMode = null, sub = null) { function registerFileDB(file_path, type, multiUserMode = null, sub = null, customPath = null) {
let db_path = null; let db_path = null;
const file_id = file_path.substring(0, file_path.length-4); const file_id = file_path.substring(0, file_path.length-4);
const file_object = generateFileObject(file_id, type, multiUserMode && multiUserMode.file_path, sub); const file_object = generateFileObject(file_id, type, customPath || multiUserMode && multiUserMode.file_path, sub);
if (!file_object) { if (!file_object) {
logger.error(`Could not find associated JSON file for ${type} file ${file_id}`); logger.error(`Could not find associated JSON file for ${type} file ${file_id}`);
return false; return false;
@@ -27,7 +27,7 @@ function registerFileDB(file_path, type, multiUserMode = null, sub = null) {
utils.fixVideoMetadataPerms(file_id, type, multiUserMode && multiUserMode.file_path); utils.fixVideoMetadataPerms(file_id, type, multiUserMode && multiUserMode.file_path);
// add thumbnail path // add thumbnail path
file_object['thumbnailPath'] = utils.getDownloadedThumbnail(file_id, type, multiUserMode && multiUserMode.file_path); file_object['thumbnailPath'] = utils.getDownloadedThumbnail(file_id, type, customPath || multiUserMode && multiUserMode.file_path);
if (!sub) { if (!sub) {
if (multiUserMode) { if (multiUserMode) {

View File

@@ -114,7 +114,11 @@ async function getSubscriptionInfo(sub, user_uid = null) {
continue; continue;
} }
if (!sub.name) { if (!sub.name) {
sub.name = sub.isPlaylist ? output_json.playlist_title : output_json.uploader; if (sub.isPlaylist) {
sub.name = output_json.playlist_title ? output_json.playlist_title : output_json.playlist;
} else {
sub.name = output_json.uploader;
}
// if it's now valid, update // if it's now valid, update
if (sub.name) { if (sub.name) {
if (user_uid) if (user_uid)
@@ -296,7 +300,8 @@ async function getVideosForSub(sub, user_uid = null) {
qualityPath.push('-x'); qualityPath.push('-x');
qualityPath.push('--audio-format', 'mp3'); qualityPath.push('--audio-format', 'mp3');
} else { } else {
qualityPath = ['-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4'] if (!sub.maxQuality || sub.maxQuality === 'best') qualityPath = ['-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4'];
else qualityPath = ['-f', `bestvideo[height<=${sub.maxQuality}]+bestaudio/best[height<=${sub.maxQuality}]`, '--merge-output-format', 'mp4'];
} }
downloadConfig.push(...qualityPath) downloadConfig.push(...qualityPath)
@@ -351,7 +356,7 @@ async function getVideosForSub(sub, user_uid = null) {
youtubedl.exec(sub.url, downloadConfig, {}, function(err, output) { youtubedl.exec(sub.url, downloadConfig, {}, function(err, output) {
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); logger.error(err.stderr ? err.stderr : err.message);
if (err.stderr.includes('This video is unavailable')) { if (err.stderr.includes('This video is unavailable')) {
logger.info('An error was encountered with at least one video, backup method will be used.') logger.info('An error was encountered with at least one video, backup method will be used.')
try { try {
@@ -430,6 +435,13 @@ function getSubscription(subID, user_uid = null) {
return db.get('subscriptions').find({id: subID}).value(); return db.get('subscriptions').find({id: subID}).value();
} }
function getSubscriptionByName(subName, user_uid = null) {
if (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) { function updateSubscription(sub, user_uid = null) {
if (user_uid) { if (user_uid) {
users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign(sub).write(); users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign(sub).write();
@@ -482,6 +494,7 @@ async function removeIDFromArchive(archive_path, id) {
module.exports = { module.exports = {
getSubscription : getSubscription, getSubscription : getSubscription,
getSubscriptionByName : getSubscriptionByName,
getAllSubscriptions : getAllSubscriptions, getAllSubscriptions : getAllSubscriptions,
updateSubscription : updateSubscription, updateSubscription : updateSubscription,
subscribe : subscribe, subscribe : subscribe,

71
backend/twitch.js Normal file
View File

@@ -0,0 +1,71 @@
var moment = require('moment');
var Axios = require('axios');
async function getCommentsForVOD(clientID, vodId) {
let url = `https://api.twitch.tv/v5/videos/${vodId}/comments?content_offset_seconds=0`,
batch,
cursor;
let comments = null;
try {
do {
batch = (await Axios.get(url, {
headers: {
'Client-ID': clientID,
Accept: 'application/vnd.twitchtv.v5+json; charset=UTF-8',
'Content-Type': 'application/json; charset=UTF-8',
}
})).data;
const str = batch.comments.map(c => {
let {
created_at: msgCreated,
content_offset_seconds: timestamp,
commenter: {
name,
_id,
created_at: acctCreated
},
message: {
body: msg
}
} = c;
const timestamp_str = moment.duration(timestamp, 'seconds')
.toISOString()
.replace(/P.*?T(?:(\d+?)H)?(?:(\d+?)M)?(?:(\d+).*?S)?/,
(_, ...ms) => {
const seg = v => v ? v.padStart(2, '0') : '00';
return `${seg(ms[0])}:${seg(ms[1])}:${seg(ms[2])}`;
});
acctCreated = moment(acctCreated).utc();
msgCreated = moment(msgCreated).utc();
if (!comments) comments = [];
comments.push({
timestamp: timestamp,
timestamp_str: timestamp_str,
name: name,
message: msg
});
// let line = `${timestamp},${msgCreated.format(tsFormat)},${name},${_id},"${msg.replace(/"/g, '""')}",${acctCreated.format(tsFormat)}`;
// return line;
}).join('\n');
cursor = batch._next;
url = `https://api.twitch.tv/v5/videos/${vodId}/comments?cursor=${cursor}`;
await new Promise(res => setTimeout(res, 300));
} while (cursor);
} catch (err) {
console.error(err);
}
return comments;
}
module.exports = {
getCommentsForVOD: getCommentsForVOD
}

View File

@@ -114,6 +114,7 @@ function getExpectedFileSize(info_json) {
const formats = info_json['format_id'].split('+'); const formats = info_json['format_id'].split('+');
let expected_filesize = 0; let expected_filesize = 0;
formats.forEach(format_id => { formats.forEach(format_id => {
if (!info_json.formats) return expected_filesize;
info_json.formats.forEach(available_format => { info_json.formats.forEach(available_format => {
if (available_format.format_id === format_id && available_format.filesize) { if (available_format.format_id === format_id && available_format.filesize) {
expected_filesize += available_format.filesize; expected_filesize += available_format.filesize;

View File

@@ -116,6 +116,8 @@ export class AppComponent implements OnInit, AfterViewInit {
if (this.allowSubscriptions) { if (this.allowSubscriptions) {
this.postsService.reloadSubscriptions(); this.postsService.reloadSubscriptions();
} }
this.postsService.reloadCategories();
} }
// theme stuff // theme stuff

View File

@@ -79,6 +79,8 @@ import { UnifiedFileCardComponent } from './components/unified-file-card/unified
import { RecentVideosComponent } from './components/recent-videos/recent-videos.component'; import { RecentVideosComponent } from './components/recent-videos/recent-videos.component';
import { EditSubscriptionDialogComponent } from './dialogs/edit-subscription-dialog/edit-subscription-dialog.component'; import { EditSubscriptionDialogComponent } from './dialogs/edit-subscription-dialog/edit-subscription-dialog.component';
import { CustomPlaylistsComponent } from './components/custom-playlists/custom-playlists.component'; import { CustomPlaylistsComponent } from './components/custom-playlists/custom-playlists.component';
import { EditCategoryDialogComponent } from './dialogs/edit-category-dialog/edit-category-dialog.component';
import { TwitchChatComponent } from './components/twitch-chat/twitch-chat.component';
registerLocaleData(es, 'es'); registerLocaleData(es, 'es');
@@ -123,7 +125,9 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
UnifiedFileCardComponent, UnifiedFileCardComponent,
RecentVideosComponent, RecentVideosComponent,
EditSubscriptionDialogComponent, EditSubscriptionDialogComponent,
CustomPlaylistsComponent CustomPlaylistsComponent,
EditCategoryDialogComponent,
TwitchChatComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,

View File

@@ -61,7 +61,8 @@ export class LogsViewerComponent implements OnInit {
data: { data: {
dialogTitle: 'Clear logs', dialogTitle: 'Clear logs',
dialogText: 'Would you like to clear your logs? This will delete all your current logs, permanently.', dialogText: 'Would you like to clear your logs? This will delete all your current logs, permanently.',
submitText: 'Clear' submitText: 'Clear',
warnSubmitColor: true
} }
}); });
dialogRef.afterClosed().subscribe(confirmed => { dialogRef.afterClosed().subscribe(confirmed => {

View File

@@ -32,7 +32,7 @@
<div class="row justify-content-center"> <div class="row justify-content-center">
<ng-container *ngIf="normal_files_received"> <ng-container *ngIf="normal_files_received">
<div *ngFor="let file of filtered_files; let i = index" class="mb-2 mt-2 d-flex justify-content-center" [ngClass]="[ postsService.card_size === 'small' ? 'col-2 small-col' : '', postsService.card_size === 'medium' ? 'col-6 col-lg-4 medium-col' : '', postsService.card_size === 'large' ? 'col-12 large-col' : '' ]"> <div *ngFor="let file of filtered_files; let i = index" class="mb-2 mt-2 d-flex justify-content-center" [ngClass]="[ postsService.card_size === 'small' ? 'col-2 small-col' : '', postsService.card_size === 'medium' ? 'col-6 col-lg-4 medium-col' : '', postsService.card_size === 'large' ? 'col-12 large-col' : '' ]">
<app-unified-file-card [index]="i" [card_size]="postsService.card_size" [locale]="postsService.locale" (goToFile)="goToFile($event)" (goToSubscription)="goToSubscription($event)" [file_obj]="file" [use_youtubedl_archive]="postsService.config['Downloader']['use_youtubedl_archive']" [loading]="false" (deleteFile)="deleteFile($event)"></app-unified-file-card> <app-unified-file-card [index]="i" [card_size]="postsService.card_size" [locale]="postsService.locale" (goToFile)="goToFile($event)" (goToSubscription)="goToSubscription($event)" [file_obj]="file" [use_youtubedl_archive]="postsService.config['Downloader']['use_youtubedl_archive']" [loading]="false" (deleteFile)="deleteFile($event)" [baseStreamPath]="postsService.path" [jwtString]="postsService.isLoggedIn ? '?jwt=' + this.postsService.token : ''"></app-unified-file-card>
</div> </div>
</ng-container> </ng-container>
<ng-container *ngIf="!normal_files_received && loading_files && loading_files.length > 0"> <ng-container *ngIf="!normal_files_received && loading_files && loading_files.length > 0">

View File

@@ -210,7 +210,7 @@ export class RecentVideosComponent implements OnInit {
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, false).subscribe(delRes => { this.postsService.deleteFile(name, type).subscribe(delRes => {
// reload mp4s // reload mp4s
this.getAllFiles(); this.getAllFiles();
}); });
@@ -233,7 +233,7 @@ export class RecentVideosComponent implements OnInit {
} }
deleteNormalFile(file, index, blacklistMode = false) { deleteNormalFile(file, index, blacklistMode = false) {
this.postsService.deleteFile(file.uid, file.isAudio, blacklistMode).subscribe(result => { this.postsService.deleteFile(file.uid, file.isAudio ? 'audio' : 'video', blacklistMode).subscribe(result => {
if (result) { if (result) {
this.postsService.openSnackBar('Delete success!', 'OK.'); this.postsService.openSnackBar('Delete success!', 'OK.');
this.files.splice(index, 1); this.files.splice(index, 1);

View File

@@ -0,0 +1,11 @@
<div class="chat-container" #scrollContainer *ngIf="visible_chat">
<div style="width: 250px; text-align: center;"><strong>Twitch Chat</strong></div>
<div style="max-width: 250px" *ngFor="let chat of visible_chat">
{{chat.timestamp_str}} - <strong>{{chat.name}}</strong>: {{chat.message}}
</div>
</div>
<ng-container *ngIf="chat_response_received && !full_chat">
<button (click)="downloadTwitchChat()" class="download-button" mat-raised-button color="accent"><ng-container i18n="Download Twitch Chat button">Download Twitch Chat</ng-container></button>
<mat-spinner *ngIf="downloading_chat" class="downloading-spinner" [diameter]="30"></mat-spinner>
</ng-container>

View File

@@ -0,0 +1,13 @@
.chat-container {
height: 100%;
overflow-y: scroll;
}
.download-button {
margin: 10px;
}
.downloading-spinner {
top: 50%;
left: 80px;
}

View File

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

View File

@@ -0,0 +1,135 @@
import { AfterViewInit, Component, ElementRef, Input, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core';
import { PostsService } from 'app/posts.services';
@Component({
selector: 'app-twitch-chat',
templateUrl: './twitch-chat.component.html',
styleUrls: ['./twitch-chat.component.scss']
})
export class TwitchChatComponent implements OnInit, AfterViewInit {
full_chat = null;
visible_chat = null;
chat_response_received = false;
downloading_chat = false;
current_chat_index = null;
CHAT_CHECK_INTERVAL_MS = 200;
chat_check_interval_obj = null;
scrollContainer = null;
@Input() db_file = null;
@Input() current_timestamp = null;
@ViewChild('scrollContainer') scrollRef: ElementRef;
constructor(private postsService: PostsService) { }
ngOnInit(): void {
this.getFullChat();
}
ngAfterViewInit() {
}
private isUserNearBottom(): boolean {
const threshold = 300;
const position = this.scrollContainer.scrollTop + this.scrollContainer.offsetHeight;
const height = this.scrollContainer.scrollHeight;
return position > height - threshold;
}
scrollToBottom = () => {
this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight;
}
addNewChatMessages() {
if (!this.scrollContainer) {
this.scrollContainer = this.scrollRef.nativeElement;
}
if (this.current_chat_index === null) {
this.current_chat_index = this.getIndexOfNextChat();
}
const latest_chat_timestamp = this.visible_chat.length ? this.visible_chat[this.visible_chat.length - 1]['timestamp'] : 0;
for (let i = this.current_chat_index + 1; i < this.full_chat.length; i++) {
if (this.full_chat[i]['timestamp'] >= latest_chat_timestamp && this.full_chat[i]['timestamp'] <= this.current_timestamp) {
this.visible_chat.push(this.full_chat[i]);
this.current_chat_index = i;
if (this.isUserNearBottom()) {
this.scrollToBottom();
}
} else if (this.full_chat[i]['timestamp'] > this.current_timestamp) {
break;
}
}
}
getIndexOfNextChat() {
const index = binarySearch(this.full_chat, 'timestamp', this.current_timestamp);
return index;
}
getFullChat() {
this.postsService.getFullTwitchChat(this.db_file.id, this.db_file.isAudio ? 'audio' : 'video', null).subscribe(res => {
this.chat_response_received = true;
if (res['chat']) {
this.initializeChatCheck(res['chat']);
}
});
}
renewChat() {
this.visible_chat = [];
this.current_chat_index = this.getIndexOfNextChat();
}
downloadTwitchChat() {
this.downloading_chat = true;
let vodId = this.db_file.url.split('videos/').length > 1 && this.db_file.url.split('videos/')[1];
vodId = vodId.split('?')[0];
if (!vodId) {
this.postsService.openSnackBar('VOD url for this video is not supported. VOD ID must be after "twitch.tv/videos/"');
}
this.postsService.downloadTwitchChat(this.db_file.id, this.db_file.isAudio ? 'audio' : 'video', vodId, null).subscribe(res => {
if (res['chat']) {
this.initializeChatCheck(res['chat']);
} else {
this.downloading_chat = false;
this.postsService.openSnackBar('Download failed.')
}
}, err => {
this.downloading_chat = false;
this.postsService.openSnackBar('Chat could not be downloaded.')
});
}
initializeChatCheck(full_chat) {
this.full_chat = full_chat;
this.visible_chat = [];
this.chat_check_interval_obj = setInterval(() => this.addNewChatMessages(), this.CHAT_CHECK_INTERVAL_MS);
}
}
function binarySearch(arr, key, n) {
let min = 0;
let max = arr.length - 1;
let mid;
while (min <= max) {
// tslint:disable-next-line: no-bitwise
mid = (min + max) >>> 1;
if (arr[mid][key] === n) {
return mid;
} else if (arr[mid][key] < n) {
min = mid + 1;
} else {
max = mid - 1;
}
}
return min;
}

View File

@@ -41,7 +41,7 @@
<div style="padding:5px"> <div style="padding:5px">
<div *ngIf="!loading && file_obj.thumbnailURL" class="img-div"> <div *ngIf="!loading && file_obj.thumbnailURL" class="img-div">
<div style="position: relative"> <div style="position: relative">
<img [ngClass]="{'image-small': card_size === 'small', 'image': card_size === 'medium', 'image-large': card_size === 'large'}" [src]="file_obj.thumbnailBlob ? thumbnailBlobURL : file_obj.thumbnailURL" alt="Thumbnail"> <img [ngClass]="{'image-small': card_size === 'small', 'image': card_size === 'medium', 'image-large': card_size === 'large'}" [src]="file_obj.thumbnailPath ? thumbnailBlobURL : file_obj.thumbnailURL" alt="Thumbnail">
<div class="duration-time"> <div class="duration-time">
{{file_length}} {{file_length}}
</div> </div>

View File

@@ -44,11 +44,13 @@ export class UnifiedFileCardComponent implements OnInit {
@Input() is_playlist = false; @Input() is_playlist = false;
@Input() index: number; @Input() index: number;
@Input() locale = null; @Input() locale = null;
@Input() baseStreamPath = null;
@Input() jwtString = null;
@Output() goToFile = new EventEmitter<any>(); @Output() goToFile = new EventEmitter<any>();
@Output() goToSubscription = new EventEmitter<any>(); @Output() goToSubscription = new EventEmitter<any>();
@Output() deleteFile = new EventEmitter<any>(); @Output() deleteFile = new EventEmitter<any>();
@Output() editPlaylist = new EventEmitter<any>(); @Output() editPlaylist = new EventEmitter<any>();
@ViewChild(MatMenuTrigger) contextMenu: MatMenuTrigger; @ViewChild(MatMenuTrigger) contextMenu: MatMenuTrigger;
contextMenuPosition = { x: '0px', y: '0px' }; contextMenuPosition = { x: '0px', y: '0px' };
@@ -67,11 +69,12 @@ export class UnifiedFileCardComponent implements OnInit {
this.file_length = fancyTimeFormat(this.file_obj.duration); this.file_length = fancyTimeFormat(this.file_obj.duration);
} }
if (this.file_obj && this.file_obj.thumbnailBlob) { if (this.file_obj && this.file_obj.thumbnailPath) {
const mime = getMimeByFilename(this.file_obj.thumbnailPath); this.thumbnailBlobURL = `${this.baseStreamPath}thumbnail/${encodeURIComponent(this.file_obj.thumbnailPath)}${this.jwtString}`;
/*const mime = getMimeByFilename(this.file_obj.thumbnailPath);
const blob = new Blob([new Uint8Array(this.file_obj.thumbnailBlob.data)], {type: mime}); const blob = new Blob([new Uint8Array(this.file_obj.thumbnailBlob.data)], {type: mime});
const bloburl = URL.createObjectURL(blob); const bloburl = URL.createObjectURL(blob);
this.thumbnailBlobURL = this.sanitizer.bypassSecurityTrustUrl(bloburl); this.thumbnailBlobURL = this.sanitizer.bypassSecurityTrustUrl(bloburl);*/
} }
} }

View File

@@ -6,7 +6,7 @@
</mat-dialog-content> </mat-dialog-content>
<mat-dialog-actions> <mat-dialog-actions>
<!-- The mat-dialog-close directive optionally accepts a value as a result for the dialog. --> <!-- The mat-dialog-close directive optionally accepts a value as a result for the dialog. -->
<button color="primary" mat-flat-button type="submit" (click)="confirmClicked()">{{submitText}}</button> <button [color]="warnSubmitColor ? 'warn' : 'primary'" mat-flat-button type="submit" (click)="confirmClicked()">{{submitText}}</button>
<div class="mat-spinner" *ngIf="submitClicked"> <div class="mat-spinner" *ngIf="submitClicked">
<mat-spinner [diameter]="25"></mat-spinner> <mat-spinner [diameter]="25"></mat-spinner>
</div> </div>

View File

@@ -15,12 +15,14 @@ export class ConfirmDialogComponent implements OnInit {
doneEmitter: EventEmitter<any> = null; doneEmitter: EventEmitter<any> = null;
onlyEmitOnDone = false; onlyEmitOnDone = false;
warnSubmitColor = false;
constructor(@Inject(MAT_DIALOG_DATA) public data: any, public dialogRef: MatDialogRef<ConfirmDialogComponent>) { constructor(@Inject(MAT_DIALOG_DATA) public data: any, public dialogRef: MatDialogRef<ConfirmDialogComponent>) {
if (this.data.dialogTitle) { this.dialogTitle = this.data.dialogTitle }; if (this.data.dialogTitle) { this.dialogTitle = this.data.dialogTitle };
if (this.data.dialogText) { this.dialogText = this.data.dialogText }; if (this.data.dialogText) { this.dialogText = this.data.dialogText };
if (this.data.submitText) { this.submitText = this.data.submitText }; if (this.data.submitText) { this.submitText = this.data.submitText };
if (this.data.warnSubmitColor) { this.warnSubmitColor = this.data.warnSubmitColor };
// checks if emitter exists, if so don't autoclose as it should be handled by caller // checks if emitter exists, if so don't autoclose as it should be handled by caller
if (this.data.doneEmitter) { if (this.data.doneEmitter) {

View File

@@ -0,0 +1,60 @@
<h4 mat-dialog-title><ng-container i18n="Editing category dialog title">Editing category</ng-container>&nbsp;{{category['name']}}</h4>
<mat-dialog-content style="max-height: 50vh">
<mat-form-field style="width: 250px; margin-bottom: 5px;">
<input matInput placeholder="Name" i18n-placeholder="Name" [(ngModel)]="category['name']" required>
</mat-form-field>
<mat-divider></mat-divider>
<h6 style="margin-top: 20px;" i18n="Rules">Rules</h6>
<mat-list>
<mat-list-item *ngFor="let rule of category['rules']; let i = index">
<mat-form-field [style.visibility]="i === 0 ? 'hidden' : null" class="operator-select">
<mat-select [disabled]="i === 0" [(ngModel)]="rule['preceding_operator']">
<mat-option value="or">OR</mat-option>
<mat-option value="and">AND</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="property-select">
<mat-select [(ngModel)]="rule['property']">
<mat-option *ngFor="let propertyOption of propertyOptions" [value]="propertyOption.value">{{propertyOption.label}}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="comparator-select">
<mat-select [(ngModel)]="rule['comparator']">
<mat-option *ngFor="let comparatorOption of comparatorOptions" [value]="comparatorOption.value">{{comparatorOption.label}}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="value-input">
<input matInput [(ngModel)]="rule['value']">
</mat-form-field>
<button [disabled]="i === category['rules'].length-1" (click)="swapRules(i, i+1)" mat-icon-button><mat-icon>arrow_downward</mat-icon></button>
<button [disabled]="i === 0" (click)="swapRules(i, i-1)" mat-icon-button><mat-icon>arrow_upward</mat-icon></button>
<button (click)="removeRule(i)" mat-icon-button><mat-icon>cancel</mat-icon></button>
</mat-list-item>
</mat-list>
<button style="margin-bottom: 8px;" mat-icon-button (click)="addNewRule()" matTooltip="Add new rule" i18n-matTooltip="Add new rule tooltip"><mat-icon>add</mat-icon></button>
<mat-divider></mat-divider>
<mat-form-field style="width: 250px; margin-top: 10px;">
<input matInput [(ngModel)]="category['custom_output']" placeholder="Custom file output" i18n-placeholder="Category custom file output placeholder">
<mat-hint>
<a target="_blank" href="https://github.com/ytdl-org/youtube-dl/blob/master/README.md#output-template">
<ng-container i18n="Custom output template documentation link">Documentation</ng-container></a>.
<ng-container i18n="Custom Output input hint">Path is relative to the config download path. Don't include extension.</ng-container>
</mat-hint>
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close><ng-container i18n="Cancel">Cancel</ng-container></button>
<button mat-button [disabled]="categoryChanged()" type="submit" (click)="saveClicked()"><ng-container i18n="Save button">Save</ng-container></button>
<div class="mat-spinner" *ngIf="updating">
<mat-spinner [diameter]="25"></mat-spinner>
</div>
</mat-dialog-actions>

View File

@@ -0,0 +1,16 @@
.operator-select {
width: 55px;
}
.property-select {
margin-left: 10px;
width: 110px;
}
.comparator-select {
margin-left: 10px;
}
.value-input {
margin-left: 10px;
}

View File

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

View File

@@ -0,0 +1,111 @@
import { Component, OnInit, Inject } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { PostsService } from 'app/posts.services';
@Component({
selector: 'app-edit-category-dialog',
templateUrl: './edit-category-dialog.component.html',
styleUrls: ['./edit-category-dialog.component.scss']
})
export class EditCategoryDialogComponent implements OnInit {
updating = false;
original_category = null;
category = null;
propertyOptions = [
{
value: 'fulltitle',
label: 'Title'
},
{
value: 'id',
label: 'ID'
},
{
value: 'webpage_url',
label: 'URL'
},
{
value: 'view_count',
label: 'Views'
},
{
value: 'uploader',
label: 'Uploader'
},
{
value: '_filename',
label: 'File Name'
},
{
value: 'tags',
label: 'Tags'
}
];
comparatorOptions = [
{
value: 'includes',
label: 'includes'
},
{
value: 'not_includes',
label: 'not includes'
},
{
value: 'equals',
label: 'equals'
},
{
value: 'not_equals',
label: 'not equals'
},
];
constructor(@Inject(MAT_DIALOG_DATA) public data: any, private postsService: PostsService) {
if (this.data) {
this.original_category = this.data.category;
this.category = JSON.parse(JSON.stringify(this.original_category));
}
}
ngOnInit(): void {
}
addNewRule() {
this.category['rules'].push({
preceding_operator: 'or',
property: 'fulltitle',
comparator: 'includes',
value: ''
});
}
saveClicked() {
this.updating = true;
this.postsService.updateCategory(this.category).subscribe(res => {
this.updating = false;
this.original_category = JSON.parse(JSON.stringify(this.category));
this.postsService.reloadCategories();
}, err => {
this.updating = false;
console.error(err);
});
}
categoryChanged() {
return JSON.stringify(this.category) === JSON.stringify(this.original_category);
}
swapRules(original_index, new_index) {
[this.category.rules[original_index], this.category.rules[new_index]] = [this.category.rules[new_index],
this.category.rules[original_index]];
}
removeRule(index) {
this.category['rules'].splice(index, 1);
}
}

View File

@@ -24,6 +24,13 @@
<mat-checkbox [disabled]="true" [(ngModel)]="audioOnlyMode"><ng-container i18n="Streaming-only mode">Audio-only mode</ng-container></mat-checkbox> <mat-checkbox [disabled]="true" [(ngModel)]="audioOnlyMode"><ng-container i18n="Streaming-only mode">Audio-only mode</ng-container></mat-checkbox>
</div> </div>
</div> </div>
<div class="col-12 mt-2">
<mat-form-field>
<mat-select placeholder="Max quality" i18n-placeholder="Max quality placeholder" [disabled]="new_sub.type === 'audio'" [(ngModel)]="new_sub.maxQuality">
<mat-option *ngFor="let available_quality of available_qualities" [value]="available_quality['value']">{{available_quality['label']}}</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="col-12"> <div class="col-12">
<div> <div>
<mat-checkbox [disabled]="new_sub.type === 'audio'" [(ngModel)]="new_sub.streamingOnly"><ng-container i18n="Streaming-only mode">Streaming-only mode</ng-container></mat-checkbox> <mat-checkbox [disabled]="new_sub.type === 'audio'" [(ngModel)]="new_sub.streamingOnly"><ng-container i18n="Streaming-only mode">Streaming-only mode</ng-container></mat-checkbox>

View File

@@ -22,6 +22,36 @@ export class EditSubscriptionDialogComponent implements OnInit {
audioOnlyMode = null; audioOnlyMode = null;
download_all = null; download_all = null;
available_qualities = [
{
'label': 'Best',
'value': 'best'
},
{
'label': '4K',
'value': '2160'
},
{
'label': '1440p',
'value': '1440'
},
{
'label': '1080p',
'value': '1080'
},
{
'label': '720p',
'value': '720'
},
{
'label': '480p',
'value': '480'
},
{
'label': '360p',
'value': '360'
}
];
time_units = [ time_units = [
'day', 'day',
@@ -39,16 +69,12 @@ export class EditSubscriptionDialogComponent implements OnInit {
if (this.sub.timerange) { if (this.sub.timerange) {
const timerange_str = this.sub.timerange.split('-')[1]; const timerange_str = this.sub.timerange.split('-')[1];
console.log(timerange_str);
const number = timerange_str.replace(/\D/g,''); const number = timerange_str.replace(/\D/g,'');
let units = timerange_str.replace(/[0-9]/g, ''); let units = timerange_str.replace(/[0-9]/g, '');
console.log(units); if (+number === 1) {
units = units.replace('s', '');
// // remove plural on units }
// if (units[units.length-1] === 's') {
// units = units.substring(0, units.length-1);
// }
this.timerange_amount = parseInt(number); this.timerange_amount = parseInt(number);
this.timerange_unit = units; this.timerange_unit = units;
@@ -71,9 +97,10 @@ export class EditSubscriptionDialogComponent implements OnInit {
} }
saveSubscription() { saveSubscription() {
this.postsService.updateSubscription(this.sub).subscribe(res => { this.postsService.updateSubscription(this.new_sub).subscribe(res => {
this.sub = this.new_sub; this.sub = this.new_sub;
this.new_sub = JSON.parse(JSON.stringify(this.sub)); this.new_sub = JSON.parse(JSON.stringify(this.sub));
this.postsService.reloadSubscriptions();
}) })
} }
@@ -85,12 +112,16 @@ export class EditSubscriptionDialogComponent implements OnInit {
} }
timerangeChanged(value, select_changed) { timerangeChanged(value, select_changed) {
console.log(this.timerange_amount); if (+this.timerange_amount === 1) {
console.log(this.timerange_unit); this.timerange_unit = this.timerange_unit.replace('s', '');
} else {
if (!this.timerange_unit.includes('s')) {
this.timerange_unit += 's';
}
}
if (this.timerange_amount && this.timerange_unit && !this.download_all) { if (this.timerange_amount && this.timerange_unit && !this.download_all) {
this.new_sub.timerange = 'now-' + this.timerange_amount.toString() + this.timerange_unit; this.new_sub.timerange = 'now-' + this.timerange_amount.toString() + this.timerange_unit;
console.log(this.new_sub.timerange);
} else { } else {
this.new_sub.timerange = null; this.new_sub.timerange = null;
} }

View File

@@ -35,6 +35,13 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="col-12 mt-2">
<mat-form-field>
<mat-select placeholder="Max quality" i18n-placeholder="Max quality placeholder" [disabled]="audioOnlyMode" [(ngModel)]="maxQuality">
<mat-option *ngFor="let available_quality of available_qualities" [value]="available_quality['value']">{{available_quality['label']}}</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="col-12"> <div class="col-12">
<div> <div>
<mat-checkbox [(ngModel)]="audioOnlyMode"><ng-container i18n="Streaming-only mode">Audio-only mode</ng-container></mat-checkbox> <mat-checkbox [(ngModel)]="audioOnlyMode"><ng-container i18n="Streaming-only mode">Audio-only mode</ng-container></mat-checkbox>

View File

@@ -17,6 +17,8 @@ export class SubscribeDialogComponent implements OnInit {
url = null; url = null;
name = null; name = null;
maxQuality = 'best';
// state // state
subscribing = false; subscribing = false;
@@ -29,12 +31,43 @@ export class SubscribeDialogComponent implements OnInit {
customFileOutput = ''; customFileOutput = '';
customArgs = ''; customArgs = '';
available_qualities = [
{
'label': 'Best',
'value': 'best'
},
{
'label': '4K',
'value': '2160'
},
{
'label': '1440p',
'value': '1440'
},
{
'label': '1080p',
'value': '1080'
},
{
'label': '720p',
'value': '720'
},
{
'label': '480p',
'value': '480'
},
{
'label': '360p',
'value': '360'
}
];
time_units = [ time_units = [
'day', 'day',
'week', 'week',
'month', 'month',
'year' 'year'
] ];
constructor(private postsService: PostsService, constructor(private postsService: PostsService,
private snackBar: MatSnackBar, private snackBar: MatSnackBar,
@@ -57,7 +90,7 @@ export class SubscribeDialogComponent implements OnInit {
if (!this.download_all) { if (!this.download_all) {
timerange = 'now-' + this.timerange_amount.toString() + this.timerange_unit; timerange = 'now-' + this.timerange_amount.toString() + this.timerange_unit;
} }
this.postsService.createSubscription(this.url, this.name, timerange, this.streamingOnlyMode, this.postsService.createSubscription(this.url, this.name, timerange, this.streamingOnlyMode, this.maxQuality,
this.audioOnlyMode, this.customArgs, this.customFileOutput).subscribe(res => { this.audioOnlyMode, this.customArgs, this.customFileOutput).subscribe(res => {
this.subscribing = false; this.subscribing = false;
if (res['new_sub']) { if (res['new_sub']) {

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, blacklistMode).subscribe(result => { this.postsService.deleteFile(this.uid, this.isAudio ? 'audio' : 'video', 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);

View File

@@ -182,7 +182,7 @@
</ng-template> </ng-template>
<ng-container *ngIf="cachedFileManagerEnabled || fileManagerEnabled"> <ng-container *ngIf="cachedFileManagerEnabled || fileManagerEnabled">
<app-recent-videos></app-recent-videos> <app-recent-videos #recentVideos></app-recent-videos>
<br/> <br/>
<h4 style="text-align: center">Custom playlists</h4> <h4 style="text-align: center">Custom playlists</h4>
<app-custom-playlists></app-custom-playlists> <app-custom-playlists></app-custom-playlists>

View File

@@ -20,6 +20,7 @@ import { CreatePlaylistComponent } from 'app/create-playlist/create-playlist.com
import { Platform } from '@angular/cdk/platform'; import { Platform } from '@angular/cdk/platform';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { ArgModifierDialogComponent } from 'app/dialogs/arg-modifier-dialog/arg-modifier-dialog.component'; import { ArgModifierDialogComponent } from 'app/dialogs/arg-modifier-dialog/arg-modifier-dialog.component';
import { RecentVideosComponent } from 'app/components/recent-videos/recent-videos.component';
export let audioFilesMouseHovering = false; export let audioFilesMouseHovering = false;
export let videoFilesMouseHovering = false; export let videoFilesMouseHovering = false;
@@ -200,6 +201,7 @@ export class MainComponent implements OnInit {
formats_loading = false; formats_loading = false;
@ViewChild('urlinput', { read: ElementRef }) urlInput: ElementRef; @ViewChild('urlinput', { read: ElementRef }) urlInput: ElementRef;
@ViewChild('recentVideos') recentVideos: RecentVideosComponent;
@ViewChildren('audiofilecard') audioFileCards: QueryList<FileCardComponent>; @ViewChildren('audiofilecard') audioFileCards: QueryList<FileCardComponent>;
@ViewChildren('videofilecard') videoFileCards: QueryList<FileCardComponent>; @ViewChildren('videofilecard') videoFileCards: QueryList<FileCardComponent>;
last_valid_url = ''; last_valid_url = '';
@@ -487,6 +489,7 @@ export class MainComponent implements OnInit {
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();
} 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 (forceView === false && this.downloadOnlyMode && !this.iOS) {
@@ -496,6 +499,7 @@ export class MainComponent implements OnInit {
} else { } else {
this.downloadAudioFile(decodeURI(name)); this.downloadAudioFile(decodeURI(name));
} }
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) {
@@ -524,6 +528,7 @@ export class MainComponent implements OnInit {
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();
} 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) { if (forceView === false && this.downloadOnlyMode) {
@@ -533,6 +538,7 @@ export class MainComponent implements OnInit {
} else { } else {
this.downloadVideoFile(decodeURI(name)); this.downloadVideoFile(decodeURI(name));
} }
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) {
@@ -746,7 +752,7 @@ export class MainComponent implements OnInit {
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, true).subscribe(delRes => { this.postsService.deleteFile(name, 'video').subscribe(delRes => {
// reload mp3s // reload mp3s
this.getMp3s(); this.getMp3s();
}); });
@@ -763,7 +769,7 @@ export class MainComponent implements OnInit {
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, false).subscribe(delRes => { this.postsService.deleteFile(name, 'audio').subscribe(delRes => {
// reload mp4s // reload mp4s
this.getMp4s(); this.getMp4s();
}); });
@@ -1161,4 +1167,10 @@ export class MainComponent implements OnInit {
} }
}); });
} }
reloadRecentVideos() {
if (this.recentVideos) {
this.recentVideos.getAllFiles();
}
}
} }

View File

@@ -1,35 +1,46 @@
<div *ngIf="playlist.length > 0 && show_player"> <div style="height: 100%" *ngIf="playlist.length > 0 && show_player">
<div [ngClass]="(type === 'audio') ? null : 'container-video'"> <div style="height: 100%" [ngClass]="(type === 'audio') ? null : 'container-video'">
<div style="max-width: 100%; margin-left: 0px; height: 70vh"> <div style="max-width: 100%; margin-left: 0px; height: 100%">
<div style="height: fit-content" [ngClass]="(type === 'audio') ? 'audio-col' : 'video-col'"> <mat-drawer-container style="height: 100%" class="example-container" autosize>
<vg-player style="height: fit-content; max-height: 75vh" (onPlayerReady)="onPlayerReady($event)" [style.background-color]="(type === 'audio') ? 'transparent' : 'black'"> <div style="height: fit-content" [ngClass]="(type === 'audio') ? 'audio-col' : 'video-col'">
<video [ngClass]="(type === 'audio') ? 'audio-styles' : 'video-styles'" #media class="video-player" [vgMedia]="media" [src]="currentItem.src" id="singleVideo" preload="auto" controls> <vg-player style="height: fit-content; max-height: 75vh" (onPlayerReady)="onPlayerReady($event)" [style.background-color]="(type === 'audio') ? 'transparent' : 'black'">
</video> <video [ngClass]="(type === 'audio') ? 'audio-styles' : 'video-styles'" #media class="video-player" [vgMedia]="media" [src]="currentItem.src" id="singleVideo" preload="auto" controls>
</vg-player> </video>
</div> </vg-player>
<div style="height: fit-content; width: 100%; margin-top: 10px;"> </div>
<mat-button-toggle-group cdkDropList [cdkDropListSortingDisabled]="!id" (cdkDropListDropped)="drop($event)" style="width: 80%; left: 9%" vertical name="videoSelect" aria-label="Video Select" #group="matButtonToggleGroup"> <div style="height: fit-content; width: 100%; margin-top: 10px;">
<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 cdkDropList [cdkDropListSortingDisabled]="!id" (cdkDropListDropped)="drop($event)" style="width: 80%; left: 9%" vertical name="videoSelect" aria-label="Video Select" #group="matButtonToggleGroup">
</mat-button-toggle-group> <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>
</div> </mat-button-toggle-group>
</div> </div>
</div> <mat-drawer #drawer class="example-sidenav" mode="side" position="end" opened="false">
<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"></app-twitch-chat>
</ng-container>
</mat-drawer>
<div *ngIf="db_file && db_file.url.includes('twitch.tv/videos/') && postsService['config']['API']['use_twitch_API']" style="position: absolute; right: 0px">
<button style="right: 0px; top: -46px;" (click)="drawer.toggle()" mat-icon-button><mat-icon>chat</mat-icon></button>
</div>
<div class="update-playlist-button-div" *ngIf="id && playlistChanged()"> <div class="update-playlist-button-div" *ngIf="id && playlistChanged()">
<div class="spinner-div"> <div class="spinner-div">
<mat-spinner *ngIf="playlist_updating" [diameter]="25"></mat-spinner> <mat-spinner *ngIf="playlist_updating" [diameter]="25"></mat-spinner>
</div> </div>
<button color="primary" [disabled]="playlist_updating" (click)="updatePlaylist()" mat-raised-button><ng-container i18n="Playlist save changes button">Save changes</ng-container>&nbsp;<mat-icon>update</mat-icon></button> <button color="primary" [disabled]="playlist_updating" (click)="updatePlaylist()" mat-raised-button><ng-container i18n="Playlist save changes button">Save changes</ng-container>&nbsp;<mat-icon>update</mat-icon></button>
</div> </div>
<div *ngIf="playlist.length > 1"> <div *ngIf="playlist.length > 1">
<button class="save-button" color="primary" (click)="downloadContent()" [disabled]="downloading" mat-fab><mat-icon class="save-icon">save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="50"></mat-spinner></button> <button class="save-button" color="primary" (click)="downloadContent()" [disabled]="downloading" mat-fab><mat-icon class="save-icon">save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="50"></mat-spinner></button>
<button *ngIf="!id" color="accent" class="favorite-button" color="primary" (click)="namePlaylistDialog()" mat-fab><mat-icon class="save-icon">favorite</mat-icon></button> <button *ngIf="!id" color="accent" class="favorite-button" color="primary" (click)="namePlaylistDialog()" mat-fab><mat-icon class="save-icon">favorite</mat-icon></button>
<button *ngIf="!is_shared && id && (!postsService.isLoggedIn || postsService.permissions.includes('sharing'))" class="share-button" color="primary" (click)="openShareDialog()" mat-fab><mat-icon class="save-icon">share</mat-icon></button> <button *ngIf="!is_shared && id && (!postsService.isLoggedIn || postsService.permissions.includes('sharing'))" class="share-button" color="primary" (click)="openShareDialog()" mat-fab><mat-icon class="save-icon">share</mat-icon></button>
</div> </div>
<div *ngIf="playlist.length === 1"> <div *ngIf="playlist.length === 1">
<button class="save-button" color="primary" (click)="downloadFile()" [disabled]="downloading" mat-fab><mat-icon class="save-icon">save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="50"></mat-spinner></button> <button class="save-button" color="primary" (click)="downloadFile()" [disabled]="downloading" mat-fab><mat-icon class="save-icon">save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="50"></mat-spinner></button>
<button *ngIf="!is_shared && uid && uid !== 'false' && type !== 'subscription' && (!postsService.isLoggedIn || postsService.permissions.includes('sharing'))" class="share-button" color="primary" (click)="openShareDialog()" mat-fab><mat-icon class="save-icon">share</mat-icon></button> <button *ngIf="!is_shared && uid && uid !== 'false' && type !== 'subscription' && (!postsService.isLoggedIn || postsService.permissions.includes('sharing'))" class="share-button" color="primary" (click)="openShareDialog()" mat-fab><mat-icon class="save-icon">share</mat-icon></button>
</div>
</mat-drawer-container>
</div>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, HostListener, EventEmitter, OnDestroy, AfterViewInit } from '@angular/core'; import { Component, OnInit, HostListener, EventEmitter, OnDestroy, AfterViewInit, ViewChild } from '@angular/core';
import { VgAPI } from 'ngx-videogular'; import { VgAPI } from 'ngx-videogular';
import { PostsService } from 'app/posts.services'; import { PostsService } from 'app/posts.services';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
@@ -7,6 +7,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { InputDialogComponent } from 'app/input-dialog/input-dialog.component'; 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';
export interface IMedia { export interface IMedia {
title: string; title: string;
@@ -31,6 +32,7 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
currentIndex = 0; currentIndex = 0;
currentItem: IMedia = null; currentItem: IMedia = null;
api: VgAPI; api: VgAPI;
api_ready = false;
// params // params
fileNames: string[]; fileNames: string[];
@@ -65,6 +67,8 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
save_volume_timer = null; save_volume_timer = null;
original_volume = null; original_volume = null;
@ViewChild('twitchchat') twitchChat: TwitchChatComponent;
@HostListener('window:resize', ['$event']) @HostListener('window:resize', ['$event'])
onResize(event) { onResize(event) {
this.innerWidth = window.innerWidth; this.innerWidth = window.innerWidth;
@@ -124,6 +128,8 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
this.getFile(); this.getFile();
} else if (this.id) { } else if (this.id) {
this.getPlaylistFiles(); this.getPlaylistFiles();
} else if (this.subscriptionName) {
this.getSubscription();
} }
if (this.url) { if (this.url) {
@@ -139,7 +145,7 @@ 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.subscriptionName || this.fileNames) { } else if (this.fileNames && !this.subscriptionName) {
this.show_player = true; this.show_player = true;
this.parseFileNames(); this.parseFileNames();
} }
@@ -171,6 +177,25 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
}); });
} }
getSubscription() {
this.postsService.getSubscription(null, this.subscriptionName).subscribe(res => {
const subscription = res['subscription'];
if (this.fileNames) {
subscription.videos.forEach(video => {
if (video['id'] === this.fileNames[0]) {
this.db_file = video;
this.show_player = true;
this.parseFileNames();
}
});
} else {
console.log('no file name specified');
}
}, err => {
this.openSnackBar(`Failed to find subscription ${this.subscriptionName}`, 'Dismiss');
});
}
getPlaylistFiles() { getPlaylistFiles() {
this.postsService.getPlaylist(this.id, null, this.uuid).subscribe(res => { this.postsService.getPlaylist(this.id, null, this.uuid).subscribe(res => {
if (res['playlist']) { if (res['playlist']) {
@@ -202,23 +227,26 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
const fileName = this.fileNames[i]; const fileName = this.fileNames[i];
let baseLocation = null; let baseLocation = null;
let fullLocation = null; let fullLocation = null;
if (!this.subscriptionName) {
baseLocation = this.type + '/';
fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName);
} else {
// default to video but include subscription name param
baseLocation = this.type === 'audio' ? 'audio/' : 'video/';
fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName) + '?subName=' + this.subscriptionName +
'&subPlaylist=' + this.subPlaylist;
}
// adds user token if in multi-user-mode // adds user token if in multi-user-mode
const uuid_str = this.uuid ? `&uuid=${this.uuid}` : ''; const uuid_str = this.uuid ? `&uuid=${this.uuid}` : '';
const uid_str = (this.id || !this.db_file) ? '' : `&uid=${this.db_file.uid}`; const uid_str = (this.id || !this.db_file) ? '' : `&uid=${this.db_file.uid}`;
const type_str = (this.id || !this.db_file || !this.db_file.type) ? '' : `&type=${this.db_file.type}` 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 id_str = this.id ? `&id=${this.id}` : '';
const file_path_str = (!this.db_file) ? '' : `&file_path=${encodeURIComponent(this.db_file.path)}`;
if (!this.subscriptionName) {
baseLocation = 'stream/';
fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName) + `?test=test${type_str}${file_path_str}`;
} else {
// 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 += (this.subscriptionName ? '&' : '&') + `jwt=${this.postsService.token}`;
if (this.is_shared) { fullLocation += `${uuid_str}${uid_str}${type_str}${id_str}`; } if (this.is_shared) { fullLocation += `${uuid_str}${uid_str}${type_str}${id_str}`; }
} else if (this.is_shared) { } else if (this.is_shared) {
fullLocation += (this.subscriptionName ? '&' : '?') + `test=test${uuid_str}${uid_str}${type_str}${id_str}`; fullLocation += (this.subscriptionName ? '&' : '?') + `test=test${uuid_str}${uid_str}${type_str}${id_str}`;
@@ -246,6 +274,13 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
onPlayerReady(api: VgAPI) { onPlayerReady(api: VgAPI) {
this.api = api; this.api = api;
this.api_ready = true;
this.api.subscriptions.seeked.subscribe(data => {
if (this.twitchChat) {
this.twitchChat.renewChat();
}
});
// 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')) {

View File

@@ -12,6 +12,7 @@ import { v4 as uuid } from 'uuid';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import * as Fingerprint2 from 'fingerprintjs2'; import * as Fingerprint2 from 'fingerprintjs2';
import { isoLangs } from './settings/locales_list'; import { isoLangs } from './settings/locales_list';
import { Title } from '@angular/platform-browser';
@Injectable() @Injectable()
export class PostsService implements CanActivate { export class PostsService implements CanActivate {
@@ -53,11 +54,12 @@ export class PostsService implements CanActivate {
// global vars // global vars
config = null; config = null;
subscriptions = null; subscriptions = null;
categories = null;
sidenav = null; sidenav = null;
locale = isoLangs['en']; locale = isoLangs['en'];
constructor(private http: HttpClient, private router: Router, @Inject(DOCUMENT) private document: Document, constructor(private http: HttpClient, private router: Router, @Inject(DOCUMENT) private document: Document,
public snackBar: MatSnackBar) { public snackBar: MatSnackBar, private titleService: Title) {
console.log('PostsService Initialized...'); console.log('PostsService Initialized...');
this.path = this.document.location.origin + '/api/'; this.path = this.document.location.origin + '/api/';
@@ -87,6 +89,7 @@ export class PostsService implements CanActivate {
const result = !this.debugMode ? res['config_file'] : res; const result = !this.debugMode ? res['config_file'] : res;
if (result) { if (result) {
this.config = result['YoutubeDLMaterial']; this.config = result['YoutubeDLMaterial'];
this.titleService.setTitle(this.config['Extra']['title_top']);
if (this.config['Advanced']['multi_user_mode']) { if (this.config['Advanced']['multi_user_mode']) {
this.checkAdminCreationStatus(); this.checkAdminCreationStatus();
// login stuff // login stuff
@@ -211,12 +214,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, isAudio: boolean, blacklistMode = false) { deleteFile(uid: string, type: string, blacklistMode = false) {
if (isAudio) { return this.http.post(this.path + 'deleteFile', {uid: uid, type: type, blacklistMode: blacklistMode}, this.httpOptions);
return this.http.post(this.path + 'deleteMp3', {uid: uid, blacklistMode: blacklistMode}, this.httpOptions);
} else {
return this.http.post(this.path + 'deleteMp4', {uid: uid, blacklistMode: blacklistMode}, this.httpOptions);
}
} }
getMp3s() { getMp3s() {
@@ -235,6 +234,14 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'getAllFiles', {}, this.httpOptions); return this.http.post(this.path + 'getAllFiles', {}, this.httpOptions);
} }
getFullTwitchChat(id, type, uuid = null) {
return this.http.post(this.path + 'getFullTwitchChat', {id: id, type: type, uuid: uuid}, this.httpOptions);
}
downloadTwitchChat(id, type, vodId, uuid = null) {
return this.http.post(this.path + 'downloadTwitchChatByVODID', {id: id, type: type, vodId: vodId, uuid: uuid}, this.httpOptions);
}
downloadFileFromServer(fileName, type, outputName = null, fullPathProvided = null, subscriptionName = null, subPlaylist = null, downloadFileFromServer(fileName, type, outputName = null, fullPathProvided = null, subscriptionName = null, subPlaylist = null,
uid = null, uuid = null, id = null) { uid = null, uuid = null, id = null) {
return this.http.post(this.path + 'downloadFile', {fileNames: fileName, return this.http.post(this.path + 'downloadFile', {fileNames: fileName,
@@ -310,9 +317,39 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'deletePlaylist', {playlistID: playlistID, type: type}, this.httpOptions); return this.http.post(this.path + 'deletePlaylist', {playlistID: playlistID, type: type}, this.httpOptions);
} }
createSubscription(url, name, timerange = null, streamingOnly = false, audioOnly = false, customArgs = null, customFileOutput = null) { // categories
return this.http.post(this.path + 'subscribe', {url: url, name: name, timerange: timerange, streamingOnly: streamingOnly,
audioOnly: audioOnly, customArgs: customArgs, customFileOutput: customFileOutput}, this.httpOptions); getAllCategories() {
return this.http.post(this.path + 'getAllCategories', {}, this.httpOptions);
}
createCategory(name) {
return this.http.post(this.path + 'createCategory', {name: name}, this.httpOptions);
}
deleteCategory(category_uid) {
return this.http.post(this.path + 'deleteCategory', {category_uid: category_uid}, this.httpOptions);
}
updateCategory(category) {
return this.http.post(this.path + 'updateCategory', {category: category}, this.httpOptions);
}
updateCategories(categories) {
return this.http.post(this.path + 'updateCategories', {categories: categories}, this.httpOptions);
}
reloadCategories() {
this.getAllCategories().subscribe(res => {
this.categories = res['categories'];
});
}
createSubscription(url, name, timerange = null, streamingOnly = false, maxQuality = 'best', audioOnly = false,
customArgs = null, customFileOutput = null) {
return this.http.post(this.path + 'subscribe', {url: url, name: name, timerange: timerange, maxQuality: maxQuality,
streamingOnly: streamingOnly, audioOnly: audioOnly, customArgs: customArgs,
customFileOutput: customFileOutput}, this.httpOptions);
} }
updateSubscription(subscription) { updateSubscription(subscription) {
@@ -328,8 +365,8 @@ export class PostsService implements CanActivate {
file_uid: file_uid}, this.httpOptions) file_uid: file_uid}, this.httpOptions)
} }
getSubscription(id) { getSubscription(id, name = null) {
return this.http.post(this.path + 'getSubscription', {id: id}, this.httpOptions); return this.http.post(this.path + 'getSubscription', {id: id, name: name}, this.httpOptions);
} }
getAllSubscriptions() { getAllSubscriptions() {

View File

@@ -116,14 +116,47 @@
</div> </div>
<div class="col-12 mt-4"> <div class="col-12 mt-4">
<mat-form-field class="text-field" color="accent">
<input matInput [(ngModel)]="new_config['Downloader']['default_file_output']" matInput placeholder="Default file output" i18n-placeholder="Default file output placeholder">
<mat-hint><a target="_blank" href="https://github.com/ytdl-org/youtube-dl/blob/master/README.md#output-template">
<ng-container i18n="Youtube-dl output template documentation link">Documentation</ng-container></a>.
<ng-container i18n="Custom Output input hint">Path is relative to the above download paths. Don't include extension.</ng-container>
</mat-hint>
</mat-form-field>
</div>
<div class="col-12 mt-4 mb-5">
<mat-form-field class="text-field" style="margin-right: 12px;" color="accent"> <mat-form-field class="text-field" style="margin-right: 12px;" color="accent">
<textarea matInput [(ngModel)]="new_config['Downloader']['custom_args']" placeholder="Custom args" i18n-placeholder="Custom args input placeholder"></textarea> <textarea matInput [(ngModel)]="new_config['Downloader']['custom_args']" placeholder="Global custom args" i18n-placeholder="Custom args input placeholder"></textarea>
<mat-hint><ng-container i18n="Custom args setting input hint">Global custom args for downloads on the home page. Args are delimited using two commas like so: ,,</ng-container></mat-hint> <mat-hint><ng-container i18n="Custom args setting input hint">Global custom args for downloads on the home page. Args are delimited using two commas like so: ,,</ng-container></mat-hint>
<button class="args-edit-button" (click)="openArgsModifierDialog()" mat-icon-button><mat-icon>edit</mat-icon></button> <button class="args-edit-button" (click)="openArgsModifierDialog()" mat-icon-button><mat-icon>edit</mat-icon></button>
</mat-form-field> </mat-form-field>
</div> </div>
</div>
<div class="col-12 mt-5"> </div>
<mat-divider></mat-divider>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12 mt-3 mb-2">
<h6 i18n="Categories">Categories</h6>
<div cdkDropList class="category-list" (cdkDropListDropped)="dropCategory($event)">
<div class="category-box" *ngFor="let category of postsService.categories" cdkDrag>
<div class="category-custom-placeholder" *cdkDragPlaceholder></div>
{{category['name']}}
<span style="float: right">
<button mat-icon-button (click)="openEditCategoryDialog(category)"><mat-icon>edit</mat-icon></button>
<button mat-icon-button (click)="deleteCategory(category)"><mat-icon>cancel</mat-icon></button>
</span>
</div>
</div>
<button style="margin-top: 10px;" mat-mini-fab (click)="openAddCategoryDialog()"><mat-icon>add</mat-icon></button>
</div>
</div>
</div>
<mat-divider></mat-divider>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12 mt-3">
<mat-checkbox color="accent" [(ngModel)]="new_config['Downloader']['use_youtubedl_archive']"><ng-container i18n="Use youtubedl archive setting">Use youtube-dl archive</ng-container></mat-checkbox> <mat-checkbox color="accent" [(ngModel)]="new_config['Downloader']['use_youtubedl_archive']"><ng-container i18n="Use youtubedl archive setting">Use youtube-dl archive</ng-container></mat-checkbox>
</div> </div>
@@ -195,12 +228,24 @@
<div class="col-12 mt-3"> <div class="col-12 mt-3">
<mat-checkbox color="accent" [(ngModel)]="new_config['API']['use_youtube_API']"><ng-container i18n="Use YouTube API setting">Use YouTube API</ng-container></mat-checkbox> <mat-checkbox color="accent" [(ngModel)]="new_config['API']['use_youtube_API']"><ng-container i18n="Use YouTube API setting">Use YouTube API</ng-container></mat-checkbox>
</div> </div>
<div class="col-12 mb-3"> <div class="col-12 mb-2">
<mat-form-field class="text-field" color="accent"> <mat-form-field class="text-field" color="accent">
<input [disabled]="!new_config['API']['use_youtube_API']" [(ngModel)]="new_config['API']['youtube_API_key']" matInput placeholder="Youtube API Key" i18n-placeholder="Youtube API Key setting placeholder" required> <input [disabled]="!new_config['API']['use_youtube_API']" [(ngModel)]="new_config['API']['youtube_API_key']" matInput placeholder="Youtube API Key" i18n-placeholder="Youtube API Key setting placeholder" required>
<mat-hint><a target="_blank" href="https://developers.google.com/youtube/v3/getting-started"><ng-container i18n="Youtube API Key setting hint">Generating a key is easy!</ng-container></a></mat-hint> <mat-hint><a target="_blank" href="https://developers.google.com/youtube/v3/getting-started"><ng-container i18n="Youtube API Key setting hint">Generating a key is easy!</ng-container></a></mat-hint>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="col-12 mt-3">
<mat-checkbox color="accent" [(ngModel)]="new_config['API']['use_twitch_API']"><ng-container i18n="Use Twitch API setting">Use Twitch API</ng-container></mat-checkbox>
</div>
<div *ngIf="new_config['API']['use_twitch_API']" class="col-12 mt-1">
<mat-checkbox color="accent" [(ngModel)]="new_config['API']['twitch_auto_download_chat']"><ng-container i18n="Auto download Twitch Chat setting">Auto-download Twitch Chat</ng-container></mat-checkbox>
</div>
<div class="col-12 mb-5">
<mat-form-field class="text-field" color="accent">
<input [disabled]="!new_config['API']['use_twitch_API']" [(ngModel)]="new_config['API']['twitch_API_key']" matInput placeholder="Twitch API Key" i18n-placeholder="Twitch API Key setting placeholder" required>
<mat-hint><ng-container i18n="Twitch API Key setting hint AKA preamble">Also known as a Client ID.</ng-container>&nbsp;<a target="_blank" href="https://dev.twitch.tv/docs/api/"><ng-container i18n="Twitch API Key setting hint">Generating a key is easy!</ng-container></a></mat-hint>
</mat-form-field>
</div>
</div> </div>
</div> </div>
<mat-divider></mat-divider> <mat-divider></mat-divider>
@@ -235,11 +280,20 @@
<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"> <div class="col-12 mt-3">
<mat-form-field>
<mat-label><ng-container i18n="Default downloader select label">Select a downloader</ng-container></mat-label>
<mat-select color="accent" [(ngModel)]="new_config['Advanced']['default_downloader']">
<mat-option value="youtube-dlc">youtube-dlc</mat-option>
<mat-option value="youtube-dl">youtube-dl</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="col-12 mt-1">
<mat-checkbox color="accent" [(ngModel)]="new_config['Advanced']['use_default_downloading_agent']"><ng-container i18n="Use default downloading agent setting">Use default downloading agent</ng-container></mat-checkbox> <mat-checkbox color="accent" [(ngModel)]="new_config['Advanced']['use_default_downloading_agent']"><ng-container i18n="Use default downloading agent setting">Use default downloading agent</ng-container></mat-checkbox>
</div> </div>
<div class="col-12"> <div class="col-12">
<mat-form-field> <mat-form-field>
<mat-label><ng-container i18n="Custom downloader select label">Select a downloader</ng-container></mat-label> <mat-label><ng-container i18n="Custom downloader select label">Select a download agent</ng-container></mat-label>
<mat-select [disabled]="new_config['Advanced']['use_default_downloading_agent']" color="accent" [(ngModel)]="new_config['Advanced']['custom_downloading_agent']"> <mat-select [disabled]="new_config['Advanced']['use_default_downloading_agent']" color="accent" [(ngModel)]="new_config['Advanced']['custom_downloading_agent']">
<mat-option value="aria2c">aria2c</mat-option> <mat-option value="aria2c">aria2c</mat-option>
<mat-option value="avconv">avconv</mat-option> <mat-option value="avconv">avconv</mat-option>
@@ -251,7 +305,7 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="col-12 mt-2 mb-1"> <div class="col-12 mt-2">
<mat-form-field> <mat-form-field>
<mat-label><ng-container i18n="Log Level label">Log Level</ng-container></mat-label> <mat-label><ng-container i18n="Log Level label">Log Level</ng-container></mat-label>
<mat-select color="accent" [(ngModel)]="new_config['Advanced']['logger_level']"> <mat-select color="accent" [(ngModel)]="new_config['Advanced']['logger_level']">
@@ -263,7 +317,7 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="col-12 mt-2 mb-1"> <div class="col-12 mb-1">
<mat-form-field> <mat-form-field>
<mat-label><ng-container i18n="Login expiration select label">Login expiration</ng-container></mat-label> <mat-label><ng-container i18n="Login expiration select label">Login expiration</ng-container></mat-label>
<mat-select color="accent" [(ngModel)]="new_config['Advanced']['jwt_expiration']"> <mat-select color="accent" [(ngModel)]="new_config['Advanced']['jwt_expiration']">

View File

@@ -30,4 +30,55 @@
margin-left: 15px; margin-left: 15px;
margin-bottom: 12px; margin-bottom: 12px;
bottom: 4px; bottom: 4px;
}
.category-list {
width: 500px;
max-width: 100%;
border: solid 1px #ccc;
min-height: 60px;
display: block;
// background: white;
border-radius: 4px;
overflow: hidden;
}
.category-box {
padding: 20px 10px;
border-bottom: solid 1px #ccc;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
cursor: move;
// background: white;
font-size: 14px;
}
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 4px;
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
0 8px 10px 1px rgba(0, 0, 0, 0.14),
0 3px 14px 2px rgba(0, 0, 0, 0.12);
}
.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.category-box:last-child {
border: none;
}
.category-list.cdk-drop-list-dragging .category-box:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.category-custom-placeholder {
background: #ccc;
border: dotted 3px #999;
min-height: 60px;
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
} }

View File

@@ -9,6 +9,9 @@ import { CURRENT_VERSION } from 'app/consts';
import { MatCheckboxChange } from '@angular/material/checkbox'; import { MatCheckboxChange } from '@angular/material/checkbox';
import { CookiesUploaderDialogComponent } from 'app/dialogs/cookies-uploader-dialog/cookies-uploader-dialog.component'; import { CookiesUploaderDialogComponent } from 'app/dialogs/cookies-uploader-dialog/cookies-uploader-dialog.component';
import { ConfirmDialogComponent } from 'app/dialogs/confirm-dialog/confirm-dialog.component'; import { ConfirmDialogComponent } from 'app/dialogs/confirm-dialog/confirm-dialog.component';
import { moveItemInArray, CdkDragDrop } from '@angular/cdk/drag-drop';
import { InputDialogComponent } from 'app/input-dialog/input-dialog.component';
import { EditCategoryDialogComponent } from 'app/dialogs/edit-category-dialog/edit-category-dialog.component';
@Component({ @Component({
selector: 'app-settings', selector: 'app-settings',
@@ -77,6 +80,74 @@ export class SettingsComponent implements OnInit {
}) })
} }
dropCategory(event: CdkDragDrop<string[]>) {
moveItemInArray(this.postsService.categories, event.previousIndex, event.currentIndex);
this.postsService.updateCategories(this.postsService.categories).subscribe(res => {
}, err => {
this.postsService.openSnackBar('Failed to update categories!');
});
}
openAddCategoryDialog() {
const done = new EventEmitter<any>();
const dialogRef = this.dialog.open(InputDialogComponent, {
width: '300px',
data: {
inputTitle: 'Name the category',
inputPlaceholder: 'Name',
submitText: 'Add',
doneEmitter: done
}
});
done.subscribe(name => {
// Eventually do additional checks on name
if (name) {
this.postsService.createCategory(name).subscribe(res => {
if (res['success']) {
this.postsService.reloadCategories();
dialogRef.close();
const new_category = res['new_category'];
this.openEditCategoryDialog(new_category);
}
});
}
});
}
deleteCategory(category) {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
dialogTitle: 'Delete category',
dialogText: `Would you like to delete ${category['name']}?`,
submitText: 'Delete',
warnSubmitColor: true
}
});
dialogRef.afterClosed().subscribe(confirmed => {
if (confirmed) {
this.postsService.deleteCategory(category['uid']).subscribe(res => {
if (res['success']) {
this.postsService.openSnackBar(`Successfully deleted ${category['name']}!`);
this.postsService.reloadCategories();
}
}, err => {
this.postsService.openSnackBar(`Failed to delete ${category['name']}!`);
});
}
});
}
openEditCategoryDialog(category) {
this.dialog.open(EditCategoryDialogComponent, {
data: {
category: category
}
});
}
generateAPIKey() { generateAPIKey() {
this.postsService.generateNewAPIKey().subscribe(res => { this.postsService.generateNewAPIKey().subscribe(res => {
if (res['new_api_key']) { if (res['new_api_key']) {
@@ -162,7 +233,8 @@ export class SettingsComponent implements OnInit {
dialogTitle: 'Kill downloads', dialogTitle: 'Kill downloads',
dialogText: 'Are you sure you want to kill all downloads? Any subscription and non-subscription downloads will end immediately, though this operation may take a minute or so to complete.', dialogText: 'Are you sure you want to kill all downloads? Any subscription and non-subscription downloads will end immediately, though this operation may take a minute or so to complete.',
submitText: 'Kill all downloads', submitText: 'Kill all downloads',
doneEmitter: done doneEmitter: done,
warnSubmitColor: true
} }
}); });
done.subscribe(confirmed => { done.subscribe(confirmed => {

View File

@@ -164,7 +164,7 @@ export class SubscriptionComponent implements OnInit {
editSubscription() { editSubscription() {
this.dialog.open(EditSubscriptionDialogComponent, { this.dialog.open(EditSubscriptionDialogComponent, {
data: { data: {
sub: this.subscription sub: this.postsService.getSubscriptionByID(this.subscription.id)
} }
}); });
} }

View File

@@ -7,9 +7,11 @@
"Downloader": { "Downloader": {
"path-audio": "audio/", "path-audio": "audio/",
"path-video": "video/", "path-video": "video/",
"use_youtubedl_archive": true, "use_youtubedl_archive": false,
"custom_args": "", "custom_args": "",
"safe_download_override": false "safe_download_override": false,
"include_thumbnail": false,
"include_metadata": true
}, },
"Extra": { "Extra": {
"title_top": "YoutubeDL-Material", "title_top": "YoutubeDL-Material",
@@ -33,12 +35,20 @@
"Subscriptions": { "Subscriptions": {
"allow_subscriptions": true, "allow_subscriptions": true,
"subscriptions_base_path": "subscriptions/", "subscriptions_base_path": "subscriptions/",
"subscriptions_check_interval": "30", "subscriptions_check_interval": "300",
"subscriptions_use_youtubedl_archive": true "subscriptions_use_youtubedl_archive": true
}, },
"Users": { "Users": {
"base_path": "users/", "base_path": "users/",
"allow_registration": true "allow_registration": true,
"auth_method": "internal",
"ldap_config": {
"url": "ldap://localhost:389",
"bindDN": "cn=root",
"bindCredentials": "secret",
"searchBase": "ou=passport-ldapauth",
"searchFilter": "(uid={{username}})"
}
}, },
"Advanced": { "Advanced": {
"use_default_downloading_agent": true, "use_default_downloading_agent": true,
@@ -47,7 +57,7 @@
"allow_advanced_download": true, "allow_advanced_download": true,
"jwt_expiration": 86400, "jwt_expiration": 86400,
"logger_level": "debug", "logger_level": "debug",
"use_cookies": true "use_cookies": false
} }
} }
} }