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
[![](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.
@@ -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.
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:
* AtomicParsley (for embedding thumbnails, package name `atomicparsley`)
### 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.
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.
4. Make sure you can connect to the specified URL + port, and if so, you are done!
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 + *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
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:
UID: YOUR_UID
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*
Official translators:
* Spanish - tzahi12345
* German - UnlimitedCookies
* Chinese - TyRoyal

View File

@@ -26,6 +26,8 @@ const shortid = require('shortid')
const url_api = require('url');
var config_api = require('./config.js');
var subscriptions_api = require('./subscriptions')
var categories_api = require('./categories');
var twitch_api = require('./twitch');
const CONSTS = require('./consts')
const { spawn } = require('child_process')
const read_last_lines = require('read-last-lines');
@@ -36,7 +38,8 @@ const is_windows = process.platform === 'win32';
var app = express();
// 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 db = low(adapter)
@@ -79,8 +82,7 @@ config_api.initialize(logger);
auth_api.initialize(users_db, logger);
db_api.initialize(db, users_db, logger);
subscriptions_api.initialize(db, users_db, logger, db_api);
// var GithubContent = require('github-content');
categories_api.initialize(db, users_db, logger, db_api);
// Set some defaults
db.defaults(
@@ -155,8 +157,8 @@ if (just_restarted) {
fs.unlinkSync('restart.json');
}
// updates & starts youtubedl
startYoutubeDL();
// updates & starts youtubedl (commented out b/c of repo takedown)
// startYoutubeDL();
var validDownloadingAgents = [
'aria2c',
@@ -173,7 +175,6 @@ const subscription_timeouts = {};
// don't overwrite config if it already happened.. NOT
// let alreadyWritten = db.get('configWriteFlag').value();
let writeConfigMode = process.env.write_ytdl_config;
var config = null;
// checks if config exists, if not, a config is auto generated
config_api.configExistsCheck();
@@ -559,6 +560,9 @@ async function loadConfig() {
// creates archive path if missing
await fs.ensureDir(archivePath);
// now this is done here due to youtube-dl's repo takedown
await startYoutubeDL();
// get subscriptions
if (allowSubscriptions) {
// 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 ext = is_audio ? '.mp3' : '.mp4';
var fileFolderPath = type === 'audio' ? audioFolderPath : videoFolderPath;
let category = null;
// prepend with user if needed
let multiUserMode = null;
@@ -1093,7 +1098,7 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) {
}
options.downloading_method = 'exec';
const downloadConfig = await generateArgs(url, type, options);
let downloadConfig = await generateArgs(url, type, options);
// adds download to download helper
const download_uid = uuid();
@@ -1115,11 +1120,22 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) {
updateDownloads();
// get video info prior to download
const info = await getVideoInfoByURL(url, downloadConfig, download);
let info = await getVideoInfoByURL(url, downloadConfig, download);
if (!info) {
resolve(false);
return;
} 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
download['_filename'] = info['_filename'];
download['filesize'] = utils.getExpectedFileSize(info);
@@ -1161,7 +1177,7 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) {
} catch(e) {
output_json = null;
}
var modified_file_name = output_json ? output_json['title'] : null;
if (!output_json) {
continue;
}
@@ -1172,6 +1188,13 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) {
var full_file_path = filepath_no_extension + ext;
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
if (!fs.existsSync(output_json['_filename'] && fs.existsSync(output_json['_filename'] + '.webm'))) {
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']);
}
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
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);
}
@@ -1362,7 +1388,7 @@ async function downloadFileByURL_normal(url, type, options, sessionID = null) {
}
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');
let useCookies = config_api.getConfigItem('ytdl_use_cookies');
var is_audio = type === 'audio';
@@ -1406,7 +1432,8 @@ async function generateArgs(url, type, options) {
}
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 {
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 resulting_file_size = download['filesize'];
if (!resulting_file_size) return;
glob(`${filename}*`, (err, files) => {
let sum_size = 0;
files.forEach(file => {
@@ -1598,12 +1627,16 @@ function checkDownloadPercent(download) {
async function startYoutubeDL() {
// auto update youtube-dl
if (!debugMode) await autoUpdateYoutubeDL();
await autoUpdateYoutubeDL();
}
// auto updates the underlying youtube-dl binary, not YoutubeDL-Material
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
let current_app_details_path = 'node_modules/youtube-dl/bin/details';
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
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'})
.then(async res => res.json())
.then(async (json) => {
// check if the versions are different
if (!json || !json[0]) {
logger.error(`Failed to check ${default_downloader} version for an update.`)
resolve(false);
return false;
}
const latest_update_version = json[0]['name'];
if (current_version !== latest_update_version) {
let binary_path = 'node_modules/youtube-dl/bin';
// 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 {
await checkExistsWithTimeout(stored_binary_path, 10000);
} 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 (err) {
logger.error(err);
resolve(false);
}
logger.info(`Binary successfully updated: ${current_version} -> ${latest_update_version}`);
resolve(true);
});
if (using_youtube_dlc) await downloadLatestYoutubeDLCBinary(latest_update_version);
else await downloadLatestYoutubeDLBinary(current_version, latest_update_version);
resolve(true);
} else {
resolve(false);
}
})
.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)
});
});
}
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) {
return new Promise(function (resolve, reject) {
@@ -1700,6 +1768,42 @@ function removeFileExtension(filename) {
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) {
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization");
res.header("Access-Control-Allow-Origin", getOrigin());
@@ -1715,13 +1819,9 @@ app.use(function(req, res, next) {
next();
} else if (req.query.apiKey === admin_token) {
next();
} else if (req.query.apiKey && config_api.getConfigItem('ytdl_use_api_key')) {
if (req.query.apiKey === config_api.getConfigItem('ytdl_api_key')) {
next();
} else {
res.status(401).send('Invalid API key');
}
} else if (req.path.includes('/api/video/') || req.path.includes('/api/audio/')) {
} else if (req.query.apiKey && config_api.getConfigItem('ytdl_use_api_key') && req.query.apiKey === config_api.getConfigItem('ytdl_api_key')) {
next();
} else if (req.path.includes('/api/stream/') || req.path.includes('/api/thumbnail/')) {
next();
} else {
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 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') ||
req.path.includes('/api/audio') ||
req.path.includes('/api/video') ||
req.path.includes('/api/stream') ||
req.path.includes('/api/downloadFile'))) {
// check if shared video
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));
// add thumbnails if present
await addThumbnails(mp3s);
if (config_api.getConfigItem('ytdl_include_thumbnail')) {
// add thumbnails if present
// await addThumbnails(mp3s);
}
res.send({
mp3s: mp3s,
@@ -1899,8 +2001,10 @@ app.get('/api/getMp4s', optionalJwt, async function(req, res) {
mp4s = JSON.parse(JSON.stringify(mp4s));
// add thumbnails if present
await addThumbnails(mp4s);
if (config_api.getConfigItem('ytdl_include_thumbnail')) {
// add thumbnails if present
// await addThumbnails(mp4s);
}
res.send({
mp4s: mp4s,
@@ -1988,8 +2092,10 @@ app.post('/api/getAllFiles', optionalJwt, async function (req, res) {
files = JSON.parse(JSON.stringify(files));
// add thumbnails if present
await addThumbnails(files);
if (config_api.getConfigItem('ytdl_include_thumbnail')) {
// add thumbnails if present
// await addThumbnails(files);
}
res.send({
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
app.post('/api/enableSharing', optionalJwt, function(req, res) {
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) => {
let name = req.body.name;
let url = req.body.url;
let maxQuality = req.body.maxQuality;
let timerange = req.body.timerange;
let streamingOnly = req.body.streamingOnly;
let audioOnly = req.body.audioOnly;
@@ -2096,6 +2299,7 @@ app.post('/api/subscribe', optionalJwt, async (req, res) => {
const new_sub = {
name: name,
url: url,
maxQuality: maxQuality,
id: uuid(),
streamingOnly: streamingOnly,
user_uid: user_uid,
@@ -2168,10 +2372,17 @@ app.post('/api/deleteSubscriptionFile', optionalJwt, async (req, res) => {
app.post('/api/getSubscription', optionalJwt, async (req, res) => {
let subID = req.body.id;
let subName = req.body.name; // if included, subID is optional
let user_uid = req.isAuthenticated() ? req.user.uid : null;
// 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) {
// failed to get subscription from db, send 400 error
@@ -2401,56 +2612,25 @@ app.post('/api/deletePlaylist', optionalJwt, async (req, res) => {
})
});
// deletes mp3 file
app.post('/api/deleteMp3', optionalJwt, async (req, res) => {
// var name = req.body.name;
// deletes non-subscription files
app.post('/api/deleteFile', optionalJwt, async (req, res) => {
var uid = req.body.uid;
var type = req.body.type;
var blacklistMode = req.body.blacklistMode;
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);
return;
}
var audio_obj = db.get('files.audio').find({uid: uid}).value();
var name = audio_obj.id;
var fullpath = audioFolderPath + name + ".mp3";
var file_obj = db.get(`files.${type}`).find({uid: uid}).value();
var name = file_obj.id;
var fullpath = file_obj ? file_obj.path : null;
var wasDeleted = false;
if (await fs.pathExists(fullpath))
{
deleteAudioFile(name, null, 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);
wasDeleted = type === 'audio' ? await deleteAudioFile(name, path.basename(fullpath), blacklistMode) : await deleteVideoFile(name, path.basename(fullpath), blacklistMode);
db.get('files.video').remove({uid: uid}).write();
// wasDeleted = true;
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) => {
let sub = req.body.sub;
let archive_dir = sub.archive;
@@ -2595,25 +2764,33 @@ app.post('/api/generateNewAPIKey', function (req, res) {
// 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;
let optionalParams = url_api.parse(req.url,true).query;
let id = decodeURIComponent(req.params.id);
let file_path = videoFolderPath + id + '.mp4';
if (req.isAuthenticated() || req.can_watch) {
let file_path = req.query.file_path ? decodeURIComponent(req.query.file_path) : null;
if (!file_path && (req.isAuthenticated() || req.can_watch)) {
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
if (optionalParams['subName']) {
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 {
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');
const isPlaylist = optionalParams['subPlaylist'];
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 fileSize = stat.size
const range = req.headers.range
@@ -2636,76 +2813,25 @@ app.get('/api/video/:id', optionalJwt, function(req , res){
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': 'video/mp4',
'Content-Type': mimetype,
}
res.writeHead(206, head);
file.pipe(res);
} else {
head = {
'Content-Length': fileSize,
'Content-Type': 'video/mp4',
'Content-Type': mimetype,
}
res.writeHead(200, head)
fs.createReadStream(file_path).pipe(res)
}
});
app.get('/api/audio/:id', optionalJwt, function(req , res){
var head;
let id = decodeURIComponent(req.params.id);
let file_path = "audio/" + id + '.mp3';
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)
}
});
app.get('/api/thumbnail/:path', optionalJwt, async (req, res) => {
let file_path = decodeURIComponent(req.params.path);
if (fs.existsSync(file_path)) path.isAbsolute(file_path) ? res.sendFile(file_path) : res.sendFile(path.join(__dirname, file_path));
else res.sendStatus(404);
});
// Downloads management

View File

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

View File

@@ -18,6 +18,10 @@ let CONFIG_ITEMS = {
'key': 'ytdl_video_folder_path',
'path': 'YoutubeDLMaterial.Downloader.path-video'
},
'ytdl_default_file_output': {
'key': 'ytdl_default_file_output',
'path': 'YoutubeDLMaterial.Downloader.default_file_output'
},
'ytdl_use_youtubedl_archive': {
'key': 'ytdl_use_youtubedl_archive',
'path': 'YoutubeDLMaterial.Downloader.use_youtubedl_archive'
@@ -82,6 +86,18 @@ let CONFIG_ITEMS = {
'key': 'ytdl_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
'ytdl_default_theme': {
@@ -130,6 +146,10 @@ let CONFIG_ITEMS = {
},
// Advanced
'ytdl_default_downloader': {
'key': 'ytdl_default_downloader',
'path': 'YoutubeDLMaterial.Advanced.default_downloader'
},
'ytdl_use_default_downloading_agent': {
'key': 'ytdl_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);
}
function registerFileDB(file_path, type, multiUserMode = null, sub = null) {
function registerFileDB(file_path, type, multiUserMode = null, sub = null, customPath = null) {
let db_path = null;
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) {
logger.error(`Could not find associated JSON file for ${type} file ${file_id}`);
return false;
@@ -27,7 +27,7 @@ function registerFileDB(file_path, type, multiUserMode = null, sub = null) {
utils.fixVideoMetadataPerms(file_id, type, multiUserMode && multiUserMode.file_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 (multiUserMode) {

View File

@@ -114,7 +114,11 @@ async function getSubscriptionInfo(sub, user_uid = null) {
continue;
}
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 (sub.name) {
if (user_uid)
@@ -296,7 +300,8 @@ async function getVideosForSub(sub, user_uid = null) {
qualityPath.push('-x');
qualityPath.push('--audio-format', 'mp3');
} 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)
@@ -351,7 +356,7 @@ async function getVideosForSub(sub, user_uid = null) {
youtubedl.exec(sub.url, downloadConfig, {}, function(err, output) {
logger.verbose('Subscription: finished check for ' + sub.name);
if (err && !output) {
logger.error(err.stderr);
logger.error(err.stderr ? err.stderr : err.message);
if (err.stderr.includes('This video is unavailable')) {
logger.info('An error was encountered with at least one video, backup method will be used.')
try {
@@ -430,6 +435,13 @@ function getSubscription(subID, user_uid = null) {
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) {
if (user_uid) {
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 = {
getSubscription : getSubscription,
getSubscriptionByName : getSubscriptionByName,
getAllSubscriptions : getAllSubscriptions,
updateSubscription : updateSubscription,
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('+');
let expected_filesize = 0;
formats.forEach(format_id => {
if (!info_json.formats) return expected_filesize;
info_json.formats.forEach(available_format => {
if (available_format.format_id === format_id && available_format.filesize) {
expected_filesize += available_format.filesize;

View File

@@ -116,6 +116,8 @@ export class AppComponent implements OnInit, AfterViewInit {
if (this.allowSubscriptions) {
this.postsService.reloadSubscriptions();
}
this.postsService.reloadCategories();
}
// 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 { EditSubscriptionDialogComponent } from './dialogs/edit-subscription-dialog/edit-subscription-dialog.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');
@@ -123,7 +125,9 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
UnifiedFileCardComponent,
RecentVideosComponent,
EditSubscriptionDialogComponent,
CustomPlaylistsComponent
CustomPlaylistsComponent,
EditCategoryDialogComponent,
TwitchChatComponent
],
imports: [
CommonModule,

View File

@@ -61,7 +61,8 @@ export class LogsViewerComponent implements OnInit {
data: {
dialogTitle: 'Clear logs',
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 => {

View File

@@ -32,7 +32,7 @@
<div class="row justify-content-center">
<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' : '' ]">
<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>
</ng-container>
<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) {
// tell server to delete the file once downloaded
this.postsService.deleteFile(name, false).subscribe(delRes => {
this.postsService.deleteFile(name, type).subscribe(delRes => {
// reload mp4s
this.getAllFiles();
});
@@ -233,7 +233,7 @@ export class RecentVideosComponent implements OnInit {
}
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) {
this.postsService.openSnackBar('Delete success!', 'OK.');
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 *ngIf="!loading && file_obj.thumbnailURL" class="img-div">
<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">
{{file_length}}
</div>

View File

@@ -44,11 +44,13 @@ export class UnifiedFileCardComponent implements OnInit {
@Input() is_playlist = false;
@Input() index: number;
@Input() locale = null;
@Input() baseStreamPath = null;
@Input() jwtString = null;
@Output() goToFile = new EventEmitter<any>();
@Output() goToSubscription = new EventEmitter<any>();
@Output() deleteFile = new EventEmitter<any>();
@Output() editPlaylist = new EventEmitter<any>();
@ViewChild(MatMenuTrigger) contextMenu: MatMenuTrigger;
contextMenuPosition = { x: '0px', y: '0px' };
@@ -67,11 +69,12 @@ export class UnifiedFileCardComponent implements OnInit {
this.file_length = fancyTimeFormat(this.file_obj.duration);
}
if (this.file_obj && this.file_obj.thumbnailBlob) {
const mime = getMimeByFilename(this.file_obj.thumbnailPath);
if (this.file_obj && 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 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-actions>
<!-- 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">
<mat-spinner [diameter]="25"></mat-spinner>
</div>

View File

@@ -15,12 +15,14 @@ export class ConfirmDialogComponent implements OnInit {
doneEmitter: EventEmitter<any> = null;
onlyEmitOnDone = false;
warnSubmitColor = false;
constructor(@Inject(MAT_DIALOG_DATA) public data: any, public dialogRef: MatDialogRef<ConfirmDialogComponent>) {
if (this.data.dialogTitle) { this.dialogTitle = this.data.dialogTitle };
if (this.data.dialogText) { this.dialogText = this.data.dialogText };
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
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>
</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>
<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;
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 = [
'day',
@@ -39,16 +69,12 @@ export class EditSubscriptionDialogComponent implements OnInit {
if (this.sub.timerange) {
const timerange_str = this.sub.timerange.split('-')[1];
console.log(timerange_str);
const number = timerange_str.replace(/\D/g,'');
let units = timerange_str.replace(/[0-9]/g, '');
console.log(units);
// // remove plural on units
// if (units[units.length-1] === 's') {
// units = units.substring(0, units.length-1);
// }
if (+number === 1) {
units = units.replace('s', '');
}
this.timerange_amount = parseInt(number);
this.timerange_unit = units;
@@ -71,9 +97,10 @@ export class EditSubscriptionDialogComponent implements OnInit {
}
saveSubscription() {
this.postsService.updateSubscription(this.sub).subscribe(res => {
this.postsService.updateSubscription(this.new_sub).subscribe(res => {
this.sub = this.new_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) {
console.log(this.timerange_amount);
console.log(this.timerange_unit);
if (+this.timerange_amount === 1) {
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) {
this.new_sub.timerange = 'now-' + this.timerange_amount.toString() + this.timerange_unit;
console.log(this.new_sub.timerange);
} else {
this.new_sub.timerange = null;
}

View File

@@ -35,6 +35,13 @@
</mat-select>
</mat-form-field>
</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>
<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;
name = null;
maxQuality = 'best';
// state
subscribing = false;
@@ -29,12 +31,43 @@ export class SubscribeDialogComponent implements OnInit {
customFileOutput = '';
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 = [
'day',
'week',
'month',
'year'
]
];
constructor(private postsService: PostsService,
private snackBar: MatSnackBar,
@@ -57,7 +90,7 @@ export class SubscribeDialogComponent implements OnInit {
if (!this.download_all) {
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.subscribing = false;
if (res['new_sub']) {

View File

@@ -56,7 +56,7 @@ export class FileCardComponent implements OnInit {
deleteFile(blacklistMode = false) {
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) {
this.openSnackBar('Delete success!', 'OK.');
this.removeFile.emit(this.name);

View File

@@ -182,7 +182,7 @@
</ng-template>
<ng-container *ngIf="cachedFileManagerEnabled || fileManagerEnabled">
<app-recent-videos></app-recent-videos>
<app-recent-videos #recentVideos></app-recent-videos>
<br/>
<h4 style="text-align: center">Custom playlists</h4>
<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 { v4 as uuid } from 'uuid';
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 videoFilesMouseHovering = false;
@@ -200,6 +201,7 @@ export class MainComponent implements OnInit {
formats_loading = false;
@ViewChild('urlinput', { read: ElementRef }) urlInput: ElementRef;
@ViewChild('recentVideos') recentVideos: RecentVideosComponent;
@ViewChildren('audiofilecard') audioFileCards: QueryList<FileCardComponent>;
@ViewChildren('videofilecard') videoFileCards: QueryList<FileCardComponent>;
last_valid_url = '';
@@ -487,6 +489,7 @@ export class MainComponent implements OnInit {
this.downloadingfile = false;
if (this.multiDownloadMode && !this.downloadOnlyMode && !navigate_mode) {
// do nothing
this.reloadRecentVideos();
} else {
// if download only mode, just download the file. no redirect
if (forceView === false && this.downloadOnlyMode && !this.iOS) {
@@ -496,6 +499,7 @@ export class MainComponent implements OnInit {
} else {
this.downloadAudioFile(decodeURI(name));
}
this.reloadRecentVideos();
} else {
localStorage.setItem('player_navigator', this.router.url.split(';')[0]);
if (is_playlist) {
@@ -524,6 +528,7 @@ export class MainComponent implements OnInit {
this.downloadingfile = false;
if (this.multiDownloadMode && !this.downloadOnlyMode && !navigate_mode) {
// do nothing
this.reloadRecentVideos();
} else {
// if download only mode, just download the file. no redirect
if (forceView === false && this.downloadOnlyMode) {
@@ -533,6 +538,7 @@ export class MainComponent implements OnInit {
} else {
this.downloadVideoFile(decodeURI(name));
}
this.reloadRecentVideos();
} else {
localStorage.setItem('player_navigator', this.router.url.split(';')[0]);
if (is_playlist) {
@@ -746,7 +752,7 @@ export class MainComponent implements OnInit {
if (!this.fileManagerEnabled) {
// tell server to delete the file once downloaded
this.postsService.deleteFile(name, true).subscribe(delRes => {
this.postsService.deleteFile(name, 'video').subscribe(delRes => {
// reload mp3s
this.getMp3s();
});
@@ -763,7 +769,7 @@ export class MainComponent implements OnInit {
if (!this.fileManagerEnabled) {
// tell server to delete the file once downloaded
this.postsService.deleteFile(name, false).subscribe(delRes => {
this.postsService.deleteFile(name, 'audio').subscribe(delRes => {
// reload mp4s
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 [ngClass]="(type === 'audio') ? null : 'container-video'">
<div style="max-width: 100%; margin-left: 0px; height: 70vh">
<div style="height: fit-content" [ngClass]="(type === 'audio') ? 'audio-col' : 'video-col'">
<vg-player style="height: fit-content; max-height: 75vh" (onPlayerReady)="onPlayerReady($event)" [style.background-color]="(type === 'audio') ? 'transparent' : 'black'">
<video [ngClass]="(type === 'audio') ? 'audio-styles' : 'video-styles'" #media class="video-player" [vgMedia]="media" [src]="currentItem.src" id="singleVideo" preload="auto" controls>
</video>
</vg-player>
</div>
<div style="height: fit-content; width: 100%; margin-top: 10px;">
<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 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>
</div>
</div>
</div>
<div style="height: 100%" *ngIf="playlist.length > 0 && show_player">
<div style="height: 100%" [ngClass]="(type === 'audio') ? null : 'container-video'">
<div style="max-width: 100%; margin-left: 0px; height: 100%">
<mat-drawer-container style="height: 100%" class="example-container" autosize>
<div style="height: fit-content" [ngClass]="(type === 'audio') ? 'audio-col' : 'video-col'">
<vg-player style="height: fit-content; max-height: 75vh" (onPlayerReady)="onPlayerReady($event)" [style.background-color]="(type === 'audio') ? 'transparent' : 'black'">
<video [ngClass]="(type === 'audio') ? 'audio-styles' : 'video-styles'" #media class="video-player" [vgMedia]="media" [src]="currentItem.src" id="singleVideo" preload="auto" controls>
</video>
</vg-player>
</div>
<div style="height: fit-content; width: 100%; margin-top: 10px;">
<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 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>
</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="spinner-div">
<mat-spinner *ngIf="playlist_updating" [diameter]="25"></mat-spinner>
</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>
</div>
<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 *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>
</div>
<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 *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 class="update-playlist-button-div" *ngIf="id && playlistChanged()">
<div class="spinner-div">
<mat-spinner *ngIf="playlist_updating" [diameter]="25"></mat-spinner>
</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>
</div>
<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 *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>
</div>
<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 *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>

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 { PostsService } from 'app/posts.services';
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 { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { ShareMediaDialogComponent } from '../dialogs/share-media-dialog/share-media-dialog.component';
import { TwitchChatComponent } from 'app/components/twitch-chat/twitch-chat.component';
export interface IMedia {
title: string;
@@ -31,6 +32,7 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
currentIndex = 0;
currentItem: IMedia = null;
api: VgAPI;
api_ready = false;
// params
fileNames: string[];
@@ -65,6 +67,8 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
save_volume_timer = null;
original_volume = null;
@ViewChild('twitchchat') twitchChat: TwitchChatComponent;
@HostListener('window:resize', ['$event'])
onResize(event) {
this.innerWidth = window.innerWidth;
@@ -124,6 +128,8 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
this.getFile();
} else if (this.id) {
this.getPlaylistFiles();
} else if (this.subscriptionName) {
this.getSubscription();
}
if (this.url) {
@@ -139,7 +145,7 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
this.currentItem = this.playlist[0];
this.currentIndex = 0;
this.show_player = true;
} else if (this.subscriptionName || this.fileNames) {
} else if (this.fileNames && !this.subscriptionName) {
this.show_player = true;
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() {
this.postsService.getPlaylist(this.id, null, this.uuid).subscribe(res => {
if (res['playlist']) {
@@ -202,23 +227,26 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
const fileName = this.fileNames[i];
let baseLocation = 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
const uuid_str = this.uuid ? `&uuid=${this.uuid}` : '';
const uid_str = (this.id || !this.db_file) ? '' : `&uid=${this.db_file.uid}`;
const type_str = (this.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 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) {
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}`; }
} else if (this.is_shared) {
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) {
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
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 * as Fingerprint2 from 'fingerprintjs2';
import { isoLangs } from './settings/locales_list';
import { Title } from '@angular/platform-browser';
@Injectable()
export class PostsService implements CanActivate {
@@ -53,11 +54,12 @@ export class PostsService implements CanActivate {
// global vars
config = null;
subscriptions = null;
categories = null;
sidenav = null;
locale = isoLangs['en'];
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...');
this.path = this.document.location.origin + '/api/';
@@ -87,6 +89,7 @@ export class PostsService implements CanActivate {
const result = !this.debugMode ? res['config_file'] : res;
if (result) {
this.config = result['YoutubeDLMaterial'];
this.titleService.setTitle(this.config['Extra']['title_top']);
if (this.config['Advanced']['multi_user_mode']) {
this.checkAdminCreationStatus();
// login stuff
@@ -211,12 +214,8 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'setConfig', {new_config_file: config}, this.httpOptions);
}
deleteFile(uid: string, isAudio: boolean, blacklistMode = false) {
if (isAudio) {
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);
}
deleteFile(uid: string, type: string, blacklistMode = false) {
return this.http.post(this.path + 'deleteFile', {uid: uid, type: type, blacklistMode: blacklistMode}, this.httpOptions);
}
getMp3s() {
@@ -235,6 +234,14 @@ export class PostsService implements CanActivate {
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,
uid = null, uuid = null, id = null) {
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);
}
createSubscription(url, name, timerange = null, streamingOnly = false, audioOnly = false, customArgs = null, customFileOutput = null) {
return this.http.post(this.path + 'subscribe', {url: url, name: name, timerange: timerange, streamingOnly: streamingOnly,
audioOnly: audioOnly, customArgs: customArgs, customFileOutput: customFileOutput}, this.httpOptions);
// categories
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) {
@@ -328,8 +365,8 @@ export class PostsService implements CanActivate {
file_uid: file_uid}, this.httpOptions)
}
getSubscription(id) {
return this.http.post(this.path + 'getSubscription', {id: id}, this.httpOptions);
getSubscription(id, name = null) {
return this.http.post(this.path + 'getSubscription', {id: id, name: name}, this.httpOptions);
}
getAllSubscriptions() {

View File

@@ -116,14 +116,47 @@
</div>
<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">
<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>
<button class="args-edit-button" (click)="openArgsModifierDialog()" mat-icon-button><mat-icon>edit</mat-icon></button>
</mat-form-field>
</div>
<div class="col-12 mt-5">
</div>
</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>
</div>
@@ -195,12 +228,24 @@
<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>
</div>
<div class="col-12 mb-3">
<div class="col-12 mb-2">
<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>
<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>
</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>
<mat-divider></mat-divider>
@@ -235,11 +280,20 @@
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<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>
</div>
<div class="col-12">
<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-option value="aria2c">aria2c</mat-option>
<mat-option value="avconv">avconv</mat-option>
@@ -251,7 +305,7 @@
</mat-select>
</mat-form-field>
</div>
<div class="col-12 mt-2 mb-1">
<div class="col-12 mt-2">
<mat-form-field>
<mat-label><ng-container i18n="Log Level label">Log Level</ng-container></mat-label>
<mat-select color="accent" [(ngModel)]="new_config['Advanced']['logger_level']">
@@ -263,7 +317,7 @@
</mat-select>
</mat-form-field>
</div>
<div class="col-12 mt-2 mb-1">
<div class="col-12 mb-1">
<mat-form-field>
<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']">

View File

@@ -30,4 +30,55 @@
margin-left: 15px;
margin-bottom: 12px;
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 { CookiesUploaderDialogComponent } from 'app/dialogs/cookies-uploader-dialog/cookies-uploader-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({
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() {
this.postsService.generateNewAPIKey().subscribe(res => {
if (res['new_api_key']) {
@@ -162,7 +233,8 @@ export class SettingsComponent implements OnInit {
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.',
submitText: 'Kill all downloads',
doneEmitter: done
doneEmitter: done,
warnSubmitColor: true
}
});
done.subscribe(confirmed => {

View File

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

View File

@@ -7,9 +7,11 @@
"Downloader": {
"path-audio": "audio/",
"path-video": "video/",
"use_youtubedl_archive": true,
"use_youtubedl_archive": false,
"custom_args": "",
"safe_download_override": false
"safe_download_override": false,
"include_thumbnail": false,
"include_metadata": true
},
"Extra": {
"title_top": "YoutubeDL-Material",
@@ -33,12 +35,20 @@
"Subscriptions": {
"allow_subscriptions": true,
"subscriptions_base_path": "subscriptions/",
"subscriptions_check_interval": "30",
"subscriptions_check_interval": "300",
"subscriptions_use_youtubedl_archive": true
},
"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": {
"use_default_downloading_agent": true,
@@ -47,7 +57,7 @@
"allow_advanced_download": true,
"jwt_expiration": 86400,
"logger_level": "debug",
"use_cookies": true
"use_cookies": false
}
}
}