Compare commits

..

23 Commits

Author SHA1 Message Date
Isaac Abadi
be71a9bd8c Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into test-improvements 2023-05-28 18:16:44 -04:00
Isaac Abadi
42c600cea9 Improved and fixed tests
Skipped tests just meant for manual testing

mocha now used prod mode

Added automated tests for PRs

Fixed backupdb and youtube-dl tests in gh actions

Fixed downloader tests

Removed erroneous logs in tests

Updated mocha.yml

Removed last console.log in tests

Increased timeout for download video test

Automated tests now force stop after tests complete

Replaced download test url with shorter video

Skip download test... for now

increased download test threshold

added more timing information to tests

unskip download test

Bump docker/setup-buildx-action from 1 to 2 in /.github/workflows

Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 1 to 2.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v1...v2)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Bump actions/setup-node from 2 to 3 in /.github/workflows

Bumps [actions/setup-node](https://github.com/actions/setup-node) from 2 to 3.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Bump actions/upload-artifact from 1 to 3 in /.github/workflows

Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 1 to 3.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v1...v3)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Bump docker/login-action from 1 to 2 in /.github/workflows

Bumps [docker/login-action](https://github.com/docker/login-action) from 1 to 2.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v1...v2)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Removed additional download button for paused download

Combined output json processing from youtube-dl between subscriptions and one-off downloads

Added workaround for missing video in playlist to one-off downloads

Fixed issue of case where output is empty

Download video code cleanup

Fixed download video test by overriding youtube-dl download method with custom method

Adds ffmpeg to automated tests

Removed test mp3/mp4 file from repo
2023-05-28 18:02:03 -04:00
Tzahi12345
0427f91cfc Merge pull request #935 from Tzahi12345/downloader-ui-fix
Downloader fixes
2023-05-28 14:03:53 -04:00
Tzahi12345
51552b3092 Merge pull request #791 from Tzahi12345/dependabot/github_actions/dot-github/workflows/docker/login-action-2
Bump docker/login-action from 1 to 2 in /.github/workflows
2023-05-28 10:52:36 -04:00
Tzahi12345
1a7ca0343a Merge pull request #792 from Tzahi12345/dependabot/github_actions/dot-github/workflows/actions/upload-artifact-3
Bump actions/upload-artifact from 1 to 3 in /.github/workflows
2023-05-28 10:52:14 -04:00
Tzahi12345
525e8e04e8 Merge pull request #790 from Tzahi12345/dependabot/github_actions/dot-github/workflows/actions/setup-node-3
Bump actions/setup-node from 2 to 3 in /.github/workflows
2023-05-28 10:51:42 -04:00
Tzahi12345
5a824cee82 Merge pull request #793 from Tzahi12345/dependabot/github_actions/dot-github/workflows/docker/setup-buildx-action-2
Bump docker/setup-buildx-action from 1 to 2 in /.github/workflows
2023-05-28 10:51:11 -04:00
Tzahi12345
13a03a722c Subscription metadata fix (#934)
* Fixed issue where metadata was attempting to be written to a dir that didn't exist yet
* Fixed issue where sub was assumed as duplicate always if using local db
2023-05-27 02:38:46 -04:00
Isaac Abadi
f7d3835111 Fixed issue of case where output is empty 2023-05-27 01:49:32 -04:00
Isaac Abadi
8212acbac6 Combined output json processing from youtube-dl between subscriptions and one-off downloads
Added workaround for missing video in playlist to one-off downloads
2023-05-27 01:39:40 -04:00
Isaac Abadi
2a1af69f1f Removed additional download button for paused download 2023-05-27 00:57:27 -04:00
Tzahi12345
f23ca61dab Fixed missing version upgrade 2023-05-25 22:24:11 -04:00
Tzahi12345
6eadb37532 4.3.2 release (#929)
* Upgraded version to 4.3.2
* Prevent null userid/username registration
2023-05-25 22:06:07 -04:00
Tzahi12345
2c61260e0f UI updates (#922)
* Fixed download spinner in player component

* Downloads UI is more mobile friendly (#905)

* Code cleanup

* Fixed size of actions in home screen downloads

* Errored downloads now display their stage as "Error" in the UI

* Moved personal settings from about dialog to profile dialog

* Profile dialog can now be opened without logging in/without multi-user mode

* Fixed issue where archive dialog could be accessed from anywhere

* Misc internationalization improvements

* Combined download stage and download progress columns

* Added back loading spinner to download actions

* Adjusted thresholds for consolidating download action buttons

* Implemented virtual scrolling for notifications (helps if many notifications exist)

* Fixed minor console error
2023-05-25 21:55:35 -04:00
Dedy Martadinata S
ba0de7f95c Fix entrypoint.sh (#921)
* Revert https://github.com/Tzahi12345/YoutubeDL-Material/pull/864

add && make it err and skipping perm check

* Restore armv7 nightly

* Update entrypoint.sh

* simplify, always setup permission regardless uid
2023-05-25 21:54:54 -04:00
Weblate (bot)
3dfdbcb151 Translated using Weblate (Chinese (Simplified)) (#925)
Currently translated at 100.0% (478 of 478 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/zh_Hans/

Co-authored-by: yangyangdaji <1504305527@qq.com>
2023-05-25 21:54:04 -04:00
Tzahi12345
c207e56855 Rebuild database task (#900)
* Improved tests for multi-user mode
* Adds task to rebuild database
* Updated subscriptions.js export syntax
* Subscription metadata is now backed up
* Added typing to task key
* Updated api models
* Tasks actions styling update
2023-05-23 22:37:25 -04:00
Tzahi12345
441131e930 Merge pull request #917 from Tzahi12345/archive-count-fix
Subscription temp archive fix
2023-05-23 22:35:49 -04:00
Isaac Abadi
7447ca038a Fixed issue where empty temp archive files would get generated 2023-05-18 21:22:20 -04:00
dependabot[bot]
c5d1b3ffcf Bump docker/setup-buildx-action from 1 to 2 in /.github/workflows
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 1 to 2.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v1...v2)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-23 11:10:06 +00:00
dependabot[bot]
c64140b605 Bump actions/upload-artifact from 1 to 3 in /.github/workflows
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 1 to 3.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v1...v3)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-23 11:10:04 +00:00
dependabot[bot]
6579f2b59e Bump docker/login-action from 1 to 2 in /.github/workflows
Bumps [docker/login-action](https://github.com/docker/login-action) from 1 to 2.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v1...v2)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-23 11:10:00 +00:00
dependabot[bot]
9f5b584593 Bump actions/setup-node from 2 to 3 in /.github/workflows
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 2 to 3.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-23 11:09:57 +00:00
43 changed files with 995 additions and 807 deletions

View File

@@ -80,7 +80,7 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64/v8 platforms: linux/amd64,linux/arm64/v8,linux/arm/v7
push: true push: true
tags: ${{ steps.docker-meta.outputs.tags }} tags: ${{ steps.docker-meta.outputs.tags }}
labels: ${{ steps.docker-meta.outputs.labels }} labels: ${{ steps.docker-meta.outputs.labels }}

40
.github/workflows/mocha.yml vendored Normal file
View 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

View File

@@ -3,6 +3,6 @@
"mochaExplorer.cwd": "backend", "mochaExplorer.cwd": "backend",
"mochaExplorer.globImplementation": "vscode", "mochaExplorer.globImplementation": "vscode",
"mochaExplorer.env": { "mochaExplorer.env": {
"YTDL_MODE": "debug" // "YTDL_MODE": "debug"
} }
} }

View File

@@ -1945,6 +1945,10 @@ app.post('/api/auth/register', optionalJwt, async (req, res) => {
res.sendStatus(409); res.sendStatus(409);
return; 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); const new_user = await auth_api.registerUser(userid, username, plaintextPassword);

View File

@@ -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.ICON_URL = 'https://i.imgur.com/IKOlr0N.png';
exports.CURRENT_VERSION = 'v4.3.1'; exports.CURRENT_VERSION = 'v4.3.2';

View File

@@ -11,9 +11,8 @@ const logger = require('./logger');
const low = require('lowdb') const low = require('lowdb')
const FileSync = require('lowdb/adapters/FileSync'); const FileSync = require('lowdb/adapters/FileSync');
const { BehaviorSubject } = require('rxjs'); 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; let database = null;
exports.database_initialized = false; exports.database_initialized = false;
exports.database_initialized_bs = new BehaviorSubject(false); exports.database_initialized_bs = new BehaviorSubject(false);
@@ -73,10 +72,6 @@ const tables = {
const tables_list = Object.keys(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; let using_local_db = null;
function setDB(input_db, input_users_db) { 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.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); setDB(input_db, input_users_db);
// must be done here to prevent getConfigItem from being called before init // must be done here to prevent getConfigItem from being called before init
using_local_db = config_api.getConfigItem('ytdl_use_local_db'); 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) => { exports.connectToDB = async (retries = 5, no_fallback = false, custom_connection_string = null) => {

View File

@@ -7,6 +7,7 @@ const Mutex = require('async-mutex').Mutex;
const youtubedl = require('youtube-dl'); const youtubedl = require('youtube-dl');
const logger = require('./logger'); const logger = require('./logger');
const youtubedl_api = require('./youtube-dl');
const config_api = require('./config'); const config_api = require('./config');
const twitch_api = require('./twitch'); const twitch_api = require('./twitch');
const { create } = require('xmlbuilder2'); const { create } = require('xmlbuilder2');
@@ -21,10 +22,10 @@ const mutex = new Mutex();
let should_check_downloads = true; let should_check_downloads = true;
if (db_api.database_initialized) { if (db_api.database_initialized) {
setupDownloads(); exports.setupDownloads();
} else { } else {
db_api.database_initialized_bs.subscribe(init => { 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 () => { return await mutex.runExclusive(async () => {
const download = { const download = {
url: url, url: url,
@@ -60,7 +61,7 @@ exports.createDownload = async (url, type, options, user_uid = null, sub_id = nu
options: options, options: options,
uid: uuid(), uid: uuid(),
step_index: 0, step_index: 0,
paused: false, paused: paused,
running: false, running: false,
finished_step: true, finished_step: true,
error: null, 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}); 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(); await fixDownloadState();
setInterval(checkDownloads, 1000); setInterval(checkDownloads, 1000);
} }
@@ -188,7 +189,7 @@ async function checkDownloads() {
if (waiting_download['step_index'] === 0) { if (waiting_download['step_index'] === 0) {
collectInfo(waiting_download['uid']); collectInfo(waiting_download['uid']);
} else if (waiting_download['step_index'] === 1) { } 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; let category = null;
// check if it fits into a category. If so, then get info again using new args // 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 // 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']) { if (category && category['custom_output']) {
@@ -259,13 +260,9 @@ async function collectInfo(download_uid) {
const files_to_check_for_progress = []; const files_to_check_for_progress = [];
// store info in download for future use // 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']));
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']));
}
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, await db_api.updateRecord('download_queue', {uid: download_uid}, {args: args,
finished_step: true, finished_step: true,
running: false, 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}); const download = await db_api.getRecord('download_queue', {uid: download_uid});
if (download['paused']) { if (download['paused']) {
return; return;
@@ -306,114 +303,102 @@ async function downloadQueuedFile(download_uid) {
const start_time = Date.now(); const start_time = Date.now();
const download_checker = setInterval(() => checkDownloadPercent(download['uid']), 1000); const download_checker = setInterval(() => checkDownloadPercent(download['uid']), 1000);
const file_objs = [];
// download file // download file
youtubedl.exec(url, args, {maxBuffer: Infinity}, async function(err, output) { const {parsed_output, err} = await youtubedl_api.runYoutubeDL(url, args, downloadMethod);
const file_objs = []; clearInterval(download_checker);
let end_time = Date.now(); let end_time = Date.now();
let difference = (end_time - start_time)/1000; let difference = (end_time - start_time)/1000;
logger.debug(`${type === 'audio' ? 'Audio' : 'Video'} download delay: ${difference} seconds.`); logger.debug(`${type === 'audio' ? 'Audio' : 'Video'} download delay: ${difference} seconds.`);
clearInterval(download_checker); if (!parsed_output) {
if (err) { logger.error(err.stderr);
logger.error(err.stderr); await handleDownloadError(download, err.stderr, 'unknown_error');
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); resolve(false);
return; 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'); new_args.push('--dump-json');
youtubedl.exec(url, new_args, {maxBuffer: Infinity}, async (err, output) => { youtubedl.exec(url, new_args, {maxBuffer: Infinity}, async (err, output) => {
if (output) { const parsed_output = utils.parseOutputJSON(output, err);
let outputs = []; if (parsed_output) {
try { resolve(parsed_output);
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);
}
} else { } else {
let error_message = `Error while retrieving info on video with URL ${url} with the following message: ${err}`; 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}`; if (err.stderr) error_message += `\n\n${err.stderr}`;

View File

@@ -1,17 +1,7 @@
#!/bin/sh #!/bin/bash
set -eu 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 # chown current working directory to current user
if [ "$*" = "$CMD" ] && [ "$(id -u)" = "0" ]; then echo "[entrypoint] setup permission, this may take a while"
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." 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" "$0" "$@" exec gosu "$UID:$GID" "$@"
fi
exec "$@"

View File

@@ -4,7 +4,7 @@
"description": "backend for YoutubeDL-Material", "description": "backend for YoutubeDL-Material",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "mocha test --exit -s 1000",
"start": "pm2-runtime --raw pm2.config.js", "start": "pm2-runtime --raw pm2.config.js",
"debug": "set YTDL_MODE=debug && node app.js" "debug": "set YTDL_MODE=debug && node app.js"
}, },

View File

@@ -33,7 +33,7 @@ exports.subscribe = async (sub, user_uid = null, skip_get_info = false) => {
} }
sub['user_uid'] = user_uid ? user_uid : undefined; 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); let success = skip_get_info ? true : await getSubscriptionInfo(sub);
exports.writeSubscriptionMetadata(sub); exports.writeSubscriptionMetadata(sub);
@@ -254,26 +254,15 @@ exports.getVideosForSub = async (sub, user_uid = null) => {
} }
logger.verbose('Subscription: finished check for ' + sub.name); logger.verbose('Subscription: finished check for ' + sub.name);
if (err && !output) { const parsed_output = utils.parseOutputJSON(output, err);
logger.error(err.stderr ? err.stderr : err.message); if (!parsed_output) {
if (err.stderr.includes('This video is unavailable') || err.stderr.includes('Private video')) { logger.error('Subscription check failed!');
logger.info('An error was encountered with at least one video, backup method will be used.') resolve(null);
try { return;
const outputs = err.stdout.split(/\r\n|\r|\n/); }
const files_to_download = await handleOutputJSON(outputs, sub, user_uid); const files_to_download = await handleOutputJSON(parsed_output, sub, user_uid);
resolve(files_to_download); resolve(files_to_download);
} catch(e) { return;
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);
}
}); });
}, err => { }, err => {
logger.error(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')) { if (config_api.getConfigItem('ytdl_subscriptions_redownload_fresh_uploads')) {
await setFreshUploads(sub, user_uid); await setFreshUploads(sub, user_uid);
checkVideosForFreshUploads(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); logger.verbose('No additional videos to download for ' + sub.name);
return []; 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 files_to_download = await getFilesToDownload(sub, output_jsons);
const base_download_options = exports.generateOptionsForSubscriptionDownload(sub, user_uid); const base_download_options = exports.generateOptionsForSubscriptionDownload(sub, user_uid);
for (let j = 0; j < files_to_download.length; j++) { for (let j = 0; j < files_to_download.length; j++) {
const file_to_download = files_to_download[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 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; 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) // 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); 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_count = archive_text.split('\n').length - 1;
const archive_path = path.join(appendedBasePath, 'archive.txt'); if (archive_count > 0) {
await fs.writeFile(archive_path, archive_text); logger.verbose(`Generating temporary archive file for subscription ${sub.name} with ${archive_count} entries.`)
downloadConfig.push('--download-archive', archive_path); 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) { if (sub.custom_args) {
const customArgsArray = sub.custom_args.split(',,'); const customArgsArray = sub.custom_args.split(',,');
@@ -488,6 +466,8 @@ exports.writeSubscriptionMetadata = (sub) => {
: config_api.getConfigItem('ytdl_subscriptions_base_path'); : config_api.getConfigItem('ytdl_subscriptions_base_path');
const appendedBasePath = getAppendedBasePath(sub, basePath); const appendedBasePath = getAppendedBasePath(sub, basePath);
const metadata_path = path.join(appendedBasePath, CONSTS.SUBSCRIPTION_BACKUP_PATH); const metadata_path = path.join(appendedBasePath, CONSTS.SUBSCRIPTION_BACKUP_PATH);
fs.ensureDirSync(appendedBasePath);
fs.writeJSONSync(metadata_path, sub); fs.writeJSONSync(metadata_path, sub);
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,8 @@ const assert = require('assert');
const low = require('lowdb') const low = require('lowdb')
const winston = require('winston'); const winston = require('winston');
const path = require('path'); const path = require('path');
const util = require('util');
const exec = util.promisify(require('child_process').exec);
const FileSync = require('lowdb/adapters/FileSync'); const FileSync = require('lowdb/adapters/FileSync');
@@ -41,11 +42,13 @@ const subscriptions_api = require('../subscriptions');
const archive_api = require('../archive'); const archive_api = require('../archive');
const categories_api = require('../categories'); const categories_api = require('../categories');
const files_api = require('../files'); const files_api = require('../files');
const youtubedl_api = require('../youtube-dl');
const config_api = require('../config');
const fs = require('fs-extra'); const fs = require('fs-extra');
const { uuid } = require('uuidv4'); const { uuid } = require('uuidv4');
const NodeID3 = require('node-id3'); 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 = { const sample_video_json = {
id: "Sample Video", id: "Sample Video",
@@ -68,9 +71,9 @@ const sample_video_json = {
} }
describe('Database', async function() { describe('Database', async function() {
describe('Import', async function() { describe.skip('Import', async function() {
// it('Migrate', async function() { // it('Migrate', async function() {
// await db_api.connectToDB(); // // await db_api.connectToDB();
// await db_api.removeAllRecords(); // await db_api.removeAllRecords();
// const success = await db_api.importJSONToDB(db.value(), users_db.value()); // const success = await db_api.importJSONToDB(db.value(), users_db.value());
// assert(success); // assert(success);
@@ -86,7 +89,7 @@ describe('Database', async function() {
}); });
it('Transfer to local', async function() { it('Transfer to local', async function() {
await db_api.connectToDB(); // await db_api.connectToDB();
await db_api.removeAllRecords('test'); await db_api.removeAllRecords('test');
await db_api.insertRecordIntoTable('test', {test: '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) { for (const local_db_mode of local_db_modes) {
let use_local_db = local_db_mode; 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() { beforeEach(async function() {
if (!use_local_db) { if (!use_local_db) {
this.timeout(120000); this.timeout(120000);
@@ -167,7 +171,7 @@ describe('Database', async function() {
]; ];
await db_api.insertRecordsIntoTable('test', test_duplicates); await db_api.insertRecordsIntoTable('test', test_duplicates);
const duplicates = await db_api.findDuplicatesByKey('test', 'test'); 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() { it('Update record', async function() {
@@ -279,7 +283,7 @@ describe('Database', async function() {
assert(stats); assert(stats);
}); });
it('Query speed', async function() { it.skip('Query speed', async function() {
this.timeout(120000); this.timeout(120000);
const NUM_RECORDS_TO_ADD = 300004; // max batch ops is 1000 const NUM_RECORDS_TO_ADD = 300004; // max batch ops is 1000
const test_records = []; const test_records = [];
@@ -337,12 +341,13 @@ describe('Database', async function() {
}); });
describe('Multi User', async function() { describe('Multi User', async function() {
this.timeout(120000);
const user_to_test = 'test_user'; const user_to_test = 'test_user';
const user_password = 'test_pass'; const user_password = 'test_pass';
const sub_to_test = ''; const sub_to_test = '';
const playlist_to_test = ''; const playlist_to_test = '';
beforeEach(async function() { beforeEach(async function() {
await db_api.connectToDB(); // await db_api.connectToDB();
await auth_api.deleteUser(user_to_test); await auth_api.deleteUser(user_to_test);
}); });
describe('Basic', function() { describe('Basic', function() {
@@ -369,17 +374,17 @@ describe('Multi User', async function() {
it('Video access - disallowed', async function() { it('Video access - disallowed', async function() {
await db_api.setVideoProperty(video_to_test, {sharingEnabled: false}); 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); assert(!video_obj);
}); });
it('Video access - allowed', async function() { it('Video access - allowed', async function() {
await db_api.setVideoProperty(video_to_test, {sharingEnabled: true}, user_to_test); 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); assert(video_obj);
}); });
}); });
describe('Zip generators', function() { describe.skip('Zip generators', function() {
it('Playlist zip generator', async function() { it('Playlist zip generator', async function() {
const playlist = await files_api.getPlaylist(playlist_to_test, user_to_test); const playlist = await files_api.getPlaylist(playlist_to_test, user_to_test);
assert(playlist); assert(playlist);
@@ -435,35 +440,50 @@ describe('Multi User', async function() {
describe('Downloader', function() { describe('Downloader', function() {
const downloader_api = require('../downloader'); 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 sub_id = 'dc834388-3454-41bf-a618-e11cb8c7de1c';
const options = { const options = {
ui_uid: uuid(), ui_uid: uuid()
user: 'admin'
} }
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() { beforeEach(async function() {
await db_api.connectToDB(); // await db_api.connectToDB();
await db_api.removeAllRecords('download_queue'); await db_api.removeAllRecords('download_queue');
}); });
it('Get file info', async function() { it('Get file info', async function() {
this.timeout(300000); this.timeout(300000);
const info = await downloader_api.getVideoInfoByURL(url); const info = await downloader_api.getVideoInfoByURL(url);
assert(!!info); assert(!!info && info.length > 0);
}); });
it('Download file', async function() { 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); const returned_download = await downloader_api.createDownload(url, 'video', options);
console.log(returned_download); assert(returned_download);
await utils.wait(20000); 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() { it('Tag file', async function() {
const audio_path = './test/sample.mp3'; const success = await generateEmptyAudioFile('test/sample_mp3.mp3');
const sample_json = fs.readJSONSync('./test/sample.info.json'); const audio_path = './test/sample_mp3.mp3';
const sample_json = fs.readJSONSync('./test/sample_mp3.info.json');
const tags = { const tags = {
title: sample_json['title'], title: sample_json['title'],
artist: sample_json['artist'] ? sample_json['artist'] : sample_json['uploader'], artist: sample_json['artist'] ? sample_json['artist'] : sample_json['uploader'],
@@ -471,14 +491,13 @@ describe('Downloader', function() {
} }
NodeID3.write(tags, audio_path); NodeID3.write(tags, audio_path);
const written_tags = NodeID3.read(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() { it('Queue file', async function() {
this.timeout(300000); this.timeout(300000);
const returned_download = await downloader_api.createDownload(url, 'video', options); const returned_download = await downloader_api.createDownload(url, 'video', options, null, null, null, null, true);
console.log(returned_download); assert(returned_download);
await utils.wait(20000);
}); });
it('Pause file', async function() { it('Pause file', async function() {
@@ -493,7 +512,7 @@ describe('Downloader', function() {
assert(args.length > 0); 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 = await subscriptions_api.getSubscription(sub_id);
const sub_options = subscriptions_api.generateOptionsForSubscriptionDownload(sub, 'admin'); const sub_options = subscriptions_api.generateOptionsForSubscriptionDownload(sub, 'admin');
const args_normal = await downloader_api.generateArgs(url, 'video', options); const args_normal = await downloader_api.generateArgs(url, 'video', options);
@@ -506,7 +525,7 @@ describe('Downloader', function() {
if (fs.existsSync(nfo_file_path)) { if (fs.existsSync(nfo_file_path)) {
fs.unlinkSync(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); downloader_api.generateNFOFile(sample_json, nfo_file_path);
assert(fs.existsSync(nfo_file_path), true); assert(fs.existsSync(nfo_file_path), true);
fs.unlinkSync(nfo_file_path); fs.unlinkSync(nfo_file_path);
@@ -534,10 +553,18 @@ describe('Downloader', function() {
describe('Twitch', async function () { describe('Twitch', async function () {
const twitch_api = require('../twitch'); const twitch_api = require('../twitch');
const example_vod = '1710641401'; 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'); const sample_path = path.join('test', 'sample.twitch_chat.json');
if (fs.existsSync(sample_path)) fs.unlinkSync(sample_path); if (fs.existsSync(sample_path)) fs.unlinkSync(sample_path);
this.timeout(300000);
await twitch_api.downloadTwitchChatByVODID(example_vod, 'sample', null, null, null, './test'); await twitch_api.downloadTwitchChatByVODID(example_vod, 'sample', null, null, null, './test');
assert(fs.existsSync(sample_path)); assert(fs.existsSync(sample_path));
@@ -550,7 +577,7 @@ describe('Downloader', function() {
describe('Tasks', function() { describe('Tasks', function() {
const tasks_api = require('../tasks'); const tasks_api = require('../tasks');
beforeEach(async function() { beforeEach(async function() {
await db_api.connectToDB(); // await db_api.connectToDB();
await db_api.removeAllRecords('tasks'); await db_api.removeAllRecords('tasks');
const dummy_task = { const dummy_task = {
@@ -569,7 +596,7 @@ describe('Tasks', function() {
await tasks_api.executeTask('backup_local_db'); await tasks_api.executeTask('backup_local_db');
const backups_new = await utils.recFindByExt('appdata', 'bak'); const backups_new = await utils.recFindByExt('appdata', 'bak');
const new_length = backups_new.length; 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() { it('Check for missing files', async function() {
@@ -579,7 +606,7 @@ describe('Tasks', function() {
await db_api.insertRecordIntoTable('files', test_missing_file); await db_api.insertRecordIntoTable('files', test_missing_file);
await tasks_api.executeTask('missing_files_check'); await tasks_api.executeTask('missing_files_check');
const missing_file_db_record = await db_api.getRecord('files', {uid: 'test'}); 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() { it('Check for duplicate files', async function() {
@@ -599,27 +626,29 @@ describe('Tasks', function() {
await tasks_api.executeTask('duplicate_files_check'); await tasks_api.executeTask('duplicate_files_check');
const duplicated_record_count = await db_api.getRecords('files', {path: 'test/missing_file.mp4'}, true); 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() { it('Import unregistered files', async function() {
this.timeout(300000); this.timeout(300000);
const success = await generateEmptyVideoFile('test/sample_mp4.mp4');
// pre-test cleanup // pre-test cleanup
await db_api.removeAllRecords('files', {title: 'Sample File'}); 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.info.json')) fs.unlinkSync('video/sample_mp4.info.json');
if (fs.existsSync('video/sample.mp4')) fs.unlinkSync('video/sample.mp4'); if (fs.existsSync('video/sample_mp4.mp4')) fs.unlinkSync('video/sample_mp4.mp4');
// copies in files // copies in files
fs.copyFileSync('test/sample.info.json', 'video/sample.info.json'); fs.copyFileSync('test/sample_mp4.info.json', 'video/sample_mp4.info.json');
fs.copyFileSync('test/sample.mp4', 'video/sample.mp4'); fs.copyFileSync('test/sample_mp4.mp4', 'video/sample_mp4.mp4');
await tasks_api.executeTask('missing_db_records'); await tasks_api.executeTask('missing_db_records');
const imported_file = await db_api.getRecord('files', {title: 'Sample File'}); const imported_file = await db_api.getRecord('files', {title: 'Sample File'});
assert(!!imported_file === true); assert(success && !!imported_file);
// post-test cleanup // post-test cleanup
if (fs.existsSync('video/sample.info.json')) fs.unlinkSync('video/sample.info.json'); if (fs.existsSync('video/sample_mp4.info.json')) fs.unlinkSync('video/sample_mp4.info.json');
if (fs.existsSync('video/sample.mp4')) fs.unlinkSync('video/sample.mp4'); if (fs.existsSync('video/sample_mp4.mp4')) fs.unlinkSync('video/sample_mp4.mp4');
}); });
it('Schedule and cancel task', async function() { it('Schedule and cancel task', async function() {
@@ -659,12 +688,12 @@ describe('Tasks', function() {
describe('Archive', async function() { describe('Archive', async function() {
beforeEach(async function() { beforeEach(async function() {
await db_api.connectToDB(); // await db_api.connectToDB();
await db_api.removeAllRecords('archives', {user_uid: 'test_user'}); await db_api.removeAllRecords('archives');
}); });
afterEach(async function() { afterEach(async function() {
await db_api.removeAllRecords('archives', {user_uid: 'test_user'}); await db_api.removeAllRecords('archives');
}); });
it('Import archive', async function() { 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'); const count = await archive_api.importArchiveFile(archive_text, 'video', 'test_user', 'test_sub');
assert(count === 4) assert(count === 4)
const archive_items = await db_api.getRecords('archives', {user_uid: 'test_user', sub_id: 'test_sub'}); 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.length === 4);
assert(archive_items.filter(archive_item => archive_item.extractor === 'testextractor2').length === 1); assert(archive_items.filter(archive_item => archive_item.extractor === 'testextractor2').length === 1);
assert(archive_items.filter(archive_item => archive_item.extractor === 'testextractor1').length === 3); 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() { it('Remove from archive', async function() {
await archive_api.addToArchive('testextractor1', 'testing1', 'video', 'test_user'); await archive_api.addToArchive('testextractor1', 'testing1', 'video', 'test_title', 'test_user');
await archive_api.addToArchive('testextractor2', 'testing1', 'video', 'test_user'); await archive_api.addToArchive('testextractor2', 'testing1', 'video', 'test_title', 'test_user');
await archive_api.addToArchive('testextractor2', 'testing2', 'video', 'test_user'); await archive_api.addToArchive('testextractor2', 'testing2', 'video', 'test_title', 'test_user');
const success = await archive_api.removeFromArchive('testextractor2', 'testing1', 'video', 'test_user'); const success = await archive_api.removeFromArchive('testextractor2', 'testing1', 'video', 'test_user');
assert(success); assert(success);
@@ -757,7 +785,7 @@ describe('Utils', async function() {
describe('Categories', async function() { describe('Categories', async function() {
beforeEach(async function() { beforeEach(async function() {
await db_api.connectToDB(); // await db_api.connectToDB();
const new_category = { const new_category = {
name: 'test_category', name: 'test_category',
uid: uuid(), uid: uuid(),
@@ -805,7 +833,6 @@ describe('Categories', async function() {
}); });
const category = await categories_api.categorize([sample_video_json]); const category = await categories_api.categorize([sample_video_json]);
console.log(category);
assert(category && category.name === 'test_category'); assert(category && category.name === 'test_category');
}); });
@@ -858,4 +885,14 @@ describe('Categories', async function() {
const category = await categories_api.categorize([sample_video_json]); const category = await categories_api.categorize([sample_video_json]);
assert(category); 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}`);
}

View File

@@ -13,7 +13,7 @@ const CONSTS = require('./consts');
const is_windows = process.platform === 'win32'; const is_windows = process.platform === 'win32';
// replaces .webm with appropriate extension // replaces .webm with appropriate extension
exports.getTrueFileName = (unfixed_path, type) => { exports.getTrueFileName = (unfixed_path, type, force_ext = null) => {
let fixed_path = unfixed_path; let fixed_path = unfixed_path;
const new_ext = (type === 'audio' ? 'mp3' : 'mp4'); const new_ext = (type === 'audio' ? 'mp3' : 'mp4');
@@ -22,7 +22,7 @@ exports.getTrueFileName = (unfixed_path, type) => {
if (old_ext !== new_ext) { 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('.'); fixed_path = unfixed_parts.join('.');
} }
return fixed_path; return fixed_path;
@@ -347,7 +347,7 @@ exports.checkExistsWithTimeout = async (filePath, timeout) => {
if (!err) { if (!err) {
clearTimeout(timer); clearTimeout(timer);
if (watcher) watcher.close(); if (watcher) watcher.close();
resolve(); resolve(true);
} }
}); });
@@ -357,7 +357,7 @@ exports.checkExistsWithTimeout = async (filePath, timeout) => {
if (eventType === 'rename' && filename === basename) { if (eventType === 'rename' && filename === basename) {
clearTimeout(timer); clearTimeout(timer);
if (watcher) watcher.close(); 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 // objects
function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date, description, view_count, height, abr) { function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date, description, view_count, height, abr) {

View File

@@ -5,6 +5,7 @@ const logger = require('./logger');
const utils = require('./utils'); const utils = require('./utils');
const CONSTS = require('./consts'); const CONSTS = require('./consts');
const config_api = require('./config.js'); const config_api = require('./config.js');
const youtubedl = require('youtube-dl');
const OUTDATED_VERSION = "2020.00.00"; 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 () => { exports.checkForYoutubeDLUpdate = async () => {
return new Promise(async resolve => { return new Promise(async resolve => {
const default_downloader = config_api.getConfigItem('ytdl_default_downloader'); const default_downloader = config_api.getConfigItem('ytdl_default_downloader');

View File

@@ -21,4 +21,4 @@ version: 0.1.0
# incremented each time you make changes to the application. Versions are not expected to # 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. # follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes. # It is recommended to use it with quotes.
appVersion: "4.3.1" appVersion: "4.3.2"

View File

@@ -1,6 +1,6 @@
{ {
"name": "youtube-dl-material", "name": "youtube-dl-material",
"version": "4.3.1", "version": "4.3.2",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",

View File

@@ -17,11 +17,11 @@
</mat-menu> </mat-menu>
<button [matMenuTriggerFor]="menuSettings" mat-icon-button><mat-icon>more_vert</mat-icon></button> <button [matMenuTriggerFor]="menuSettings" mat-icon-button><mat-icon>more_vert</mat-icon></button>
<mat-menu #menuSettings="matMenu"> <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> <mat-icon>person</mat-icon>
<span i18n="Profile menu label">Profile</span> <span i18n="Profile menu label">Profile</span>
</button> </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> <mat-icon>topic</mat-icon>
<span i18n="Archives menu label">Archives</span> <span i18n="Archives menu label">Archives</span>
</button> </button>

View File

@@ -34,6 +34,7 @@ import { MatBadgeModule } from '@angular/material/badge';
import { DragDropModule } from '@angular/cdk/drag-drop'; import { DragDropModule } from '@angular/cdk/drag-drop';
import { ClipboardModule } from '@angular/cdk/clipboard'; import { ClipboardModule } from '@angular/cdk/clipboard';
import { TextFieldModule } from '@angular/cdk/text-field'; import { TextFieldModule } from '@angular/cdk/text-field';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
@@ -189,6 +190,7 @@ registerLocaleData(es, 'es');
DragDropModule, DragDropModule,
ClipboardModule, ClipboardModule,
TextFieldModule, TextFieldModule,
ScrollingModule,
NgxFileDropModule, NgxFileDropModule,
AvatarModule, AvatarModule,
ContentLoaderModule, ContentLoaderModule,

View File

@@ -10,8 +10,8 @@
<!-- Title Column --> <!-- Title Column -->
<ng-container matColumnDef="title"> <ng-container matColumnDef="title">
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Title">Title</ng-container> </mat-header-cell> <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"> <mat-cell *matCellDef="let element" style="flex: 2">
<span class="one-line" [matTooltip]="element.title ? element.title : null"> <span class="one-line" [matTooltip]="element.title ? element.title : null">
{{element.title}} {{element.title}}
</span> </span>
@@ -31,41 +31,47 @@
</mat-cell> </mat-cell>
</ng-container> </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 --> <!-- Progress Column -->
<ng-container matColumnDef="percent_complete"> <ng-container matColumnDef="percent_complete">
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Progress">Progress</ng-container> </mat-header-cell> <mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Progress">Progress</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element"> <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"> <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>
<ng-container *ngIf="!element.percent_complete"> <ng-container *ngIf="!element.percent_complete">
N/A N/A
</ng-container> </ng-container>
</ng-container>
<ng-container *ngIf="element.error" i18n="Error">Error</ng-container>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<!-- Actions Column --> <!-- Actions Column -->
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<mat-header-cell *matHeaderCellDef> <ng-container i18n="Actions">Actions</ng-container> </mat-header-cell> <mat-header-cell *matHeaderCellDef [ngStyle]="{flex: actionsFlex}"> <ng-container i18n="Actions">Actions</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element"> <mat-cell *matCellDef="let element" [ngStyle]="{flex: actionsFlex}">
<div> <div *ngIf="!minimizeButtons">
<ng-container *ngIf="!element.finished"> <ng-container *ngFor="let downloadAction of downloadActions">
<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> <span class="button-span">
<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> <mat-spinner [diameter]="28" *ngIf="downloadAction.loading && downloadAction.loading(element)" class="icon-button-spinner"></mat-spinner>
<button *ngIf="false && !element.paused" (click)="cancelDownload(element.uid)" mat-icon-button matTooltip="Cancel" i18n-matTooltip="Cancel"><mat-icon>cancel</mat-icon></button> <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>
<ng-container *ngIf="element.finished"> </div>
<button *ngIf="!element.error" (click)="watchContent(element)" mat-icon-button matTooltip="Watch content" i18n-matTooltip="Watch content"><mat-icon>smart_display</mat-icon></button> <div *ngIf="minimizeButtons">
<button *ngIf="element.error" (click)="showError(element)" mat-icon-button matTooltip="Show error" i18n-matTooltip="Show error"><mat-icon>warning</mat-icon></button> <button [matMenuTriggerFor]="download_actions" mat-icon-button><mat-icon>more_vert</mat-icon></button>
<button (click)="restartDownload(element.uid)" mat-icon-button matTooltip="Restart" i18n-matTooltip="Restart"><mat-icon>restart_alt</mat-icon></button> <mat-menu #download_actions="matMenu">
</ng-container> <ng-container *ngFor="let downloadAction of downloadActions">
<button *ngIf="element.finished || element.paused" (click)="clearDownload(element.uid)" mat-icon-button matTooltip="Clear" i18n-matTooltip="Clear"><mat-icon>delete</mat-icon></button> <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> </div>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
@@ -80,9 +86,9 @@
</mat-paginator> </mat-paginator>
</div> </div>
<div *ngIf="!uids" class="downloads-action-button-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 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 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 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 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" color="warn" mat-stroked-button (click)="clearDownloadsByType()"><ng-container i18n="Clear downloads">Clear downloads</ng-container></button>
</div> </div>
</div> </div>

View File

@@ -10,15 +10,23 @@ mat-header-cell, mat-cell {
.icon-button-spinner { .icon-button-spinner {
position: absolute; position: absolute;
top: 7px; top: -13px;
left: 6px; left: 10px;
}
.button-span {
position: relative;;
} }
.downloads-action-button-div { .downloads-action-button-div {
margin-top: 10px;
margin-left: 5px; margin-left: 5px;
} }
.downloads-action-button {
margin-top: 10px;
margin-right: 10px;
}
.rounded-top { .rounded-top {
border-radius: 16px 16px 0px 0px !important; border-radius: 16px 16px 0px 0px !important;
} }

View File

@@ -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 { PostsService } from 'app/posts.services';
import { trigger, transition, animateChild, stagger, query, style, animate } from '@angular/animations'; import { trigger, transition, animateChild, stagger, query, style, animate } from '@angular/animations';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@@ -13,31 +13,7 @@ import { Download } from 'api-types';
@Component({ @Component({
selector: 'app-downloads', selector: 'app-downloads',
templateUrl: './downloads.component.html', templateUrl: './downloads.component.html',
styleUrls: ['./downloads.component.scss'], 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' }))
]),
])
],
}) })
export class DownloadsComponent implements OnInit, OnDestroy { export class DownloadsComponent implements OnInit, OnDestroy {
@@ -62,13 +38,73 @@ export class DownloadsComponent implements OnInit, OnDestroy {
3: $localize`Complete` 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>(); 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; downloads_retrieved = false;
innerWidth: number;
@ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
@HostListener('window:resize', ['$event'])
onResize(): void {
this.innerWidth = window.innerWidth;
this.recalculateColumns();
}
sort_downloads = (a: Download, b: Download): number => { sort_downloads = (a: Download, b: Download): number => {
const result = b.timestamp_start - a.timestamp_start; const result = b.timestamp_start - a.timestamp_start;
return result; 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) { } constructor(public postsService: PostsService, private router: Router, private dialog: MatDialog, private clipboard: Clipboard) { }
ngOnInit(): void { 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) { if (this.postsService.initialized) {
this.getCurrentDownloadsRecurring(); this.getCurrentDownloadsRecurring();
} else { } else {
@@ -164,8 +204,8 @@ export class DownloadsComponent implements OnInit, OnDestroy {
}); });
} }
pauseDownload(download_uid: string): void { pauseDownload(download: Download): void {
this.postsService.pauseDownload(download_uid).subscribe(res => { this.postsService.pauseDownload(download['uid']).subscribe(res => {
if (!res['success']) { if (!res['success']) {
this.postsService.openSnackBar($localize`Failed to pause download! See server logs for more info.`); 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 { resumeDownload(download: Download): void {
this.postsService.resumeDownload(download_uid).subscribe(res => { this.postsService.resumeDownload(download['uid']).subscribe(res => {
if (!res['success']) { if (!res['success']) {
this.postsService.openSnackBar($localize`Failed to resume download! See server logs for more info.`); 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 { restartDownload(download: Download): void {
this.postsService.restartDownload(download_uid).subscribe(res => { this.postsService.restartDownload(download['uid']).subscribe(res => {
if (!res['success']) { if (!res['success']) {
this.postsService.openSnackBar($localize`Failed to restart download! See server logs for more info.`); this.postsService.openSnackBar($localize`Failed to restart download! See server logs for more info.`);
} else { } else {
@@ -208,16 +248,16 @@ export class DownloadsComponent implements OnInit, OnDestroy {
}); });
} }
cancelDownload(download_uid: string): void { cancelDownload(download: Download): void {
this.postsService.cancelDownload(download_uid).subscribe(res => { this.postsService.cancelDownload(download['uid']).subscribe(res => {
if (!res['success']) { if (!res['success']) {
this.postsService.openSnackBar($localize`Failed to cancel download! See server logs for more info.`); this.postsService.openSnackBar($localize`Failed to cancel download! See server logs for more info.`);
} }
}); });
} }
clearDownload(download_uid: string): void { clearDownload(download: Download): void {
this.postsService.clearDownload(download_uid).subscribe(res => { this.postsService.clearDownload(download['uid']).subscribe(res => {
if (!res['success']) { if (!res['success']) {
this.postsService.openSnackBar($localize`Failed to pause download! See server logs for more info.`); 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 { showError(download: Download): void {
console.log(download)
const copyToClipboardEmitter = new EventEmitter<boolean>(); const copyToClipboardEmitter = new EventEmitter<boolean>();
this.dialog.open(ConfirmDialogComponent, { this.dialog.open(ConfirmDialogComponent, {
data: { 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
}

View File

@@ -1,30 +1,32 @@
<div class="card-radius mat-elevation-z2" *ngFor="let notification of notifications; let i = index;"> <cdk-virtual-scroll-viewport itemSize="50" class="viewport" minBufferPx="1200" maxBufferPx="1200">
<mat-card class="notification-card card-radius"> <div #notification_parent class="notification-card-parent card-radius mat-elevation-z2" *cdkVirtualFor="let notification of notifications; let i = index;">
<mat-card-header> <mat-card class="notification-card card-radius">
<mat-card-subtitle> <mat-card-header>
<div> <mat-card-subtitle>
<span class="notification-timestamp">{{notification.timestamp * 1000 | date:'short'}}</span> <div>
</div> <span class="notification-timestamp">{{notification.timestamp * 1000 | date:'short'}}</span>
</mat-card-subtitle> </div>
<mat-card-title> </mat-card-subtitle>
<ng-container *ngIf="NOTIFICATION_PREFIX[notification.type]"> <mat-card-title>
{{NOTIFICATION_PREFIX[notification.type]}} <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> </ng-container>
</mat-card-title> </mat-card-content>
</mat-card-header> <mat-card-actions class="notification-actions" *ngIf="notification.actions?.length > 0">
<mat-card-content> <button matTooltip="Remove" i18n-matTooltip="Remove" (click)="emitDeleteNotification(notification.uid)" mat-icon-button><mat-icon>close</mat-icon></button>
<ng-container *ngIf="NOTIFICATION_SUFFIX_KEY[notification.type]"> <span *ngFor="let action of notification.actions">
<div style="word-break: break-word"> <button [matTooltip]="NOTIFICATION_ACTION_TO_STRING[action]" (click)="emitNotificationAction(notification, action)" mat-icon-button><mat-icon>{{NOTIFICATION_ICON[action]}}</mat-icon></button>
{{notification['data'][NOTIFICATION_SUFFIX_KEY[notification.type]]}} </span>
</div> </mat-card-actions>
</ng-container> <span *ngIf="!notification.read" class="dot"></span>
</mat-card-content> </mat-card>
<mat-card-actions *ngIf="notification.actions?.length > 0"> </div>
<button matTooltip="Remove" i18n-matTooltip="Remove" (click)="emitDeleteNotification(notification.uid)" mat-icon-button><mat-icon>close</mat-icon></button> </cdk-virtual-scroll-viewport>
<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>

View File

@@ -13,12 +13,21 @@
font-size: 14px; font-size: 14px;
} }
.notification-card-parent {
margin: 5px;
}
.notification-card { .notification-card {
margin-top: 5px; margin-top: 5px;
} }
.notification-actions {
margin-top: auto;
}
.card-radius { .card-radius {
border-radius: 12px; border-radius: 12px;
height: 166px;
} }
.dot { .dot {
@@ -30,4 +39,8 @@
position: absolute; position: absolute;
right: 8px; right: 8px;
top: 8px; top: 8px;
} }
.viewport {
height: 100%;
}

View File

@@ -4,7 +4,10 @@
} }
.notifications-list-parent { .notifications-list-parent {
max-height: 70vh;
overflow-y: auto; overflow-y: auto;
padding: 0px 10px 10px 10px; padding: 0px 10px 10px 10px;
}
.notifications-list {
display: block
} }

View File

@@ -4,7 +4,7 @@
<mat-chip-listbox [value]="selectedFilters" [multiple]="true" (change)="selectedFiltersChanged($event)"> <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-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> </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> </div>
<button style="margin: 10px 0px 2px 10px;" *ngIf="notifications?.length > 0" color="warn" (click)="deleteAllNotifications()" mat-stroked-button>Remove all</button> <button style="margin: 10px 0px 2px 10px;" *ngIf="notifications?.length > 0" color="warn" (click)="deleteAllNotifications()" mat-stroked-button>Remove all</button>
</div> </div>

View File

@@ -14,6 +14,7 @@ export class NotificationsComponent implements OnInit {
notifications: Notification[] = null; notifications: Notification[] = null;
filtered_notifications: Notification[] = null; filtered_notifications: Notification[] = null;
list_height = '65vh';
@Output() notificationCount = new EventEmitter<number>(); @Output() notificationCount = new EventEmitter<number>();
@@ -110,6 +111,8 @@ export class NotificationsComponent implements OnInit {
filterNotifications(): void { filterNotifications(): void {
this.filtered_notifications = this.notifications.filter(notification => this.selectedFilters.length === 0 || this.selectedFilters.includes(notification.type)); 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 { selectedFiltersChanged(event: MatChipListboxChange): void {
@@ -117,6 +120,12 @@ export class NotificationsComponent implements OnInit {
this.filterNotifications(); 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 => { originalOrder = (): number => {
return 0; return 0;
} }

View File

@@ -1 +1 @@
export const CURRENT_VERSION = 'v4.3.1'; export const CURRENT_VERSION = 'v4.3.2';

View File

@@ -35,36 +35,6 @@
<p> <p>
<ng-container i18n="About bug prefix">Found a bug or have a suggestion?</ng-container>&nbsp;<a [href]="issuesLink" target="_blank"><ng-container i18n="About bug click here">Click here</ng-container></a>&nbsp;<ng-container i18n="About bug suffix">to create an issue!</ng-container> <ng-container i18n="About bug prefix">Found a bug or have a suggestion?</ng-container>&nbsp;<a [href]="issuesLink" target="_blank"><ng-container i18n="About bug click here">Click here</ng-container></a>&nbsp;<ng-container i18n="About bug suffix">to create an issue!</ng-container>
</p> </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> </div>
</mat-dialog-content> </mat-dialog-content>

View File

@@ -16,8 +16,6 @@ export class AboutDialogComponent implements OnInit {
checking_for_updates = true; checking_for_updates = true;
current_version_tag = CURRENT_VERSION; current_version_tag = CURRENT_VERSION;
sidepanel_mode = this.postsService.sidepanel_mode;
card_size = this.postsService.card_size;
constructor(public postsService: PostsService) { } constructor(public postsService: PostsService) { }
@@ -31,15 +29,4 @@ export class AboutDialogComponent implements OnInit {
this.latestGithubRelease = res; 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;
}
} }

View File

@@ -13,19 +13,52 @@
</div> </div>
<div style="margin-top: 20px;"> <div style="margin-top: 20px;">
</div> </div>
<mat-divider style="margin-bottom: 20px"></mat-divider>
</div> </div>
<mat-form-field color="accent">
<div *ngIf="!postsService.isLoggedIn || !postsService.user"> <mat-label><ng-container i18n="Language select label">Language</ng-container></mat-label>
<h5><mat-icon>warn</mat-icon><ng-container i18n="Not logged in notification">You are not logged in.</ng-container></h5> <mat-select (selectionChange)="localeSelectChanged($event.value)" [(value)]="initialLocale">
<button (click)="loginClicked()" mat-raised-button color="primary"><ng-container i18n="Login">Login</ng-container></button> <mat-option *ngFor="let locale of supported_locales" [value]="locale">
</div> <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-content>
<mat-dialog-actions> <mat-dialog-actions>
<div style="width: 100%"> <div style="width: 100%">
<div style="position: relative"> <div style="position: relative">
<button mat-stroked-button mat-dialog-close color="primary"><ng-container i18n="Close">Close</ng-container></button> <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>
</div> </div>
</mat-dialog-actions> </mat-dialog-actions>

View File

@@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core';
import { PostsService } from 'app/posts.services'; import { PostsService } from 'app/posts.services';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { MatDialogRef } from '@angular/material/dialog'; import { MatDialogRef } from '@angular/material/dialog';
import { isoLangs } from './locales_list';
@Component({ @Component({
selector: 'app-user-profile-dialog', selector: 'app-user-profile-dialog',
@@ -10,9 +11,24 @@ import { MatDialogRef } from '@angular/material/dialog';
}) })
export class UserProfileDialogComponent implements OnInit { 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>) { } constructor(public postsService: PostsService, private router: Router, public dialogRef: MatDialogRef<UserProfileDialogComponent>) { }
ngOnInit(): void { 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() { loginClicked() {
@@ -25,4 +41,19 @@ export class UserProfileDialogComponent implements OnInit {
this.dialogRef.close(); 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;
}
} }

View File

@@ -205,7 +205,7 @@
</div> </div>
<div style="display: flex; justify-content: center;" *ngIf="downloads && downloads.length > 0 && !autoplay"> <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> </div>
<ng-container *ngIf="cachedFileManagerEnabled || fileManagerEnabled"> <ng-container *ngIf="cachedFileManagerEnabled || fileManagerEnabled">

View File

@@ -37,11 +37,15 @@
} }
.spinner { .spinner {
bottom: 1px; bottom: -2px;
left: 2px; left: 6px;
position: absolute; position: absolute;
} }
.buttons {
position: relative;
}
.save-button { .save-button {
right: 25px; right: 25px;
position: fixed; position: fixed;
@@ -85,13 +89,6 @@
margin-bottom: 15px; margin-bottom: 15px;
} }
.spinner-div {
position: relative;
display: inline-block;
margin-right: 12px;
top: 8px;
}
.skip-ad-button { .skip-ad-button {
position: absolute; position: absolute;
right: 20px; right: 20px;

View File

@@ -22,20 +22,22 @@
</p> </p>
</ng-container> </ng-container>
<ng-container *ngIf="!db_file || !db_file['description']"> <ng-container *ngIf="!db_file || !db_file['description']">
<p style="text-align: center;"> <p i18n="No description" style="text-align: center;">
No description available. No description available.
</p> </p>
</ng-container> </ng-container>
</div> </div>
<div class="col-2"> <div class="col-2">
<ng-container *ngIf="db_playlist"> <span class="buttons" *ngIf="db_playlist">
<button (click)="downloadContent()" [disabled]="downloading" mat-icon-button><mat-icon>save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="35"></mat-spinner></button> <button (click)="downloadContent()" [disabled]="downloading" mat-icon-button><mat-icon>save</mat-icon></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> <button *ngIf="(!postsService.isLoggedIn || postsService.permissions.includes('sharing')) && !auto" (click)="openShareDialog()" mat-icon-button><mat-icon>share</mat-icon></button>
</ng-container> </span>
<ng-container *ngIf="db_file"> <span class="buttons" *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> <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> <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> <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 (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> <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> <app-twitch-chat #twitchchat [db_file]="db_file" [current_timestamp]="api.currentTime" [sub]="subscription"></app-twitch-chat>
</ng-container> </ng-container>
</mat-drawer> </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>&nbsp;<mat-icon>update</mat-icon></button>
</div> -->
</mat-drawer-container> </mat-drawer-container>
</div> </div>
</div> </div>

View File

@@ -61,8 +61,6 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
url = null; url = null;
name = null; name = null;
innerWidth: number;
downloading = false; downloading = false;
save_volume_timer = null; save_volume_timer = null;
@@ -70,14 +68,7 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('twitchchat') twitchChat: TwitchChatComponent; @ViewChild('twitchchat') twitchChat: TwitchChatComponent;
@HostListener('window:resize', ['$event'])
onResize(): void {
this.innerWidth = window.innerWidth;
}
ngOnInit(): void { ngOnInit(): void {
this.innerWidth = window.innerWidth;
this.playlist_id = this.route.snapshot.paramMap.get('playlist_id'); this.playlist_id = this.route.snapshot.paramMap.get('playlist_id');
this.uid = this.route.snapshot.paramMap.get('uid'); this.uid = this.route.snapshot.paramMap.get('uid');
this.sub_id = this.route.snapshot.paramMap.get('sub_id'); this.sub_id = this.route.snapshot.paramMap.get('sub_id');

View File

@@ -115,7 +115,7 @@ import {
RestartDownloadResponse, RestartDownloadResponse,
TaskType TaskType
} from '../api-types'; } 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 { Title } from '@angular/platform-browser';
import { MatDrawerMode } from '@angular/material/sidenav'; import { MatDrawerMode } from '@angular/material/sidenav';

View File

@@ -78,23 +78,6 @@
</div> </div>
</div> </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> </ng-template>
</mat-tab> </mat-tab>
<!-- Downloader --> <!-- Downloader -->

View File

@@ -1,6 +1,5 @@
import { Component, OnInit, EventEmitter } from '@angular/core'; import { Component, OnInit, EventEmitter } from '@angular/core';
import { PostsService } from 'app/posts.services'; import { PostsService } from 'app/posts.services';
import { isoLangs } from './locales_list';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import {DomSanitizer} from '@angular/platform-browser'; import {DomSanitizer} from '@angular/platform-browser';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
@@ -22,10 +21,6 @@ import { GenerateRssUrlComponent } from 'app/dialogs/generate-rss-url/generate-r
styleUrls: ['./settings.component.scss'] styleUrls: ['./settings.component.scss']
}) })
export class SettingsComponent implements OnInit { 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; initial_config = null;
new_config = null new_config = null
loading_config = false; loading_config = false;
@@ -83,16 +78,6 @@ export class SettingsComponent implements OnInit {
const tab = this.route.snapshot.paramMap.get('tab'); const tab = this.route.snapshot.paramMap.get('tab');
this.tabIndex = tab && this.TAB_TO_INDEX[tab] ? this.TAB_TO_INDEX[tab] : 0; 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 { 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 { generateBookmarklet(): void {
this.bookmarksite('YTDL-Material', this.generated_bookmarklet_code); this.bookmarksite('YTDL-Material', this.generated_bookmarklet_code);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1432,7 +1432,7 @@
</trans-unit> </trans-unit>
<trans-unit id="994363f08f9fbfa3b3994ff7b35c6904fdff18d8" datatype="html"> <trans-unit id="994363f08f9fbfa3b3994ff7b35c6904fdff18d8" datatype="html">
<source>Profile</source> <source>Profile</source>
<target state="translated">资料</target> <target state="translated">个人资料</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">app/app.component.html</context> <context context-type="sourcefile">app/app.component.html</context>
<context context-type="linenumber">19</context> <context context-type="linenumber">19</context>
@@ -5015,6 +5015,24 @@
</context-group> </context-group>
<note priority="1" from="description">Use gotify API setting</note> <note priority="1" from="description">Use gotify API setting</note>
</trans-unit> </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> </body>
</file> </file>
</xliff> </xliff>