mirror of
https://github.com/Tzahi12345/YoutubeDL-Material.git
synced 2026-03-08 04:20:08 +03:00
Compare commits
23 Commits
rebuild-da
...
test-impro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be71a9bd8c | ||
|
|
42c600cea9 | ||
|
|
0427f91cfc | ||
|
|
51552b3092 | ||
|
|
1a7ca0343a | ||
|
|
525e8e04e8 | ||
|
|
5a824cee82 | ||
|
|
13a03a722c | ||
|
|
f7d3835111 | ||
|
|
8212acbac6 | ||
|
|
2a1af69f1f | ||
|
|
f23ca61dab | ||
|
|
6eadb37532 | ||
|
|
2c61260e0f | ||
|
|
ba0de7f95c | ||
|
|
3dfdbcb151 | ||
|
|
c207e56855 | ||
|
|
441131e930 | ||
|
|
7447ca038a | ||
|
|
c5d1b3ffcf | ||
|
|
c64140b605 | ||
|
|
6579f2b59e | ||
|
|
9f5b584593 |
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -80,7 +80,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
platforms: linux/amd64,linux/arm64/v8,linux/arm/v7
|
||||
push: true
|
||||
tags: ${{ steps.docker-meta.outputs.tags }}
|
||||
labels: ${{ steps.docker-meta.outputs.labels }}
|
||||
|
||||
40
.github/workflows/mocha.yml
vendored
Normal file
40
.github/workflows/mocha.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Tests
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: 'Backend - mocha'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node:
|
||||
- 16
|
||||
steps:
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '${{ matrix.node }}'
|
||||
- uses: actions/checkout@v2
|
||||
- name: 'Cache node_modules'
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-v${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-v${{ matrix.node }}-
|
||||
working-directory: ./backend
|
||||
- uses: FedericoCarboni/setup-ffmpeg@v2
|
||||
id: setup-ffmpeg
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
working-directory: ./backend
|
||||
- name: Run All Node.js Tests
|
||||
run: npm run test
|
||||
working-directory: ./backend
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -3,6 +3,6 @@
|
||||
"mochaExplorer.cwd": "backend",
|
||||
"mochaExplorer.globImplementation": "vscode",
|
||||
"mochaExplorer.env": {
|
||||
"YTDL_MODE": "debug"
|
||||
// "YTDL_MODE": "debug"
|
||||
}
|
||||
}
|
||||
@@ -1945,6 +1945,10 @@ app.post('/api/auth/register', optionalJwt, async (req, res) => {
|
||||
res.sendStatus(409);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userid || !username) {
|
||||
logger.error(`Registration failed for user ${userid}. Username or userid is invalid.`);
|
||||
}
|
||||
|
||||
const new_user = await auth_api.registerUser(userid, username, plaintextPassword);
|
||||
|
||||
|
||||
@@ -354,4 +354,4 @@ exports.YTDL_ARGS_WITH_VALUES = new Set(YTDL_ARGS_WITH_VALUES);
|
||||
|
||||
exports.ICON_URL = 'https://i.imgur.com/IKOlr0N.png';
|
||||
|
||||
exports.CURRENT_VERSION = 'v4.3.1';
|
||||
exports.CURRENT_VERSION = 'v4.3.2';
|
||||
|
||||
@@ -11,9 +11,8 @@ const logger = require('./logger');
|
||||
const low = require('lowdb')
|
||||
const FileSync = require('lowdb/adapters/FileSync');
|
||||
const { BehaviorSubject } = require('rxjs');
|
||||
const local_adapter = new FileSync('./appdata/local_db.json');
|
||||
const local_db = low(local_adapter);
|
||||
|
||||
let local_db = null;
|
||||
let database = null;
|
||||
exports.database_initialized = false;
|
||||
exports.database_initialized_bs = new BehaviorSubject(false);
|
||||
@@ -73,10 +72,6 @@ const tables = {
|
||||
|
||||
const tables_list = Object.keys(tables);
|
||||
|
||||
const local_db_defaults = {}
|
||||
tables_list.forEach(table => {local_db_defaults[table] = []});
|
||||
local_db.defaults(local_db_defaults).write();
|
||||
|
||||
let using_local_db = null;
|
||||
|
||||
function setDB(input_db, input_users_db) {
|
||||
@@ -85,11 +80,18 @@ function setDB(input_db, input_users_db) {
|
||||
exports.users_db = input_users_db
|
||||
}
|
||||
|
||||
exports.initialize = (input_db, input_users_db) => {
|
||||
exports.initialize = (input_db, input_users_db, db_name = 'local_db.json') => {
|
||||
setDB(input_db, input_users_db);
|
||||
|
||||
// must be done here to prevent getConfigItem from being called before init
|
||||
using_local_db = config_api.getConfigItem('ytdl_use_local_db');
|
||||
|
||||
const local_adapter = new FileSync(`./appdata/${db_name}`);
|
||||
local_db = low(local_adapter);
|
||||
|
||||
const local_db_defaults = {}
|
||||
tables_list.forEach(table => {local_db_defaults[table] = []});
|
||||
local_db.defaults(local_db_defaults).write();
|
||||
}
|
||||
|
||||
exports.connectToDB = async (retries = 5, no_fallback = false, custom_connection_string = null) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ const Mutex = require('async-mutex').Mutex;
|
||||
const youtubedl = require('youtube-dl');
|
||||
|
||||
const logger = require('./logger');
|
||||
const youtubedl_api = require('./youtube-dl');
|
||||
const config_api = require('./config');
|
||||
const twitch_api = require('./twitch');
|
||||
const { create } = require('xmlbuilder2');
|
||||
@@ -21,10 +22,10 @@ const mutex = new Mutex();
|
||||
let should_check_downloads = true;
|
||||
|
||||
if (db_api.database_initialized) {
|
||||
setupDownloads();
|
||||
exports.setupDownloads();
|
||||
} else {
|
||||
db_api.database_initialized_bs.subscribe(init => {
|
||||
if (init) setupDownloads();
|
||||
if (init) exports.setupDownloads();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,7 +48,7 @@ We use checkDownloads() to move downloads through the steps and call their respe
|
||||
|
||||
*/
|
||||
|
||||
exports.createDownload = async (url, type, options, user_uid = null, sub_id = null, sub_name = null, prefetched_info = null) => {
|
||||
exports.createDownload = async (url, type, options, user_uid = null, sub_id = null, sub_name = null, prefetched_info = null, paused = false) => {
|
||||
return await mutex.runExclusive(async () => {
|
||||
const download = {
|
||||
url: url,
|
||||
@@ -60,7 +61,7 @@ exports.createDownload = async (url, type, options, user_uid = null, sub_id = nu
|
||||
options: options,
|
||||
uid: uuid(),
|
||||
step_index: 0,
|
||||
paused: false,
|
||||
paused: paused,
|
||||
running: false,
|
||||
finished_step: true,
|
||||
error: null,
|
||||
@@ -133,7 +134,7 @@ async function handleDownloadError(download, error_message, error_type = null) {
|
||||
await db_api.updateRecord('download_queue', {uid: download['uid']}, {error: error_message, finished: true, running: false, error_type: error_type});
|
||||
}
|
||||
|
||||
async function setupDownloads() {
|
||||
exports.setupDownloads = async () => {
|
||||
await fixDownloadState();
|
||||
setInterval(checkDownloads, 1000);
|
||||
}
|
||||
@@ -188,7 +189,7 @@ async function checkDownloads() {
|
||||
if (waiting_download['step_index'] === 0) {
|
||||
collectInfo(waiting_download['uid']);
|
||||
} else if (waiting_download['step_index'] === 1) {
|
||||
downloadQueuedFile(waiting_download['uid']);
|
||||
exports.downloadQueuedFile(waiting_download['uid']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,7 +241,7 @@ async function collectInfo(download_uid) {
|
||||
let category = null;
|
||||
|
||||
// check if it fits into a category. If so, then get info again using new args
|
||||
if (!Array.isArray(info) || config_api.getConfigItem('ytdl_allow_playlist_categorization')) category = await categories_api.categorize(info);
|
||||
if (info.length === 0 || config_api.getConfigItem('ytdl_allow_playlist_categorization')) 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']) {
|
||||
@@ -259,13 +260,9 @@ async function collectInfo(download_uid) {
|
||||
const files_to_check_for_progress = [];
|
||||
|
||||
// store info in download for future use
|
||||
if (Array.isArray(info)) {
|
||||
for (let info_obj of info) files_to_check_for_progress.push(utils.removeFileExtension(info_obj['_filename']));
|
||||
} else {
|
||||
files_to_check_for_progress.push(utils.removeFileExtension(info['_filename']));
|
||||
}
|
||||
for (let info_obj of info) files_to_check_for_progress.push(utils.removeFileExtension(info_obj['_filename']));
|
||||
|
||||
const playlist_title = Array.isArray(info) ? info[0]['playlist_title'] || info[0]['playlist'] : null;
|
||||
const playlist_title = info.length > 0 ? info[0]['playlist_title'] || info[0]['playlist'] : null;
|
||||
await db_api.updateRecord('download_queue', {uid: download_uid}, {args: args,
|
||||
finished_step: true,
|
||||
running: false,
|
||||
@@ -278,7 +275,7 @@ async function collectInfo(download_uid) {
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadQueuedFile(download_uid) {
|
||||
exports.downloadQueuedFile = async(download_uid, downloadMethod = youtubedl.exec) => {
|
||||
const download = await db_api.getRecord('download_queue', {uid: download_uid});
|
||||
if (download['paused']) {
|
||||
return;
|
||||
@@ -306,114 +303,102 @@ async function downloadQueuedFile(download_uid) {
|
||||
const start_time = Date.now();
|
||||
|
||||
const download_checker = setInterval(() => checkDownloadPercent(download['uid']), 1000);
|
||||
|
||||
const file_objs = [];
|
||||
// download file
|
||||
youtubedl.exec(url, args, {maxBuffer: Infinity}, async function(err, output) {
|
||||
const file_objs = [];
|
||||
let end_time = Date.now();
|
||||
let difference = (end_time - start_time)/1000;
|
||||
logger.debug(`${type === 'audio' ? 'Audio' : 'Video'} download delay: ${difference} seconds.`);
|
||||
clearInterval(download_checker);
|
||||
if (err) {
|
||||
logger.error(err.stderr);
|
||||
await handleDownloadError(download, err.stderr, 'unknown_error');
|
||||
const {parsed_output, err} = await youtubedl_api.runYoutubeDL(url, args, downloadMethod);
|
||||
clearInterval(download_checker);
|
||||
let end_time = Date.now();
|
||||
let difference = (end_time - start_time)/1000;
|
||||
logger.debug(`${type === 'audio' ? 'Audio' : 'Video'} download delay: ${difference} seconds.`);
|
||||
if (!parsed_output) {
|
||||
logger.error(err.stderr);
|
||||
await handleDownloadError(download, err.stderr, 'unknown_error');
|
||||
resolve(false);
|
||||
return;
|
||||
} else if (parsed_output) {
|
||||
if (parsed_output.length === 0 || parsed_output[0].length === 0) {
|
||||
// ERROR!
|
||||
const error_message = `No output received for video download, check if it exists in your archive.`;
|
||||
await handleDownloadError(download, error_message, 'no_output');
|
||||
logger.warn(error_message);
|
||||
resolve(false);
|
||||
return;
|
||||
} else if (output) {
|
||||
if (output.length === 0 || output[0].length === 0) {
|
||||
// ERROR!
|
||||
const error_message = `No output received for video download, check if it exists in your archive.`;
|
||||
await handleDownloadError(download, error_message, 'no_output');
|
||||
logger.warn(error_message);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
let output_json = null;
|
||||
try {
|
||||
// we have to do this because sometimes there will be leading characters before the actual json
|
||||
const start_idx = output[i].indexOf('{"');
|
||||
const clean_output = output[i].slice(start_idx, output[i].length);
|
||||
output_json = JSON.parse(clean_output);
|
||||
} catch(e) {
|
||||
output_json = null;
|
||||
}
|
||||
|
||||
if (!output_json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// get filepath with no extension
|
||||
const filepath_no_extension = utils.removeFileExtension(output_json['_filename']);
|
||||
|
||||
const ext = type === 'audio' ? '.mp3' : '.mp4';
|
||||
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_api.getConfigItem('ytdl_twitch_auto_download_chat')) {
|
||||
let vodId = url.split('twitch.tv/videos/')[1];
|
||||
vodId = vodId.split('?')[0];
|
||||
twitch_api.downloadTwitchChatByVODID(vodId, file_name, type, download['user_uid']);
|
||||
}
|
||||
|
||||
// renames file if necessary due to bug
|
||||
if (!fs.existsSync(output_json['_filename']) && fs.existsSync(output_json['_filename'] + '.webm')) {
|
||||
try {
|
||||
fs.renameSync(output_json['_filename'] + '.webm', output_json['_filename']);
|
||||
logger.info('Renamed ' + file_name + '.webm to ' + file_name);
|
||||
} catch(e) {
|
||||
logger.error(`Failed to rename file ${output_json['_filename']} to its appropriate extension.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'audio') {
|
||||
let tags = {
|
||||
title: output_json['title'],
|
||||
artist: output_json['artist'] ? output_json['artist'] : output_json['uploader']
|
||||
}
|
||||
let success = NodeID3.write(tags, utils.removeFileExtension(output_json['_filename']) + '.mp3');
|
||||
if (!success) logger.error('Failed to apply ID3 tag to audio file ' + output_json['_filename']);
|
||||
}
|
||||
|
||||
if (config_api.getConfigItem('ytdl_generate_nfo_files')) {
|
||||
exports.generateNFOFile(output_json, `${filepath_no_extension}.nfo`);
|
||||
}
|
||||
|
||||
if (options.cropFileSettings) {
|
||||
await utils.cropFile(full_file_path, options.cropFileSettings.cropFileStart, options.cropFileSettings.cropFileEnd, ext);
|
||||
}
|
||||
|
||||
// registers file in DB
|
||||
const file_obj = await files_api.registerFileDB(full_file_path, type, download['user_uid'], category, download['sub_id'] ? download['sub_id'] : null, options.cropFileSettings);
|
||||
|
||||
await archive_api.addToArchive(output_json['extractor'], output_json['id'], type, output_json['title'], download['user_uid'], download['sub_id']);
|
||||
|
||||
notifications_api.sendDownloadNotification(file_obj, download['user_uid']);
|
||||
|
||||
file_objs.push(file_obj);
|
||||
}
|
||||
|
||||
let container = null;
|
||||
|
||||
if (file_objs.length > 1) {
|
||||
// create playlist
|
||||
const playlist_name = file_objs.map(file_obj => file_obj.title).join(', ');
|
||||
container = await files_api.createPlaylist(playlist_name, file_objs.map(file_obj => file_obj.uid), download['user_uid']);
|
||||
} else if (file_objs.length === 1) {
|
||||
container = file_objs[0];
|
||||
} else {
|
||||
const error_message = 'Downloaded file failed to result in metadata object.';
|
||||
logger.error(error_message);
|
||||
await handleDownloadError(download, error_message, 'no_metadata');
|
||||
}
|
||||
|
||||
const file_uids = file_objs.map(file_obj => file_obj.uid);
|
||||
await db_api.updateRecord('download_queue', {uid: download_uid}, {finished_step: true, finished: true, running: false, step_index: 3, percent_complete: 100, file_uids: file_uids, container: container});
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
for (const output_json of parsed_output) {
|
||||
if (!output_json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// get filepath with no extension
|
||||
const filepath_no_extension = utils.removeFileExtension(output_json['_filename']);
|
||||
|
||||
const ext = type === 'audio' ? '.mp3' : '.mp4';
|
||||
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_api.getConfigItem('ytdl_twitch_auto_download_chat')) {
|
||||
let vodId = url.split('twitch.tv/videos/')[1];
|
||||
vodId = vodId.split('?')[0];
|
||||
twitch_api.downloadTwitchChatByVODID(vodId, file_name, type, download['user_uid']);
|
||||
}
|
||||
|
||||
// renames file if necessary due to bug
|
||||
if (!fs.existsSync(output_json['_filename']) && fs.existsSync(output_json['_filename'] + '.webm')) {
|
||||
try {
|
||||
fs.renameSync(output_json['_filename'] + '.webm', output_json['_filename']);
|
||||
logger.info('Renamed ' + file_name + '.webm to ' + file_name);
|
||||
} catch(e) {
|
||||
logger.error(`Failed to rename file ${output_json['_filename']} to its appropriate extension.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'audio') {
|
||||
let tags = {
|
||||
title: output_json['title'],
|
||||
artist: output_json['artist'] ? output_json['artist'] : output_json['uploader']
|
||||
}
|
||||
let success = NodeID3.write(tags, utils.removeFileExtension(output_json['_filename']) + '.mp3');
|
||||
if (!success) logger.error('Failed to apply ID3 tag to audio file ' + output_json['_filename']);
|
||||
}
|
||||
|
||||
if (config_api.getConfigItem('ytdl_generate_nfo_files')) {
|
||||
exports.generateNFOFile(output_json, `${filepath_no_extension}.nfo`);
|
||||
}
|
||||
|
||||
if (options.cropFileSettings) {
|
||||
await utils.cropFile(full_file_path, options.cropFileSettings.cropFileStart, options.cropFileSettings.cropFileEnd, ext);
|
||||
}
|
||||
|
||||
// registers file in DB
|
||||
const file_obj = await files_api.registerFileDB(full_file_path, type, download['user_uid'], category, download['sub_id'] ? download['sub_id'] : null, options.cropFileSettings);
|
||||
|
||||
await archive_api.addToArchive(output_json['extractor'], output_json['id'], type, output_json['title'], download['user_uid'], download['sub_id']);
|
||||
|
||||
notifications_api.sendDownloadNotification(file_obj, download['user_uid']);
|
||||
|
||||
file_objs.push(file_obj);
|
||||
}
|
||||
|
||||
let container = null;
|
||||
|
||||
if (file_objs.length > 1) {
|
||||
// create playlist
|
||||
const playlist_name = file_objs.map(file_obj => file_obj.title).join(', ');
|
||||
container = await files_api.createPlaylist(playlist_name, file_objs.map(file_obj => file_obj.uid), download['user_uid']);
|
||||
} else if (file_objs.length === 1) {
|
||||
container = file_objs[0];
|
||||
} else {
|
||||
const error_message = 'Downloaded file failed to result in metadata object.';
|
||||
logger.error(error_message);
|
||||
await handleDownloadError(download, error_message, 'no_metadata');
|
||||
}
|
||||
|
||||
const file_uids = file_objs.map(file_obj => file_obj.uid);
|
||||
await db_api.updateRecord('download_queue', {uid: download_uid}, {finished_step: true, finished: true, running: false, step_index: 3, percent_complete: 100, file_uids: file_uids, container: container});
|
||||
resolve(file_uids);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -564,33 +549,9 @@ exports.getVideoInfoByURL = async (url, args = [], download_uid = null) => {
|
||||
new_args.push('--dump-json');
|
||||
|
||||
youtubedl.exec(url, new_args, {maxBuffer: Infinity}, async (err, output) => {
|
||||
if (output) {
|
||||
let outputs = [];
|
||||
try {
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
let output_json = null;
|
||||
try {
|
||||
output_json = JSON.parse(output[i]);
|
||||
} catch(e) {
|
||||
output_json = null;
|
||||
}
|
||||
|
||||
if (!output_json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
outputs.push(output_json);
|
||||
}
|
||||
resolve(outputs.length === 1 ? outputs[0] : outputs);
|
||||
} catch(e) {
|
||||
const error = `Error while retrieving info on video with URL ${url} with the following message: output JSON could not be parsed. Output JSON: ${output}`;
|
||||
logger.error(error);
|
||||
if (download_uid) {
|
||||
const download = await db_api.getRecord('download_queue', {uid: download_uid});
|
||||
await handleDownloadError(download, error, 'parse_failed');
|
||||
}
|
||||
resolve(null);
|
||||
}
|
||||
const parsed_output = utils.parseOutputJSON(output, err);
|
||||
if (parsed_output) {
|
||||
resolve(parsed_output);
|
||||
} else {
|
||||
let error_message = `Error while retrieving info on video with URL ${url} with the following message: ${err}`;
|
||||
if (err.stderr) error_message += `\n\n${err.stderr}`;
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
#!/bin/sh
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
CMD="npm start && pm2 start"
|
||||
|
||||
# if the first arg starts with "-" pass it to program
|
||||
if [ "${1#-}" != "$1" ]; then
|
||||
set -- "$CMD" "$@"
|
||||
fi
|
||||
|
||||
# chown current working directory to current user
|
||||
if [ "$*" = "$CMD" ] && [ "$(id -u)" = "0" ]; then
|
||||
find . \! -user "$UID" -exec chown "$UID:$GID" -R '{}' + || echo "WARNING! Could not change directory ownership. If you manage permissions externally this is fine, otherwise you may experience issues when downloading or deleting videos."
|
||||
exec gosu "$UID:$GID" "$0" "$@"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
echo "[entrypoint] setup permission, this may take a while"
|
||||
find . \! -user "$UID" -exec chown "$UID:$GID" '{}' + || echo "WARNING! Could not change directory ownership. If you manage permissions externally this is fine, otherwise you may experience issues when downloading or deleting videos."
|
||||
exec gosu "$UID:$GID" "$@"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "backend for YoutubeDL-Material",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test": "mocha test --exit -s 1000",
|
||||
"start": "pm2-runtime --raw pm2.config.js",
|
||||
"debug": "set YTDL_MODE=debug && node app.js"
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@ exports.subscribe = async (sub, user_uid = null, skip_get_info = false) => {
|
||||
}
|
||||
|
||||
sub['user_uid'] = user_uid ? user_uid : undefined;
|
||||
await db_api.insertRecordIntoTable('subscriptions', sub);
|
||||
await db_api.insertRecordIntoTable('subscriptions', JSON.parse(JSON.stringify(sub)));
|
||||
|
||||
let success = skip_get_info ? true : await getSubscriptionInfo(sub);
|
||||
exports.writeSubscriptionMetadata(sub);
|
||||
@@ -254,26 +254,15 @@ exports.getVideosForSub = async (sub, user_uid = null) => {
|
||||
}
|
||||
|
||||
logger.verbose('Subscription: finished check for ' + sub.name);
|
||||
if (err && !output) {
|
||||
logger.error(err.stderr ? err.stderr : err.message);
|
||||
if (err.stderr.includes('This video is unavailable') || err.stderr.includes('Private video')) {
|
||||
logger.info('An error was encountered with at least one video, backup method will be used.')
|
||||
try {
|
||||
const outputs = err.stdout.split(/\r\n|\r|\n/);
|
||||
const files_to_download = await handleOutputJSON(outputs, sub, user_uid);
|
||||
resolve(files_to_download);
|
||||
} catch(e) {
|
||||
logger.error('Backup method failed. See error below:');
|
||||
logger.error(e);
|
||||
}
|
||||
} else {
|
||||
logger.error('Subscription check failed!');
|
||||
}
|
||||
resolve(false);
|
||||
} else if (output) {
|
||||
const files_to_download = await handleOutputJSON(output, sub, user_uid);
|
||||
resolve(files_to_download);
|
||||
}
|
||||
const parsed_output = utils.parseOutputJSON(output, err);
|
||||
if (!parsed_output) {
|
||||
logger.error('Subscription check failed!');
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const files_to_download = await handleOutputJSON(parsed_output, sub, user_uid);
|
||||
resolve(files_to_download);
|
||||
return;
|
||||
});
|
||||
}, err => {
|
||||
logger.error(err);
|
||||
@@ -281,38 +270,24 @@ exports.getVideosForSub = async (sub, user_uid = null) => {
|
||||
});
|
||||
}
|
||||
|
||||
async function handleOutputJSON(output, sub, user_uid) {
|
||||
async function handleOutputJSON(output_jsons, sub, user_uid) {
|
||||
if (config_api.getConfigItem('ytdl_subscriptions_redownload_fresh_uploads')) {
|
||||
await setFreshUploads(sub, user_uid);
|
||||
checkVideosForFreshUploads(sub, user_uid);
|
||||
}
|
||||
|
||||
if (output.length === 0 || (output.length === 1 && output[0] === '')) {
|
||||
if (output_jsons.length === 0 || (output_jsons.length === 1 && output_jsons[0] === '')) {
|
||||
logger.verbose('No additional videos to download for ' + sub.name);
|
||||
return [];
|
||||
}
|
||||
|
||||
const output_jsons = [];
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
let output_json = null;
|
||||
try {
|
||||
output_json = JSON.parse(output[i]);
|
||||
output_jsons.push(output_json);
|
||||
} catch(e) {
|
||||
output_json = null;
|
||||
}
|
||||
if (!output_json) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const files_to_download = await getFilesToDownload(sub, output_jsons);
|
||||
const base_download_options = exports.generateOptionsForSubscriptionDownload(sub, user_uid);
|
||||
|
||||
for (let j = 0; j < files_to_download.length; j++) {
|
||||
const file_to_download = files_to_download[j];
|
||||
file_to_download['formats'] = utils.stripPropertiesFromObject(file_to_download['formats'], ['format_id', 'filesize', 'filesize_approx']); // prevent download object from blowing up in size
|
||||
await downloader_api.createDownload(file_to_download['webpage_url'], sub.type || 'video', base_download_options, user_uid, sub.id, sub.name, file_to_download);
|
||||
await downloader_api.createDownload(file_to_download['webpage_url'], sub.type || 'video', base_download_options, user_uid, sub.id, sub.name, [file_to_download]);
|
||||
}
|
||||
|
||||
return files_to_download;
|
||||
@@ -373,10 +348,13 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
|
||||
|
||||
// skip videos that are in the archive. otherwise sub download can be permanently slow (vs. just the first time)
|
||||
const archive_text = await archive_api.generateArchive(sub.type, sub.user_uid, sub.id);
|
||||
logger.verbose(`Generating temporary archive file for subscription ${sub.name} with ${archive_text.split('\n').length - 1} entries.`)
|
||||
const archive_path = path.join(appendedBasePath, 'archive.txt');
|
||||
await fs.writeFile(archive_path, archive_text);
|
||||
downloadConfig.push('--download-archive', archive_path);
|
||||
const archive_count = archive_text.split('\n').length - 1;
|
||||
if (archive_count > 0) {
|
||||
logger.verbose(`Generating temporary archive file for subscription ${sub.name} with ${archive_count} entries.`)
|
||||
const archive_path = path.join(appendedBasePath, 'archive.txt');
|
||||
await fs.writeFile(archive_path, archive_text);
|
||||
downloadConfig.push('--download-archive', archive_path);
|
||||
}
|
||||
|
||||
if (sub.custom_args) {
|
||||
const customArgsArray = sub.custom_args.split(',,');
|
||||
@@ -488,6 +466,8 @@ exports.writeSubscriptionMetadata = (sub) => {
|
||||
: config_api.getConfigItem('ytdl_subscriptions_base_path');
|
||||
const appendedBasePath = getAppendedBasePath(sub, basePath);
|
||||
const metadata_path = path.join(appendedBasePath, CONSTS.SUBSCRIPTION_BACKUP_PATH);
|
||||
|
||||
fs.ensureDirSync(appendedBasePath);
|
||||
fs.writeJSONSync(metadata_path, sub);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
1
backend/test/sample_mp3.info.json
Normal file
1
backend/test/sample_mp3.info.json
Normal file
File diff suppressed because one or more lines are too long
1
backend/test/sample_mp4.info.json
Normal file
1
backend/test/sample_mp4.info.json
Normal file
File diff suppressed because one or more lines are too long
@@ -3,7 +3,8 @@ const assert = require('assert');
|
||||
const low = require('lowdb')
|
||||
const winston = require('winston');
|
||||
const path = require('path');
|
||||
|
||||
const util = require('util');
|
||||
const exec = util.promisify(require('child_process').exec);
|
||||
|
||||
const FileSync = require('lowdb/adapters/FileSync');
|
||||
|
||||
@@ -41,11 +42,13 @@ const subscriptions_api = require('../subscriptions');
|
||||
const archive_api = require('../archive');
|
||||
const categories_api = require('../categories');
|
||||
const files_api = require('../files');
|
||||
const youtubedl_api = require('../youtube-dl');
|
||||
const config_api = require('../config');
|
||||
const fs = require('fs-extra');
|
||||
const { uuid } = require('uuidv4');
|
||||
const NodeID3 = require('node-id3');
|
||||
|
||||
db_api.initialize(db, users_db);
|
||||
db_api.initialize(db, users_db, 'local_db_test.json');
|
||||
|
||||
const sample_video_json = {
|
||||
id: "Sample Video",
|
||||
@@ -68,9 +71,9 @@ const sample_video_json = {
|
||||
}
|
||||
|
||||
describe('Database', async function() {
|
||||
describe('Import', async function() {
|
||||
describe.skip('Import', async function() {
|
||||
// it('Migrate', async function() {
|
||||
// await db_api.connectToDB();
|
||||
// // await db_api.connectToDB();
|
||||
// await db_api.removeAllRecords();
|
||||
// const success = await db_api.importJSONToDB(db.value(), users_db.value());
|
||||
// assert(success);
|
||||
@@ -86,7 +89,7 @@ describe('Database', async function() {
|
||||
});
|
||||
|
||||
it('Transfer to local', async function() {
|
||||
await db_api.connectToDB();
|
||||
// await db_api.connectToDB();
|
||||
await db_api.removeAllRecords('test');
|
||||
await db_api.insertRecordIntoTable('test', {test: 'test'});
|
||||
|
||||
@@ -114,7 +117,8 @@ describe('Database', async function() {
|
||||
|
||||
for (const local_db_mode of local_db_modes) {
|
||||
let use_local_db = local_db_mode;
|
||||
describe(`Use local DB - ${use_local_db}`, async function() {
|
||||
const describe_skippable = use_local_db ? describe : describe.skip;
|
||||
describe_skippable(`Use local DB - ${use_local_db}`, async function() {
|
||||
beforeEach(async function() {
|
||||
if (!use_local_db) {
|
||||
this.timeout(120000);
|
||||
@@ -167,7 +171,7 @@ describe('Database', async function() {
|
||||
];
|
||||
await db_api.insertRecordsIntoTable('test', test_duplicates);
|
||||
const duplicates = await db_api.findDuplicatesByKey('test', 'test');
|
||||
console.log(duplicates);
|
||||
assert(duplicates && duplicates.length === 2 && duplicates[0]['key'] === '2' && duplicates[1]['key'] === '4')
|
||||
});
|
||||
|
||||
it('Update record', async function() {
|
||||
@@ -279,7 +283,7 @@ describe('Database', async function() {
|
||||
assert(stats);
|
||||
});
|
||||
|
||||
it('Query speed', async function() {
|
||||
it.skip('Query speed', async function() {
|
||||
this.timeout(120000);
|
||||
const NUM_RECORDS_TO_ADD = 300004; // max batch ops is 1000
|
||||
const test_records = [];
|
||||
@@ -337,12 +341,13 @@ describe('Database', async function() {
|
||||
});
|
||||
|
||||
describe('Multi User', async function() {
|
||||
this.timeout(120000);
|
||||
const user_to_test = 'test_user';
|
||||
const user_password = 'test_pass';
|
||||
const sub_to_test = '';
|
||||
const playlist_to_test = '';
|
||||
beforeEach(async function() {
|
||||
await db_api.connectToDB();
|
||||
// await db_api.connectToDB();
|
||||
await auth_api.deleteUser(user_to_test);
|
||||
});
|
||||
describe('Basic', function() {
|
||||
@@ -369,17 +374,17 @@ describe('Multi User', async function() {
|
||||
|
||||
it('Video access - disallowed', async function() {
|
||||
await db_api.setVideoProperty(video_to_test, {sharingEnabled: false});
|
||||
const video_obj = auth_api.getUserVideo(user_to_test, video_to_test, true);
|
||||
const video_obj = await auth_api.getUserVideo(user_to_test, video_to_test, true);
|
||||
assert(!video_obj);
|
||||
});
|
||||
|
||||
it('Video access - allowed', async function() {
|
||||
await db_api.setVideoProperty(video_to_test, {sharingEnabled: true}, user_to_test);
|
||||
const video_obj = auth_api.getUserVideo(user_to_test, video_to_test, true);
|
||||
const video_obj = await auth_api.getUserVideo(user_to_test, video_to_test, true);
|
||||
assert(video_obj);
|
||||
});
|
||||
});
|
||||
describe('Zip generators', function() {
|
||||
describe.skip('Zip generators', function() {
|
||||
it('Playlist zip generator', async function() {
|
||||
const playlist = await files_api.getPlaylist(playlist_to_test, user_to_test);
|
||||
assert(playlist);
|
||||
@@ -435,35 +440,50 @@ describe('Multi User', async function() {
|
||||
|
||||
describe('Downloader', function() {
|
||||
const downloader_api = require('../downloader');
|
||||
const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||||
const url = 'https://www.youtube.com/watch?v=hpigjnKl7nI';
|
||||
const sub_id = 'dc834388-3454-41bf-a618-e11cb8c7de1c';
|
||||
const options = {
|
||||
ui_uid: uuid(),
|
||||
user: 'admin'
|
||||
ui_uid: uuid()
|
||||
}
|
||||
|
||||
before(async function() {
|
||||
const update_available = await youtubedl_api.checkForYoutubeDLUpdate();
|
||||
if (update_available) await youtubedl_api.updateYoutubeDL(update_available);
|
||||
config_api.setConfigItem('ytdl_max_concurrent_downloads', 0);
|
||||
});
|
||||
|
||||
beforeEach(async function() {
|
||||
await db_api.connectToDB();
|
||||
// await db_api.connectToDB();
|
||||
await db_api.removeAllRecords('download_queue');
|
||||
});
|
||||
|
||||
it('Get file info', async function() {
|
||||
this.timeout(300000);
|
||||
const info = await downloader_api.getVideoInfoByURL(url);
|
||||
assert(!!info);
|
||||
assert(!!info && info.length > 0);
|
||||
});
|
||||
|
||||
it('Download file', async function() {
|
||||
this.timeout(300000);
|
||||
this.timeout(300000);
|
||||
await downloader_api.setupDownloads();
|
||||
const args = await downloader_api.generateArgs(url, 'video', options, null, true);
|
||||
const [info] = await downloader_api.getVideoInfoByURL(url, args);
|
||||
if (fs.existsSync(info['_filename'])) fs.unlinkSync(info['_filename']);
|
||||
const returned_download = await downloader_api.createDownload(url, 'video', options);
|
||||
console.log(returned_download);
|
||||
await utils.wait(20000);
|
||||
|
||||
assert(returned_download);
|
||||
const custom_download_method = async (url, args, options, callback) => {
|
||||
fs.writeJSONSync(utils.getTrueFileName(info['_filename'], 'video', '.info.json'), info);
|
||||
await generateEmptyVideoFile(info['_filename']);
|
||||
return await callback(null, [JSON.stringify(info)]);
|
||||
}
|
||||
const success = await downloader_api.downloadQueuedFile(returned_download['uid'], custom_download_method);
|
||||
assert(success);
|
||||
});
|
||||
|
||||
it('Tag file', async function() {
|
||||
const audio_path = './test/sample.mp3';
|
||||
const sample_json = fs.readJSONSync('./test/sample.info.json');
|
||||
const success = await generateEmptyAudioFile('test/sample_mp3.mp3');
|
||||
const audio_path = './test/sample_mp3.mp3';
|
||||
const sample_json = fs.readJSONSync('./test/sample_mp3.info.json');
|
||||
const tags = {
|
||||
title: sample_json['title'],
|
||||
artist: sample_json['artist'] ? sample_json['artist'] : sample_json['uploader'],
|
||||
@@ -471,14 +491,13 @@ describe('Downloader', function() {
|
||||
}
|
||||
NodeID3.write(tags, audio_path);
|
||||
const written_tags = NodeID3.read(audio_path);
|
||||
assert(written_tags['raw']['TRCK'] === '27');
|
||||
assert(success && written_tags['raw']['TRCK'] === '27');
|
||||
});
|
||||
|
||||
it('Queue file', async function() {
|
||||
this.timeout(300000);
|
||||
const returned_download = await downloader_api.createDownload(url, 'video', options);
|
||||
console.log(returned_download);
|
||||
await utils.wait(20000);
|
||||
const returned_download = await downloader_api.createDownload(url, 'video', options, null, null, null, null, true);
|
||||
assert(returned_download);
|
||||
});
|
||||
|
||||
it('Pause file', async function() {
|
||||
@@ -493,7 +512,7 @@ describe('Downloader', function() {
|
||||
assert(args.length > 0);
|
||||
});
|
||||
|
||||
it('Generate args - subscription', async function() {
|
||||
it.skip('Generate args - subscription', async function() {
|
||||
const sub = await subscriptions_api.getSubscription(sub_id);
|
||||
const sub_options = subscriptions_api.generateOptionsForSubscriptionDownload(sub, 'admin');
|
||||
const args_normal = await downloader_api.generateArgs(url, 'video', options);
|
||||
@@ -506,7 +525,7 @@ describe('Downloader', function() {
|
||||
if (fs.existsSync(nfo_file_path)) {
|
||||
fs.unlinkSync(nfo_file_path);
|
||||
}
|
||||
const sample_json = fs.readJSONSync('./test/sample.info.json');
|
||||
const sample_json = fs.readJSONSync('./test/sample_mp4.info.json');
|
||||
downloader_api.generateNFOFile(sample_json, nfo_file_path);
|
||||
assert(fs.existsSync(nfo_file_path), true);
|
||||
fs.unlinkSync(nfo_file_path);
|
||||
@@ -534,10 +553,18 @@ describe('Downloader', function() {
|
||||
describe('Twitch', async function () {
|
||||
const twitch_api = require('../twitch');
|
||||
const example_vod = '1710641401';
|
||||
it('Download VOD', async function() {
|
||||
it('Download VOD chat', async function() {
|
||||
this.timeout(300000);
|
||||
if (!fs.existsSync('TwitchDownloaderCLI')) {
|
||||
try {
|
||||
await exec('sh ../docker-utils/fetch-twitchdownloader.sh');
|
||||
fs.copyFileSync('../docker-utils/TwitchDownloaderCLI', 'TwitchDownloaderCLI');
|
||||
} catch (e) {
|
||||
logger.info('TwitchDownloaderCLI fetch failed, file may exist regardless.');
|
||||
}
|
||||
}
|
||||
const sample_path = path.join('test', 'sample.twitch_chat.json');
|
||||
if (fs.existsSync(sample_path)) fs.unlinkSync(sample_path);
|
||||
this.timeout(300000);
|
||||
await twitch_api.downloadTwitchChatByVODID(example_vod, 'sample', null, null, null, './test');
|
||||
assert(fs.existsSync(sample_path));
|
||||
|
||||
@@ -550,7 +577,7 @@ describe('Downloader', function() {
|
||||
describe('Tasks', function() {
|
||||
const tasks_api = require('../tasks');
|
||||
beforeEach(async function() {
|
||||
await db_api.connectToDB();
|
||||
// await db_api.connectToDB();
|
||||
await db_api.removeAllRecords('tasks');
|
||||
|
||||
const dummy_task = {
|
||||
@@ -569,7 +596,7 @@ describe('Tasks', function() {
|
||||
await tasks_api.executeTask('backup_local_db');
|
||||
const backups_new = await utils.recFindByExt('appdata', 'bak');
|
||||
const new_length = backups_new.length;
|
||||
assert(original_length, new_length-1);
|
||||
assert(original_length === new_length-1);
|
||||
});
|
||||
|
||||
it('Check for missing files', async function() {
|
||||
@@ -579,7 +606,7 @@ describe('Tasks', function() {
|
||||
await db_api.insertRecordIntoTable('files', test_missing_file);
|
||||
await tasks_api.executeTask('missing_files_check');
|
||||
const missing_file_db_record = await db_api.getRecord('files', {uid: 'test'});
|
||||
assert(!missing_file_db_record, true);
|
||||
assert(!missing_file_db_record);
|
||||
});
|
||||
|
||||
it('Check for duplicate files', async function() {
|
||||
@@ -599,27 +626,29 @@ describe('Tasks', function() {
|
||||
|
||||
await tasks_api.executeTask('duplicate_files_check');
|
||||
const duplicated_record_count = await db_api.getRecords('files', {path: 'test/missing_file.mp4'}, true);
|
||||
assert(duplicated_record_count == 1, true);
|
||||
assert(duplicated_record_count === 1);
|
||||
});
|
||||
|
||||
it('Import unregistered files', async function() {
|
||||
this.timeout(300000);
|
||||
|
||||
const success = await generateEmptyVideoFile('test/sample_mp4.mp4');
|
||||
|
||||
// pre-test cleanup
|
||||
await db_api.removeAllRecords('files', {title: 'Sample File'});
|
||||
if (fs.existsSync('video/sample.info.json')) fs.unlinkSync('video/sample.info.json');
|
||||
if (fs.existsSync('video/sample.mp4')) fs.unlinkSync('video/sample.mp4');
|
||||
if (fs.existsSync('video/sample_mp4.info.json')) fs.unlinkSync('video/sample_mp4.info.json');
|
||||
if (fs.existsSync('video/sample_mp4.mp4')) fs.unlinkSync('video/sample_mp4.mp4');
|
||||
|
||||
// copies in files
|
||||
fs.copyFileSync('test/sample.info.json', 'video/sample.info.json');
|
||||
fs.copyFileSync('test/sample.mp4', 'video/sample.mp4');
|
||||
fs.copyFileSync('test/sample_mp4.info.json', 'video/sample_mp4.info.json');
|
||||
fs.copyFileSync('test/sample_mp4.mp4', 'video/sample_mp4.mp4');
|
||||
await tasks_api.executeTask('missing_db_records');
|
||||
const imported_file = await db_api.getRecord('files', {title: 'Sample File'});
|
||||
assert(!!imported_file === true);
|
||||
assert(success && !!imported_file);
|
||||
|
||||
// post-test cleanup
|
||||
if (fs.existsSync('video/sample.info.json')) fs.unlinkSync('video/sample.info.json');
|
||||
if (fs.existsSync('video/sample.mp4')) fs.unlinkSync('video/sample.mp4');
|
||||
if (fs.existsSync('video/sample_mp4.info.json')) fs.unlinkSync('video/sample_mp4.info.json');
|
||||
if (fs.existsSync('video/sample_mp4.mp4')) fs.unlinkSync('video/sample_mp4.mp4');
|
||||
});
|
||||
|
||||
it('Schedule and cancel task', async function() {
|
||||
@@ -659,12 +688,12 @@ describe('Tasks', function() {
|
||||
|
||||
describe('Archive', async function() {
|
||||
beforeEach(async function() {
|
||||
await db_api.connectToDB();
|
||||
await db_api.removeAllRecords('archives', {user_uid: 'test_user'});
|
||||
// await db_api.connectToDB();
|
||||
await db_api.removeAllRecords('archives');
|
||||
});
|
||||
|
||||
afterEach(async function() {
|
||||
await db_api.removeAllRecords('archives', {user_uid: 'test_user'});
|
||||
await db_api.removeAllRecords('archives');
|
||||
});
|
||||
|
||||
it('Import archive', async function() {
|
||||
@@ -678,7 +707,6 @@ describe('Archive', async function() {
|
||||
const count = await archive_api.importArchiveFile(archive_text, 'video', 'test_user', 'test_sub');
|
||||
assert(count === 4)
|
||||
const archive_items = await db_api.getRecords('archives', {user_uid: 'test_user', sub_id: 'test_sub'});
|
||||
console.log(archive_items);
|
||||
assert(archive_items.length === 4);
|
||||
assert(archive_items.filter(archive_item => archive_item.extractor === 'testextractor2').length === 1);
|
||||
assert(archive_items.filter(archive_item => archive_item.extractor === 'testextractor1').length === 3);
|
||||
@@ -709,9 +737,9 @@ describe('Archive', async function() {
|
||||
});
|
||||
|
||||
it('Remove from archive', async function() {
|
||||
await archive_api.addToArchive('testextractor1', 'testing1', 'video', 'test_user');
|
||||
await archive_api.addToArchive('testextractor2', 'testing1', 'video', 'test_user');
|
||||
await archive_api.addToArchive('testextractor2', 'testing2', 'video', 'test_user');
|
||||
await archive_api.addToArchive('testextractor1', 'testing1', 'video', 'test_title', 'test_user');
|
||||
await archive_api.addToArchive('testextractor2', 'testing1', 'video', 'test_title', 'test_user');
|
||||
await archive_api.addToArchive('testextractor2', 'testing2', 'video', 'test_title', 'test_user');
|
||||
|
||||
const success = await archive_api.removeFromArchive('testextractor2', 'testing1', 'video', 'test_user');
|
||||
assert(success);
|
||||
@@ -757,7 +785,7 @@ describe('Utils', async function() {
|
||||
|
||||
describe('Categories', async function() {
|
||||
beforeEach(async function() {
|
||||
await db_api.connectToDB();
|
||||
// await db_api.connectToDB();
|
||||
const new_category = {
|
||||
name: 'test_category',
|
||||
uid: uuid(),
|
||||
@@ -805,7 +833,6 @@ describe('Categories', async function() {
|
||||
});
|
||||
|
||||
const category = await categories_api.categorize([sample_video_json]);
|
||||
console.log(category);
|
||||
assert(category && category.name === 'test_category');
|
||||
});
|
||||
|
||||
@@ -858,4 +885,14 @@ describe('Categories', async function() {
|
||||
const category = await categories_api.categorize([sample_video_json]);
|
||||
assert(category);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const generateEmptyVideoFile = async (file_path) => {
|
||||
if (fs.existsSync(file_path)) fs.unlinkSync(file_path);
|
||||
return await exec(`ffmpeg -t 1 -f lavfi -i color=c=black:s=640x480 -c:v libx264 -tune stillimage -pix_fmt yuv420p "${file_path}"`);
|
||||
}
|
||||
|
||||
const generateEmptyAudioFile = async (file_path) => {
|
||||
if (fs.existsSync(file_path)) fs.unlinkSync(file_path);
|
||||
return await exec(`ffmpeg -f lavfi -i anullsrc=r=44100:cl=mono -t 1 -q:a 9 -acodec libmp3lame ${file_path}`);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const CONSTS = require('./consts');
|
||||
const is_windows = process.platform === 'win32';
|
||||
|
||||
// replaces .webm with appropriate extension
|
||||
exports.getTrueFileName = (unfixed_path, type) => {
|
||||
exports.getTrueFileName = (unfixed_path, type, force_ext = null) => {
|
||||
let fixed_path = unfixed_path;
|
||||
|
||||
const new_ext = (type === 'audio' ? 'mp3' : 'mp4');
|
||||
@@ -22,7 +22,7 @@ exports.getTrueFileName = (unfixed_path, type) => {
|
||||
|
||||
|
||||
if (old_ext !== new_ext) {
|
||||
unfixed_parts[unfixed_parts.length-1] = new_ext;
|
||||
unfixed_parts[unfixed_parts.length-1] = force_ext || new_ext;
|
||||
fixed_path = unfixed_parts.join('.');
|
||||
}
|
||||
return fixed_path;
|
||||
@@ -347,7 +347,7 @@ exports.checkExistsWithTimeout = async (filePath, timeout) => {
|
||||
if (!err) {
|
||||
clearTimeout(timer);
|
||||
if (watcher) watcher.close();
|
||||
resolve();
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -357,7 +357,7 @@ exports.checkExistsWithTimeout = async (filePath, timeout) => {
|
||||
if (eventType === 'rename' && filename === basename) {
|
||||
clearTimeout(timer);
|
||||
if (watcher) watcher.close();
|
||||
resolve();
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -530,6 +530,40 @@ exports.getDirectoriesInDirectory = async (basePath) => {
|
||||
}
|
||||
}
|
||||
|
||||
exports.parseOutputJSON = (output, err) => {
|
||||
let split_output = [];
|
||||
// const output_jsons = [];
|
||||
if (err && !output) {
|
||||
if (!err.stderr.includes('This video is unavailable') && !err.stderr.includes('Private video')) {
|
||||
return null;
|
||||
}
|
||||
logger.info('An error was encountered with at least one video, backup method will be used.')
|
||||
try {
|
||||
split_output = err.stdout.split(/\r\n|\r|\n/);
|
||||
} catch (e) {
|
||||
logger.error('Backup method failed. See error below:');
|
||||
logger.error(e);
|
||||
return null;
|
||||
}
|
||||
} else if (output.length === 0 || (output.length === 1 && output[0].length === 0)) {
|
||||
// output is '' or ['']
|
||||
return [];
|
||||
} else {
|
||||
for (const output_item of output) {
|
||||
// we have to do this because sometimes there will be leading characters before the actual json
|
||||
const start_idx = output_item.indexOf('{"');
|
||||
const clean_output = output_item.slice(start_idx, output_item.length);
|
||||
split_output.push(clean_output);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return split_output.map(split_output_str => JSON.parse(split_output_str));
|
||||
} catch(e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// objects
|
||||
|
||||
function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date, description, view_count, height, abr) {
|
||||
|
||||
@@ -5,6 +5,7 @@ const logger = require('./logger');
|
||||
const utils = require('./utils');
|
||||
const CONSTS = require('./consts');
|
||||
const config_api = require('./config.js');
|
||||
const youtubedl = require('youtube-dl');
|
||||
|
||||
const OUTDATED_VERSION = "2020.00.00";
|
||||
|
||||
@@ -25,6 +26,15 @@ const download_sources = {
|
||||
}
|
||||
}
|
||||
|
||||
exports.runYoutubeDL = async (url, args, downloadMethod = youtubedl.exec) => {
|
||||
return new Promise(resolve => {
|
||||
downloadMethod(url, args, {maxBuffer: Infinity}, async function(err, output) {
|
||||
const parsed_output = utils.parseOutputJSON(output, err);
|
||||
resolve({parsed_output, err});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
exports.checkForYoutubeDLUpdate = async () => {
|
||||
return new Promise(async resolve => {
|
||||
const default_downloader = config_api.getConfigItem('ytdl_default_downloader');
|
||||
|
||||
@@ -21,4 +21,4 @@ version: 0.1.0
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||
# It is recommended to use it with quotes.
|
||||
appVersion: "4.3.1"
|
||||
appVersion: "4.3.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "youtube-dl-material",
|
||||
"version": "4.3.1",
|
||||
"version": "4.3.2",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
</mat-menu>
|
||||
<button [matMenuTriggerFor]="menuSettings" mat-icon-button><mat-icon>more_vert</mat-icon></button>
|
||||
<mat-menu #menuSettings="matMenu">
|
||||
<button class="top-menu-button" (click)="openProfileDialog()" *ngIf="postsService.isLoggedIn" mat-menu-item>
|
||||
<button class="top-menu-button" (click)="openProfileDialog()" mat-menu-item>
|
||||
<mat-icon>person</mat-icon>
|
||||
<span i18n="Profile menu label">Profile</span>
|
||||
</button>
|
||||
<button class="top-menu-button" (click)="openArchivesDialog()" mat-menu-item>
|
||||
<button *ngIf="!postsService.config?.Advanced.multi_user_mode || postsService.isLoggedIn" class="top-menu-button" (click)="openArchivesDialog()" mat-menu-item>
|
||||
<mat-icon>topic</mat-icon>
|
||||
<span i18n="Archives menu label">Archives</span>
|
||||
</button>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { DragDropModule } from '@angular/cdk/drag-drop';
|
||||
import { ClipboardModule } from '@angular/cdk/clipboard';
|
||||
import { TextFieldModule } from '@angular/cdk/text-field';
|
||||
import { ScrollingModule } from '@angular/cdk/scrolling';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { AppComponent } from './app.component';
|
||||
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
@@ -189,6 +190,7 @@ registerLocaleData(es, 'es');
|
||||
DragDropModule,
|
||||
ClipboardModule,
|
||||
TextFieldModule,
|
||||
ScrollingModule,
|
||||
NgxFileDropModule,
|
||||
AvatarModule,
|
||||
ContentLoaderModule,
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
<!-- Title Column -->
|
||||
<ng-container matColumnDef="title">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Title">Title</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header style="flex: 2"> <ng-container i18n="Title">Title</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element" style="flex: 2">
|
||||
<span class="one-line" [matTooltip]="element.title ? element.title : null">
|
||||
{{element.title}}
|
||||
</span>
|
||||
@@ -31,41 +31,47 @@
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<!-- Stage Column -->
|
||||
<ng-container matColumnDef="step_index">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Stage">Stage</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element"> {{STEP_INDEX_TO_LABEL[element.step_index]}} </mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<!-- Progress Column -->
|
||||
<ng-container matColumnDef="percent_complete">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Progress">Progress</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element">
|
||||
<ng-container *ngIf="!element.error && element.step_index !== 2">
|
||||
{{STEP_INDEX_TO_LABEL[element.step_index]}}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!element.error && element.step_index === 2">
|
||||
<ng-container *ngIf="element.percent_complete">
|
||||
{{+(element.percent_complete) > 100 ? '100' : element.percent_complete}}%
|
||||
{{+(element.percent_complete) > 100 ? '100' : element.percent_complete}}%
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!element.percent_complete">
|
||||
N/A
|
||||
N/A
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="element.error" i18n="Error">Error</ng-container>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<mat-header-cell *matHeaderCellDef> <ng-container i18n="Actions">Actions</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element">
|
||||
<div>
|
||||
<ng-container *ngIf="!element.finished">
|
||||
<button (click)="pauseDownload(element.uid)" *ngIf="!element.paused || !element.finished_step" [disabled]="element.paused && !element.finished_step" mat-icon-button matTooltip="Pause" i18n-matTooltip="Pause"><mat-spinner [diameter]="28" *ngIf="element.paused && !element.finished_step" class="icon-button-spinner"></mat-spinner><mat-icon>pause</mat-icon></button>
|
||||
<button (click)="resumeDownload(element.uid)" *ngIf="element.paused && element.finished_step" mat-icon-button matTooltip="Resume" i18n-matTooltip="Resume"><mat-icon>play_arrow</mat-icon></button>
|
||||
<button *ngIf="false && !element.paused" (click)="cancelDownload(element.uid)" mat-icon-button matTooltip="Cancel" i18n-matTooltip="Cancel"><mat-icon>cancel</mat-icon></button>
|
||||
<mat-header-cell *matHeaderCellDef [ngStyle]="{flex: actionsFlex}"> <ng-container i18n="Actions">Actions</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element" [ngStyle]="{flex: actionsFlex}">
|
||||
<div *ngIf="!minimizeButtons">
|
||||
<ng-container *ngFor="let downloadAction of downloadActions">
|
||||
<span class="button-span">
|
||||
<mat-spinner [diameter]="28" *ngIf="downloadAction.loading && downloadAction.loading(element)" class="icon-button-spinner"></mat-spinner>
|
||||
<button *ngIf="downloadAction.show(element)" (click)="downloadAction.action(element)" [disabled]="downloadAction.loading && downloadAction.loading(element)" [matTooltip]="downloadAction.tooltip" mat-icon-button><mat-icon>{{downloadAction.icon}}</mat-icon></button>
|
||||
</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="element.finished">
|
||||
<button *ngIf="!element.error" (click)="watchContent(element)" mat-icon-button matTooltip="Watch content" i18n-matTooltip="Watch content"><mat-icon>smart_display</mat-icon></button>
|
||||
<button *ngIf="element.error" (click)="showError(element)" mat-icon-button matTooltip="Show error" i18n-matTooltip="Show error"><mat-icon>warning</mat-icon></button>
|
||||
<button (click)="restartDownload(element.uid)" mat-icon-button matTooltip="Restart" i18n-matTooltip="Restart"><mat-icon>restart_alt</mat-icon></button>
|
||||
</ng-container>
|
||||
<button *ngIf="element.finished || element.paused" (click)="clearDownload(element.uid)" mat-icon-button matTooltip="Clear" i18n-matTooltip="Clear"><mat-icon>delete</mat-icon></button>
|
||||
</div>
|
||||
<div *ngIf="minimizeButtons">
|
||||
<button [matMenuTriggerFor]="download_actions" mat-icon-button><mat-icon>more_vert</mat-icon></button>
|
||||
<mat-menu #download_actions="matMenu">
|
||||
<ng-container *ngFor="let downloadAction of downloadActions">
|
||||
<button *ngIf="downloadAction.show(element)" (click)="downloadAction.action(element)" [disabled]="downloadAction.loading && downloadAction.loading(element)" mat-menu-item>
|
||||
<mat-icon>{{downloadAction.icon}}</mat-icon>
|
||||
<span>{{downloadAction.tooltip}}</span>
|
||||
</button>
|
||||
</ng-container>
|
||||
</mat-menu>
|
||||
</div>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
@@ -80,9 +86,9 @@
|
||||
</mat-paginator>
|
||||
</div>
|
||||
<div *ngIf="!uids" class="downloads-action-button-div">
|
||||
<button [disabled]="!running_download_exists" mat-stroked-button (click)="pauseAllDownloads()"><ng-container i18n="Pause all downloads">Pause all downloads</ng-container></button>
|
||||
<button style="margin-left: 10px;" [disabled]="!paused_download_exists" mat-stroked-button (click)="resumeAllDownloads()"><ng-container i18n="Resume all downloads">Resume all downloads</ng-container></button>
|
||||
<button color="warn" style="margin-left: 10px;" mat-stroked-button (click)="clearDownloadsByType()"><ng-container i18n="Clear downloads">Clear downloads</ng-container></button>
|
||||
<button class="downloads-action-button" [disabled]="!running_download_exists" mat-stroked-button (click)="pauseAllDownloads()"><ng-container i18n="Pause all downloads">Pause all downloads</ng-container></button>
|
||||
<button class="downloads-action-button" [disabled]="!paused_download_exists" mat-stroked-button (click)="resumeAllDownloads()"><ng-container i18n="Resume all downloads">Resume all downloads</ng-container></button>
|
||||
<button class="downloads-action-button" color="warn" mat-stroked-button (click)="clearDownloadsByType()"><ng-container i18n="Clear downloads">Clear downloads</ng-container></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,15 +10,23 @@ mat-header-cell, mat-cell {
|
||||
|
||||
.icon-button-spinner {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
left: 6px;
|
||||
top: -13px;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.button-span {
|
||||
position: relative;;
|
||||
}
|
||||
|
||||
.downloads-action-button-div {
|
||||
margin-top: 10px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.downloads-action-button {
|
||||
margin-top: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.rounded-top {
|
||||
border-radius: 16px 16px 0px 0px !important;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy, ViewChild, Input, EventEmitter } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, ViewChild, Input, EventEmitter, HostListener } from '@angular/core';
|
||||
import { PostsService } from 'app/posts.services';
|
||||
import { trigger, transition, animateChild, stagger, query, style, animate } from '@angular/animations';
|
||||
import { Router } from '@angular/router';
|
||||
@@ -13,31 +13,7 @@ import { Download } from 'api-types';
|
||||
@Component({
|
||||
selector: 'app-downloads',
|
||||
templateUrl: './downloads.component.html',
|
||||
styleUrls: ['./downloads.component.scss'],
|
||||
animations: [
|
||||
// nice stagger effect when showing existing elements
|
||||
trigger('list', [
|
||||
transition(':enter', [
|
||||
// child animation selector + stagger
|
||||
query('@items',
|
||||
stagger(100, animateChild()), { optional: true }
|
||||
)
|
||||
]),
|
||||
]),
|
||||
trigger('items', [
|
||||
// cubic-bezier for a tiny bouncing feel
|
||||
transition(':enter', [
|
||||
style({ transform: 'scale(0.5)', opacity: 0 }),
|
||||
animate('500ms cubic-bezier(.8,-0.6,0.2,1.5)',
|
||||
style({ transform: 'scale(1)', opacity: 1 }))
|
||||
]),
|
||||
transition(':leave', [
|
||||
style({ transform: 'scale(1)', opacity: 1, height: '*' }),
|
||||
animate('1s cubic-bezier(.8,-0.6,0.2,1.5)',
|
||||
style({ transform: 'scale(0.5)', opacity: 0, height: '0px', margin: '0px' }))
|
||||
]),
|
||||
])
|
||||
],
|
||||
styleUrls: ['./downloads.component.scss']
|
||||
})
|
||||
export class DownloadsComponent implements OnInit, OnDestroy {
|
||||
|
||||
@@ -62,13 +38,73 @@ export class DownloadsComponent implements OnInit, OnDestroy {
|
||||
3: $localize`Complete`
|
||||
}
|
||||
|
||||
displayedColumns: string[] = ['timestamp_start', 'title', 'step_index', 'sub_name', 'percent_complete', 'actions'];
|
||||
actionsFlex = 2;
|
||||
minimizeButtons = false;
|
||||
displayedColumnsBig: string[] = ['timestamp_start', 'title', 'sub_name', 'percent_complete', 'actions'];
|
||||
displayedColumnsSmall: string[] = ['title', 'percent_complete', 'actions'];
|
||||
displayedColumns: string[] = this.displayedColumnsBig;
|
||||
dataSource = null; // new MatTableDataSource<Download>();
|
||||
|
||||
// The purpose of this is to reduce code reuse for displaying these actions as icons or in a menu
|
||||
downloadActions: DownloadAction[] = [
|
||||
{
|
||||
tooltip: $localize`Watch content`,
|
||||
action: (download: Download) => this.watchContent(download),
|
||||
show: (download: Download) => download.finished && !download.error,
|
||||
icon: 'smart_display'
|
||||
},
|
||||
{
|
||||
tooltip: $localize`Show error`,
|
||||
action: (download: Download) => this.showError(download),
|
||||
show: (download: Download) => download.finished && !!download.error,
|
||||
icon: 'warning'
|
||||
},
|
||||
{
|
||||
tooltip: $localize`Restart`,
|
||||
action: (download: Download) => this.restartDownload(download),
|
||||
show: (download: Download) => download.finished,
|
||||
icon: 'restart_alt'
|
||||
},
|
||||
{
|
||||
tooltip: $localize`Pause`,
|
||||
action: (download: Download) => this.pauseDownload(download),
|
||||
show: (download: Download) => !download.finished && (!download.paused || !download.finished_step),
|
||||
icon: 'pause',
|
||||
loading: (download: Download) => download.paused && !download.finished_step
|
||||
},
|
||||
{
|
||||
tooltip: $localize`Resume`,
|
||||
action: (download: Download) => this.resumeDownload(download),
|
||||
show: (download: Download) => !download.finished && download.paused && download.finished_step,
|
||||
icon: 'play_arrow'
|
||||
},
|
||||
{
|
||||
tooltip: $localize`Cancel`,
|
||||
action: (download: Download) => this.cancelDownload(download),
|
||||
show: (download: Download) => false && !download.finished && !download.paused, // TODO: add possibility to cancel download
|
||||
icon: 'cancel'
|
||||
},
|
||||
{
|
||||
tooltip: $localize`Clear`,
|
||||
action: (download: Download) => this.clearDownload(download),
|
||||
show: (download: Download) => download.finished || download.paused,
|
||||
icon: 'delete'
|
||||
}
|
||||
]
|
||||
|
||||
downloads_retrieved = false;
|
||||
|
||||
innerWidth: number;
|
||||
|
||||
@ViewChild(MatPaginator) paginator: MatPaginator;
|
||||
@ViewChild(MatSort) sort: MatSort;
|
||||
|
||||
@HostListener('window:resize', ['$event'])
|
||||
onResize(): void {
|
||||
this.innerWidth = window.innerWidth;
|
||||
this.recalculateColumns();
|
||||
}
|
||||
|
||||
sort_downloads = (a: Download, b: Download): number => {
|
||||
const result = b.timestamp_start - a.timestamp_start;
|
||||
return result;
|
||||
@@ -77,6 +113,10 @@ export class DownloadsComponent implements OnInit, OnDestroy {
|
||||
constructor(public postsService: PostsService, private router: Router, private dialog: MatDialog, private clipboard: Clipboard) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
// Remove sub name as it's not necessary for one-off downloads
|
||||
if (this.uids) this.displayedColumnsBig = this.displayedColumnsBig.filter(col => col !== 'sub_name');
|
||||
this.innerWidth = window.innerWidth;
|
||||
this.recalculateColumns();
|
||||
if (this.postsService.initialized) {
|
||||
this.getCurrentDownloadsRecurring();
|
||||
} else {
|
||||
@@ -164,8 +204,8 @@ export class DownloadsComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
pauseDownload(download_uid: string): void {
|
||||
this.postsService.pauseDownload(download_uid).subscribe(res => {
|
||||
pauseDownload(download: Download): void {
|
||||
this.postsService.pauseDownload(download['uid']).subscribe(res => {
|
||||
if (!res['success']) {
|
||||
this.postsService.openSnackBar($localize`Failed to pause download! See server logs for more info.`);
|
||||
}
|
||||
@@ -180,8 +220,8 @@ export class DownloadsComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
resumeDownload(download_uid: string): void {
|
||||
this.postsService.resumeDownload(download_uid).subscribe(res => {
|
||||
resumeDownload(download: Download): void {
|
||||
this.postsService.resumeDownload(download['uid']).subscribe(res => {
|
||||
if (!res['success']) {
|
||||
this.postsService.openSnackBar($localize`Failed to resume download! See server logs for more info.`);
|
||||
}
|
||||
@@ -196,8 +236,8 @@ export class DownloadsComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
restartDownload(download_uid: string): void {
|
||||
this.postsService.restartDownload(download_uid).subscribe(res => {
|
||||
restartDownload(download: Download): void {
|
||||
this.postsService.restartDownload(download['uid']).subscribe(res => {
|
||||
if (!res['success']) {
|
||||
this.postsService.openSnackBar($localize`Failed to restart download! See server logs for more info.`);
|
||||
} else {
|
||||
@@ -208,16 +248,16 @@ export class DownloadsComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
cancelDownload(download_uid: string): void {
|
||||
this.postsService.cancelDownload(download_uid).subscribe(res => {
|
||||
cancelDownload(download: Download): void {
|
||||
this.postsService.cancelDownload(download['uid']).subscribe(res => {
|
||||
if (!res['success']) {
|
||||
this.postsService.openSnackBar($localize`Failed to cancel download! See server logs for more info.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearDownload(download_uid: string): void {
|
||||
this.postsService.clearDownload(download_uid).subscribe(res => {
|
||||
clearDownload(download: Download): void {
|
||||
this.postsService.clearDownload(download['uid']).subscribe(res => {
|
||||
if (!res['success']) {
|
||||
this.postsService.openSnackBar($localize`Failed to pause download! See server logs for more info.`);
|
||||
}
|
||||
@@ -257,6 +297,7 @@ export class DownloadsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
showError(download: Download): void {
|
||||
console.log(download)
|
||||
const copyToClipboardEmitter = new EventEmitter<boolean>();
|
||||
this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
@@ -276,4 +317,22 @@ export class DownloadsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
recalculateColumns() {
|
||||
if (this.innerWidth < 650) this.displayedColumns = this.displayedColumnsSmall;
|
||||
else this.displayedColumns = this.displayedColumnsBig;
|
||||
|
||||
this.actionsFlex = this.uids || this.innerWidth < 800 ? 1 : 2;
|
||||
|
||||
if (this.innerWidth < 800 && !this.uids || this.innerWidth < 1100 && this.uids) this.minimizeButtons = true;
|
||||
else this.minimizeButtons = false;
|
||||
}
|
||||
}
|
||||
|
||||
interface DownloadAction {
|
||||
tooltip: string,
|
||||
action: (download: Download) => void,
|
||||
show: (download: Download) => boolean,
|
||||
icon: string,
|
||||
loading?: (download: Download) => boolean
|
||||
}
|
||||
@@ -1,30 +1,32 @@
|
||||
<div class="card-radius mat-elevation-z2" *ngFor="let notification of notifications; let i = index;">
|
||||
<mat-card class="notification-card card-radius">
|
||||
<mat-card-header>
|
||||
<mat-card-subtitle>
|
||||
<div>
|
||||
<span class="notification-timestamp">{{notification.timestamp * 1000 | date:'short'}}</span>
|
||||
</div>
|
||||
</mat-card-subtitle>
|
||||
<mat-card-title>
|
||||
<ng-container *ngIf="NOTIFICATION_PREFIX[notification.type]">
|
||||
{{NOTIFICATION_PREFIX[notification.type]}}
|
||||
<cdk-virtual-scroll-viewport itemSize="50" class="viewport" minBufferPx="1200" maxBufferPx="1200">
|
||||
<div #notification_parent class="notification-card-parent card-radius mat-elevation-z2" *cdkVirtualFor="let notification of notifications; let i = index;">
|
||||
<mat-card class="notification-card card-radius">
|
||||
<mat-card-header>
|
||||
<mat-card-subtitle>
|
||||
<div>
|
||||
<span class="notification-timestamp">{{notification.timestamp * 1000 | date:'short'}}</span>
|
||||
</div>
|
||||
</mat-card-subtitle>
|
||||
<mat-card-title>
|
||||
<ng-container *ngIf="NOTIFICATION_PREFIX[notification.type]">
|
||||
{{NOTIFICATION_PREFIX[notification.type]}}
|
||||
</ng-container>
|
||||
</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<ng-container *ngIf="NOTIFICATION_SUFFIX_KEY[notification.type]">
|
||||
<div style="word-break: break-word">
|
||||
{{notification['data'][NOTIFICATION_SUFFIX_KEY[notification.type]]}}
|
||||
</div>
|
||||
</ng-container>
|
||||
</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<ng-container *ngIf="NOTIFICATION_SUFFIX_KEY[notification.type]">
|
||||
<div style="word-break: break-word">
|
||||
{{notification['data'][NOTIFICATION_SUFFIX_KEY[notification.type]]}}
|
||||
</div>
|
||||
</ng-container>
|
||||
</mat-card-content>
|
||||
<mat-card-actions *ngIf="notification.actions?.length > 0">
|
||||
<button matTooltip="Remove" i18n-matTooltip="Remove" (click)="emitDeleteNotification(notification.uid)" mat-icon-button><mat-icon>close</mat-icon></button>
|
||||
<span *ngFor="let action of notification.actions">
|
||||
<button [matTooltip]="NOTIFICATION_ACTION_TO_STRING[action]" (click)="emitNotificationAction(notification, action)" mat-icon-button><mat-icon>{{NOTIFICATION_ICON[action]}}</mat-icon></button>
|
||||
</span>
|
||||
</mat-card-actions>
|
||||
<span *ngIf="!notification.read" class="dot"></span>
|
||||
</mat-card>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
<mat-card-actions class="notification-actions" *ngIf="notification.actions?.length > 0">
|
||||
<button matTooltip="Remove" i18n-matTooltip="Remove" (click)="emitDeleteNotification(notification.uid)" mat-icon-button><mat-icon>close</mat-icon></button>
|
||||
<span *ngFor="let action of notification.actions">
|
||||
<button [matTooltip]="NOTIFICATION_ACTION_TO_STRING[action]" (click)="emitNotificationAction(notification, action)" mat-icon-button><mat-icon>{{NOTIFICATION_ICON[action]}}</mat-icon></button>
|
||||
</span>
|
||||
</mat-card-actions>
|
||||
<span *ngIf="!notification.read" class="dot"></span>
|
||||
</mat-card>
|
||||
</div>
|
||||
</cdk-virtual-scroll-viewport>
|
||||
@@ -13,12 +13,21 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.notification-card-parent {
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.notification-card {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.card-radius {
|
||||
border-radius: 12px;
|
||||
height: 166px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
@@ -30,4 +39,8 @@
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.viewport {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -4,7 +4,10 @@
|
||||
}
|
||||
|
||||
.notifications-list-parent {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
padding: 0px 10px 10px 10px;
|
||||
}
|
||||
|
||||
.notifications-list {
|
||||
display: block
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
<mat-chip-listbox [value]="selectedFilters" [multiple]="true" (change)="selectedFiltersChanged($event)">
|
||||
<mat-chip-option *ngFor="let filter of notificationFilters | keyvalue: originalOrder" [value]="filter.key" [selected]="selectedFilters.includes(filter.key)" color="accent">{{filter.value.label}}</mat-chip-option>
|
||||
</mat-chip-listbox>
|
||||
<app-notifications-list (notificationAction)="notificationAction($event)" (deleteNotification)="deleteNotification($event)" [notifications]="filtered_notifications"></app-notifications-list>
|
||||
<app-notifications-list class="notifications-list" [style.height]="list_height" (notificationAction)="notificationAction($event)" (deleteNotification)="deleteNotification($event)" [notifications]="filtered_notifications"></app-notifications-list>
|
||||
</div>
|
||||
<button style="margin: 10px 0px 2px 10px;" *ngIf="notifications?.length > 0" color="warn" (click)="deleteAllNotifications()" mat-stroked-button>Remove all</button>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ export class NotificationsComponent implements OnInit {
|
||||
|
||||
notifications: Notification[] = null;
|
||||
filtered_notifications: Notification[] = null;
|
||||
list_height = '65vh';
|
||||
|
||||
@Output() notificationCount = new EventEmitter<number>();
|
||||
|
||||
@@ -110,6 +111,8 @@ export class NotificationsComponent implements OnInit {
|
||||
|
||||
filterNotifications(): void {
|
||||
this.filtered_notifications = this.notifications.filter(notification => this.selectedFilters.length === 0 || this.selectedFilters.includes(notification.type));
|
||||
// We need to do this to get the virtual scroll component to have an appropriate height
|
||||
this.calculateListHeight();
|
||||
}
|
||||
|
||||
selectedFiltersChanged(event: MatChipListboxChange): void {
|
||||
@@ -117,6 +120,12 @@ export class NotificationsComponent implements OnInit {
|
||||
this.filterNotifications();
|
||||
}
|
||||
|
||||
calculateListHeight() {
|
||||
const avgHeight = 166;
|
||||
const calcHeight = this.filtered_notifications.length * avgHeight;
|
||||
this.list_height = calcHeight > window.innerHeight*0.65 ? '65vh' : `${calcHeight}px`;
|
||||
}
|
||||
|
||||
originalOrder = (): number => {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const CURRENT_VERSION = 'v4.3.1';
|
||||
export const CURRENT_VERSION = 'v4.3.2';
|
||||
|
||||
@@ -35,36 +35,6 @@
|
||||
<p>
|
||||
<ng-container i18n="About bug prefix">Found a bug or have a suggestion?</ng-container> <a [href]="issuesLink" target="_blank"><ng-container i18n="About bug click here">Click here</ng-container></a> <ng-container i18n="About bug suffix">to create an issue!</ng-container>
|
||||
</p>
|
||||
<mat-divider></mat-divider>
|
||||
<div style="margin-top: 10px;">
|
||||
<h5>Personal settings:</h5>
|
||||
<mat-form-field>
|
||||
<mat-label i18n="Sidepanel mode">Sidepanel mode</mat-label>
|
||||
<mat-select [(ngModel)]="sidepanel_mode" (selectionChange)="sidePanelModeChanged($event.value)">
|
||||
<mat-option value="over">
|
||||
Over
|
||||
</mat-option>
|
||||
<mat-option value="side">
|
||||
Side
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<br/>
|
||||
<mat-form-field>
|
||||
<mat-label i18n="File card size">File card size</mat-label>
|
||||
<mat-select [(ngModel)]="card_size" (selectionChange)="cardSizeOptionChanged($event.value)">
|
||||
<mat-option value="large">
|
||||
Large
|
||||
</mat-option>
|
||||
<mat-option value="medium">
|
||||
Medium
|
||||
</mat-option>
|
||||
<mat-option value="small">
|
||||
Small
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ export class AboutDialogComponent implements OnInit {
|
||||
checking_for_updates = true;
|
||||
|
||||
current_version_tag = CURRENT_VERSION;
|
||||
sidepanel_mode = this.postsService.sidepanel_mode;
|
||||
card_size = this.postsService.card_size;
|
||||
|
||||
constructor(public postsService: PostsService) { }
|
||||
|
||||
@@ -31,15 +29,4 @@ export class AboutDialogComponent implements OnInit {
|
||||
this.latestGithubRelease = res;
|
||||
});
|
||||
}
|
||||
|
||||
sidePanelModeChanged(new_mode) {
|
||||
localStorage.setItem('sidepanel_mode', new_mode);
|
||||
this.postsService.sidepanel_mode = new_mode;
|
||||
}
|
||||
|
||||
cardSizeOptionChanged(new_size) {
|
||||
localStorage.setItem('card_size', new_size);
|
||||
this.postsService.card_size = new_size;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -13,19 +13,52 @@
|
||||
</div>
|
||||
<div style="margin-top: 20px;">
|
||||
</div>
|
||||
<mat-divider style="margin-bottom: 20px"></mat-divider>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!postsService.isLoggedIn || !postsService.user">
|
||||
<h5><mat-icon>warn</mat-icon><ng-container i18n="Not logged in notification">You are not logged in.</ng-container></h5>
|
||||
<button (click)="loginClicked()" mat-raised-button color="primary"><ng-container i18n="Login">Login</ng-container></button>
|
||||
</div>
|
||||
<mat-form-field color="accent">
|
||||
<mat-label><ng-container i18n="Language select label">Language</ng-container></mat-label>
|
||||
<mat-select (selectionChange)="localeSelectChanged($event.value)" [(value)]="initialLocale">
|
||||
<mat-option *ngFor="let locale of supported_locales" [value]="locale">
|
||||
<ng-container *ngIf="all_locales[locale]">
|
||||
{{all_locales[locale]['nativeName']}}
|
||||
</ng-container>
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<br/>
|
||||
<mat-form-field>
|
||||
<mat-label i18n="Sidepanel mode">Sidepanel mode</mat-label>
|
||||
<mat-select [(ngModel)]="sidepanel_mode" (selectionChange)="sidePanelModeChanged($event.value)">
|
||||
<mat-option i18n="Over" value="over">
|
||||
Over
|
||||
</mat-option>
|
||||
<mat-option i18n="Side" value="side">
|
||||
Side
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<br/>
|
||||
<mat-form-field>
|
||||
<mat-label i18n="File card size">File card size</mat-label>
|
||||
<mat-select [(ngModel)]="card_size" (selectionChange)="cardSizeOptionChanged($event.value)">
|
||||
<mat-option i18n="Large" value="large">
|
||||
Large
|
||||
</mat-option>
|
||||
<mat-option i18n="Medium" value="medium">
|
||||
Medium
|
||||
</mat-option>
|
||||
<mat-option i18n="Small" value="small">
|
||||
Small
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions>
|
||||
<div style="width: 100%">
|
||||
<div style="position: relative">
|
||||
<button mat-stroked-button mat-dialog-close color="primary"><ng-container i18n="Close">Close</ng-container></button>
|
||||
<button style="position: absolute; right: 0px;" (click)="logoutClicked()" mat-stroked-button color="warn"><ng-container i18n="Logout">Logout</ng-container></button>
|
||||
<button *ngIf="postsService.isLoggedIn" style="position: absolute; right: 0px;" (click)="logoutClicked()" mat-stroked-button color="warn"><ng-container i18n="Logout">Logout</ng-container></button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-dialog-actions>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core';
|
||||
import { PostsService } from 'app/posts.services';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatDialogRef } from '@angular/material/dialog';
|
||||
import { isoLangs } from './locales_list';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-profile-dialog',
|
||||
@@ -10,9 +11,24 @@ import { MatDialogRef } from '@angular/material/dialog';
|
||||
})
|
||||
export class UserProfileDialogComponent implements OnInit {
|
||||
|
||||
all_locales = isoLangs;
|
||||
supported_locales = ['en', 'es', 'de', 'fr', 'nl', 'pt', 'it', 'ca', 'cs', 'nb', 'ru', 'zh', 'ko', 'id', 'en-GB'];
|
||||
initialLocale = localStorage.getItem('locale');
|
||||
sidepanel_mode = this.postsService.sidepanel_mode;
|
||||
card_size = this.postsService.card_size;
|
||||
|
||||
constructor(public postsService: PostsService, private router: Router, public dialogRef: MatDialogRef<UserProfileDialogComponent>) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.postsService.getSupportedLocales().subscribe(res => {
|
||||
if (res && res['supported_locales']) {
|
||||
this.supported_locales = ['en', 'en-GB']; // required
|
||||
this.supported_locales = this.supported_locales.concat(res['supported_locales']);
|
||||
}
|
||||
}, err => {
|
||||
console.error(`Failed to retrieve list of supported languages! You may need to run: 'node src/postbuild.mjs'. Error below:`);
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
loginClicked() {
|
||||
@@ -25,4 +41,19 @@ export class UserProfileDialogComponent implements OnInit {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
localeSelectChanged(new_val: string): void {
|
||||
localStorage.setItem('locale', new_val);
|
||||
this.postsService.openSnackBar($localize`Language successfully changed! Reload to update the page.`)
|
||||
}
|
||||
|
||||
sidePanelModeChanged(new_mode) {
|
||||
localStorage.setItem('sidepanel_mode', new_mode);
|
||||
this.postsService.sidepanel_mode = new_mode;
|
||||
}
|
||||
|
||||
cardSizeOptionChanged(new_size) {
|
||||
localStorage.setItem('card_size', new_size);
|
||||
this.postsService.card_size = new_size;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: center;" *ngIf="downloads && downloads.length > 0 && !autoplay">
|
||||
<app-downloads style="width: 80%; margin-bottom: 10px" [uids]="download_uids"></app-downloads>
|
||||
<app-downloads style="width: 80%; min-width: 350px; margin-bottom: 10px" [uids]="download_uids"></app-downloads>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="cachedFileManagerEnabled || fileManagerEnabled">
|
||||
|
||||
@@ -37,11 +37,15 @@
|
||||
}
|
||||
|
||||
.spinner {
|
||||
bottom: 1px;
|
||||
left: 2px;
|
||||
bottom: -2px;
|
||||
left: 6px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
right: 25px;
|
||||
position: fixed;
|
||||
@@ -85,13 +89,6 @@
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.spinner-div {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-right: 12px;
|
||||
top: 8px;
|
||||
}
|
||||
|
||||
.skip-ad-button {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
|
||||
@@ -22,20 +22,22 @@
|
||||
</p>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!db_file || !db_file['description']">
|
||||
<p style="text-align: center;">
|
||||
<p i18n="No description" style="text-align: center;">
|
||||
No description available.
|
||||
</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<ng-container *ngIf="db_playlist">
|
||||
<button (click)="downloadContent()" [disabled]="downloading" mat-icon-button><mat-icon>save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="35"></mat-spinner></button>
|
||||
<span class="buttons" *ngIf="db_playlist">
|
||||
<button (click)="downloadContent()" [disabled]="downloading" mat-icon-button><mat-icon>save</mat-icon></button>
|
||||
<mat-spinner *ngIf="downloading" class="spinner" [diameter]="35"></mat-spinner>
|
||||
<button *ngIf="(!postsService.isLoggedIn || postsService.permissions.includes('sharing')) && !auto" (click)="openShareDialog()" mat-icon-button><mat-icon>share</mat-icon></button>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="db_file">
|
||||
<button (click)="downloadFile()" [disabled]="downloading" mat-icon-button><mat-icon>cloud_download</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="35"></mat-spinner></button>
|
||||
</span>
|
||||
<span class="buttons" *ngIf="db_file">
|
||||
<button (click)="downloadFile()" [disabled]="downloading" mat-icon-button><mat-icon>cloud_download</mat-icon></button>
|
||||
<mat-spinner *ngIf="downloading" class="spinner" [diameter]="35"></mat-spinner>
|
||||
<button *ngIf="!postsService.isLoggedIn || postsService.permissions.includes('sharing')" (click)="openShareDialog()" mat-icon-button><mat-icon>share</mat-icon></button>
|
||||
</ng-container>
|
||||
</span>
|
||||
<ng-container *ngIf="db_file || playlist[currentIndex]"></ng-container>
|
||||
<button (click)="openFileInfoDialog()" *ngIf="db_file || db_playlist" mat-icon-button><mat-icon>info</mat-icon></button>
|
||||
<button *ngIf="db_file && db_file.url.includes('twitch.tv')" (click)="drawer.toggle()" mat-icon-button><mat-icon>chat</mat-icon></button>
|
||||
@@ -56,14 +58,6 @@
|
||||
<app-twitch-chat #twitchchat [db_file]="db_file" [current_timestamp]="api.currentTime" [sub]="subscription"></app-twitch-chat>
|
||||
</ng-container>
|
||||
</mat-drawer>
|
||||
|
||||
<!-- <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> <mat-icon>update</mat-icon></button>
|
||||
|
||||
</div> -->
|
||||
</mat-drawer-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,8 +61,6 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
url = null;
|
||||
name = null;
|
||||
|
||||
innerWidth: number;
|
||||
|
||||
downloading = false;
|
||||
|
||||
save_volume_timer = null;
|
||||
@@ -70,14 +68,7 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
@ViewChild('twitchchat') twitchChat: TwitchChatComponent;
|
||||
|
||||
@HostListener('window:resize', ['$event'])
|
||||
onResize(): void {
|
||||
this.innerWidth = window.innerWidth;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.innerWidth = window.innerWidth;
|
||||
|
||||
this.playlist_id = this.route.snapshot.paramMap.get('playlist_id');
|
||||
this.uid = this.route.snapshot.paramMap.get('uid');
|
||||
this.sub_id = this.route.snapshot.paramMap.get('sub_id');
|
||||
|
||||
@@ -115,7 +115,7 @@ import {
|
||||
RestartDownloadResponse,
|
||||
TaskType
|
||||
} from '../api-types';
|
||||
import { isoLangs } from './settings/locales_list';
|
||||
import { isoLangs } from './dialogs/user-profile-dialog/locales_list';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { MatDrawerMode } from '@angular/material/sidenav';
|
||||
|
||||
|
||||
@@ -78,23 +78,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<mat-divider></mat-divider>
|
||||
<div *ngIf="new_config" class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 mt-3">
|
||||
<mat-form-field color="accent">
|
||||
<mat-label><ng-container i18n="Language select label">Language</ng-container></mat-label>
|
||||
<mat-select (selectionChange)="localeSelectChanged($event.value)" [(value)]="initialLocale">
|
||||
<mat-option *ngFor="let locale of supported_locales" [value]="locale">
|
||||
<ng-container *ngIf="all_locales[locale]">
|
||||
{{all_locales[locale]['nativeName']}}
|
||||
</ng-container>
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
<!-- Downloader -->
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Component, OnInit, EventEmitter } from '@angular/core';
|
||||
import { PostsService } from 'app/posts.services';
|
||||
import { isoLangs } from './locales_list';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import {DomSanitizer} from '@angular/platform-browser';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
@@ -22,10 +21,6 @@ import { GenerateRssUrlComponent } from 'app/dialogs/generate-rss-url/generate-r
|
||||
styleUrls: ['./settings.component.scss']
|
||||
})
|
||||
export class SettingsComponent implements OnInit {
|
||||
all_locales = isoLangs;
|
||||
supported_locales = ['en', 'es', 'de', 'fr', 'nl', 'pt', 'it', 'ca', 'cs', 'nb', 'ru', 'zh', 'ko', 'id', 'en-GB'];
|
||||
initialLocale = localStorage.getItem('locale');
|
||||
|
||||
initial_config = null;
|
||||
new_config = null
|
||||
loading_config = false;
|
||||
@@ -83,16 +78,6 @@ export class SettingsComponent implements OnInit {
|
||||
|
||||
const tab = this.route.snapshot.paramMap.get('tab');
|
||||
this.tabIndex = tab && this.TAB_TO_INDEX[tab] ? this.TAB_TO_INDEX[tab] : 0;
|
||||
|
||||
this.postsService.getSupportedLocales().subscribe(res => {
|
||||
if (res && res['supported_locales']) {
|
||||
this.supported_locales = ['en', 'en-GB']; // required
|
||||
this.supported_locales = this.supported_locales.concat(res['supported_locales']);
|
||||
}
|
||||
}, err => {
|
||||
console.error(`Failed to retrieve list of supported languages! You may need to run: 'node src/postbuild.mjs'. Error below:`);
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
getConfig(): void {
|
||||
@@ -207,11 +192,6 @@ export class SettingsComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
localeSelectChanged(new_val: string): void {
|
||||
localStorage.setItem('locale', new_val);
|
||||
this.postsService.openSnackBar($localize`Language successfully changed! Reload to update the page.`)
|
||||
}
|
||||
|
||||
generateBookmarklet(): void {
|
||||
this.bookmarksite('YTDL-Material', this.generated_bookmarklet_code);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1432,7 +1432,7 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="994363f08f9fbfa3b3994ff7b35c6904fdff18d8" datatype="html">
|
||||
<source>Profile</source>
|
||||
<target state="translated">资料</target>
|
||||
<target state="translated">个人资料</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">app/app.component.html</context>
|
||||
<context context-type="linenumber">19</context>
|
||||
@@ -5015,6 +5015,24 @@
|
||||
</context-group>
|
||||
<note priority="1" from="description">Use gotify API setting</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="7cedb649779673568447b994463b2882c4e0436a" datatype="html">
|
||||
<source>Slack Webhook URL</source>
|
||||
<target state="translated">Slack Webhook 网址</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
|
||||
<context context-type="linenumber">397</context>
|
||||
</context-group>
|
||||
<note priority="1" from="description">Slack Webhook URL</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="3264d82792954815be755b3da01e2625458711dc" datatype="html">
|
||||
<source>Discord Webhook URL</source>
|
||||
<target state="translated">Discord Webhook 网址</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
|
||||
<context context-type="linenumber">390</context>
|
||||
</context-group>
|
||||
<note priority="1" from="description">Discord Webhook URL</note>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
Reference in New Issue
Block a user