mirror of
https://github.com/Tzahi12345/YoutubeDL-Material.git
synced 2026-03-22 20:51:00 +03:00
Compare commits
53 Commits
rebuild-da
...
youtubedl-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95084f32fa | ||
|
|
688ca07e1f | ||
|
|
9142c0c6fb | ||
|
|
93f6401c1e | ||
|
|
26d009d815 | ||
|
|
9ec2e6a72c | ||
|
|
e21f0a3ae5 | ||
|
|
bd8b6f4a93 | ||
|
|
ff9558ab35 | ||
|
|
8aa21e3c4b | ||
|
|
58811a7c72 | ||
|
|
30e485a653 | ||
|
|
9d674c51ca | ||
|
|
9ca6563b36 | ||
|
|
7bf2fb5fef | ||
|
|
8a7615c91d | ||
|
|
6b3f9649ca | ||
|
|
d59c340019 | ||
|
|
2aaea00976 | ||
|
|
9206d4ba28 | ||
|
|
3a86d5c636 | ||
|
|
ea43ea7121 | ||
|
|
3fc1110fc1 | ||
|
|
3615b810e4 | ||
|
|
78d48f2ab9 | ||
|
|
cd0aaf6a61 | ||
|
|
d3cb957991 | ||
|
|
098c5a3c25 | ||
|
|
be71a9bd8c | ||
|
|
42c600cea9 | ||
|
|
0427f91cfc | ||
|
|
51552b3092 | ||
|
|
1a7ca0343a | ||
|
|
525e8e04e8 | ||
|
|
5a824cee82 | ||
|
|
13a03a722c | ||
|
|
f7d3835111 | ||
|
|
8212acbac6 | ||
|
|
2a1af69f1f | ||
|
|
1f615a2379 | ||
|
|
f50d3104de | ||
|
|
f23ca61dab | ||
|
|
6eadb37532 | ||
|
|
2c61260e0f | ||
|
|
ba0de7f95c | ||
|
|
3dfdbcb151 | ||
|
|
c207e56855 | ||
|
|
441131e930 | ||
|
|
7447ca038a | ||
|
|
c5d1b3ffcf | ||
|
|
c64140b605 | ||
|
|
6579f2b59e | ||
|
|
9f5b584593 |
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -80,7 +80,7 @@ jobs:
|
|||||||
with:
|
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
40
.github/workflows/mocha.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: Tests
|
||||||
|
'on':
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- synchronize
|
||||||
|
- reopened
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: 'Backend - mocha'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node:
|
||||||
|
- 16
|
||||||
|
steps:
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '${{ matrix.node }}'
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: 'Cache node_modules'
|
||||||
|
uses: actions/cache@v2
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: ${{ runner.os }}-node-v${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-v${{ matrix.node }}-
|
||||||
|
working-directory: ./backend
|
||||||
|
- uses: FedericoCarboni/setup-ffmpeg@v2
|
||||||
|
id: setup-ffmpeg
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
working-directory: ./backend
|
||||||
|
- name: Run All Node.js Tests
|
||||||
|
run: npm run test
|
||||||
|
working-directory: ./backend
|
||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -3,6 +3,6 @@
|
|||||||
"mochaExplorer.cwd": "backend",
|
"mochaExplorer.cwd": "backend",
|
||||||
"mochaExplorer.globImplementation": "vscode",
|
"mochaExplorer.globImplementation": "vscode",
|
||||||
"mochaExplorer.env": {
|
"mochaExplorer.env": {
|
||||||
"YTDL_MODE": "debug"
|
// "YTDL_MODE": "debug"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
26
Dockerfile
26
Dockerfile
@@ -19,26 +19,22 @@ ENV USER=youtube
|
|||||||
ENV NO_UPDATE_NOTIFIER=true
|
ENV NO_UPDATE_NOTIFIER=true
|
||||||
ENV PM2_HOME=/app/pm2
|
ENV PM2_HOME=/app/pm2
|
||||||
ENV ALLOW_CONFIG_MUTATIONS=true
|
ENV ALLOW_CONFIG_MUTATIONS=true
|
||||||
# Directy fetch specific version
|
|
||||||
## https://deb.nodesource.com/node_16.x/pool/main/n/nodejs/nodejs_16.14.2-deb-1nodesource1_amd64.deb
|
# Use NVM to get specific node version
|
||||||
|
ENV NODE_VERSION=16.14.2
|
||||||
RUN groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
|
RUN groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
|
||||||
apt update && \
|
apt update && \
|
||||||
apt install -y --no-install-recommends curl ca-certificates tzdata libicu70 && \
|
apt install -y --no-install-recommends curl ca-certificates tzdata libicu70 libatomic1 && \
|
||||||
apt clean && \
|
apt clean && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
RUN case ${TARGETPLATFORM} in \
|
|
||||||
"linux/amd64") NODE_ARCH=amd64 ;; \
|
|
||||||
"linux/arm") NODE_ARCH=armhf ;; \
|
|
||||||
"linux/arm/v7") NODE_ARCH=armhf ;; \
|
|
||||||
"linux/arm64") NODE_ARCH=arm64 ;; \
|
|
||||||
esac \
|
|
||||||
&& curl -L https://deb.nodesource.com/node_16.x/pool/main/n/nodejs/nodejs_16.14.2-deb-1nodesource1_$NODE_ARCH.deb -o ./nodejs.deb && \
|
|
||||||
apt update && \
|
|
||||||
apt install -y ./nodejs.deb && \
|
|
||||||
apt clean && \
|
|
||||||
rm -rf /var/lib/apt/lists/* &&\
|
|
||||||
rm nodejs.deb;
|
|
||||||
|
|
||||||
|
RUN mkdir /usr/local/nvm
|
||||||
|
ENV PATH="/usr/local/nvm/versions/node/v${NODE_VERSION}/bin/:${PATH}"
|
||||||
|
ENV NVM_DIR=/usr/local/nvm
|
||||||
|
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.5/install.sh | bash
|
||||||
|
RUN . "$NVM_DIR/nvm.sh" && nvm install ${NODE_VERSION}
|
||||||
|
RUN . "$NVM_DIR/nvm.sh" && nvm use v${NODE_VERSION}
|
||||||
|
RUN . "$NVM_DIR/nvm.sh" && nvm alias default v${NODE_VERSION}
|
||||||
|
|
||||||
# Build frontend
|
# Build frontend
|
||||||
ARG BUILDPLATFORM
|
ARG BUILDPLATFORM
|
||||||
|
|||||||
@@ -293,6 +293,48 @@ paths:
|
|||||||
$ref: '#/components/schemas/UnsubscribeResponse'
|
$ref: '#/components/schemas/UnsubscribeResponse'
|
||||||
security:
|
security:
|
||||||
- Auth query parameter: []
|
- Auth query parameter: []
|
||||||
|
/api/checkSubscription:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- subscriptions
|
||||||
|
summary: Run a check for videos for a subscription
|
||||||
|
description: Runs a subscription check
|
||||||
|
operationId: post-api-checksubscription
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CheckSubscriptionRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SuccessObject'
|
||||||
|
security:
|
||||||
|
- Auth query parameter: []
|
||||||
|
/api/cancelCheckSubscription:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- subscriptions
|
||||||
|
summary: Cancels check for videos for a subscription
|
||||||
|
description: Cancels subscription check
|
||||||
|
operationId: post-api-checksubscription
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CheckSubscriptionRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SuccessObject'
|
||||||
|
security:
|
||||||
|
- Auth query parameter: []
|
||||||
/api/deleteSubscriptionFile:
|
/api/deleteSubscriptionFile:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@@ -1758,14 +1800,14 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
task_key:
|
task_key:
|
||||||
type: string
|
$ref: '#/components/schemas/TaskType'
|
||||||
required:
|
required:
|
||||||
- task_key
|
- task_key
|
||||||
UpdateTaskScheduleRequest:
|
UpdateTaskScheduleRequest:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
task_key:
|
task_key:
|
||||||
type: string
|
$ref: '#/components/schemas/TaskType'
|
||||||
new_schedule:
|
new_schedule:
|
||||||
$ref: '#/components/schemas/Schedule'
|
$ref: '#/components/schemas/Schedule'
|
||||||
required:
|
required:
|
||||||
@@ -1775,7 +1817,7 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
task_key:
|
task_key:
|
||||||
type: string
|
$ref: '#/components/schemas/TaskType'
|
||||||
new_data:
|
new_data:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -1785,7 +1827,7 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
task_key:
|
task_key:
|
||||||
type: string
|
$ref: '#/components/schemas/TaskType'
|
||||||
new_options:
|
new_options:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -1981,11 +2023,11 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
UnsubscribeRequest:
|
UnsubscribeRequest:
|
||||||
required:
|
required:
|
||||||
- sub
|
- sub_id
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
sub:
|
sub_id:
|
||||||
$ref: '#/components/schemas/SubscriptionRequestData'
|
type: string
|
||||||
deleteMode:
|
deleteMode:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Defaults to false
|
description: Defaults to false
|
||||||
@@ -1998,6 +2040,13 @@ components:
|
|||||||
type: boolean
|
type: boolean
|
||||||
error:
|
error:
|
||||||
type: string
|
type: string
|
||||||
|
CheckSubscriptionRequest:
|
||||||
|
required:
|
||||||
|
- sub_id
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
sub_id:
|
||||||
|
type: string
|
||||||
DeleteAllFilesResponse:
|
DeleteAllFilesResponse:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -2683,6 +2732,8 @@ components:
|
|||||||
type: boolean
|
type: boolean
|
||||||
paused:
|
paused:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
cancelled:
|
||||||
|
type: boolean
|
||||||
finished_step:
|
finished_step:
|
||||||
type: boolean
|
type: boolean
|
||||||
url:
|
url:
|
||||||
@@ -2726,7 +2777,7 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
key:
|
key:
|
||||||
type: string
|
$ref: '#/components/schemas/TaskType'
|
||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
last_ran:
|
last_ran:
|
||||||
@@ -2745,6 +2796,17 @@ components:
|
|||||||
$ref: '#/components/schemas/Schedule'
|
$ref: '#/components/schemas/Schedule'
|
||||||
options:
|
options:
|
||||||
type: object
|
type: object
|
||||||
|
TaskType:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- backup_local_db
|
||||||
|
- missing_files_check
|
||||||
|
- missing_db_records
|
||||||
|
- duplicate_files_check
|
||||||
|
- youtubedl_update_check
|
||||||
|
- delete_old_files
|
||||||
|
- import_legacy_archives
|
||||||
|
- rebuild_database
|
||||||
Schedule:
|
Schedule:
|
||||||
required:
|
required:
|
||||||
- type
|
- type
|
||||||
@@ -2830,6 +2892,8 @@ components:
|
|||||||
nullable: true
|
nullable: true
|
||||||
isPlaylist:
|
isPlaylist:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
child_process:
|
||||||
|
type: object
|
||||||
archive:
|
archive:
|
||||||
type: string
|
type: string
|
||||||
timerange:
|
timerange:
|
||||||
@@ -2838,6 +2902,10 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
custom_output:
|
custom_output:
|
||||||
type: string
|
type: string
|
||||||
|
downloading:
|
||||||
|
type: boolean
|
||||||
|
paused:
|
||||||
|
type: boolean
|
||||||
videos:
|
videos:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
|
|||||||
122
backend/app.js
122
backend/app.js
@@ -20,11 +20,6 @@ const ps = require('ps-node');
|
|||||||
const Feed = require('feed').Feed;
|
const Feed = require('feed').Feed;
|
||||||
const session = require('express-session');
|
const session = require('express-session');
|
||||||
|
|
||||||
// needed if bin/details somehow gets deleted
|
|
||||||
if (!fs.existsSync(CONSTS.DETAILS_BIN_PATH)) fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, {"version":"2000.06.06","path":"node_modules\\youtube-dl\\bin\\youtube-dl.exe","exec":"youtube-dl.exe","downloader":"youtube-dl"})
|
|
||||||
|
|
||||||
const youtubedl = require('youtube-dl');
|
|
||||||
|
|
||||||
const logger = require('./logger');
|
const logger = require('./logger');
|
||||||
const config_api = require('./config.js');
|
const config_api = require('./config.js');
|
||||||
const downloader_api = require('./downloader');
|
const downloader_api = require('./downloader');
|
||||||
@@ -535,7 +530,8 @@ async function loadConfig() {
|
|||||||
if (allowSubscriptions) {
|
if (allowSubscriptions) {
|
||||||
// set downloading to false
|
// set downloading to false
|
||||||
let subscriptions = await subscriptions_api.getAllSubscriptions();
|
let subscriptions = await subscriptions_api.getAllSubscriptions();
|
||||||
subscriptions_api.updateSubscriptionPropertyMultiple(subscriptions, {downloading: false});
|
subscriptions.forEach(async sub => subscriptions_api.writeSubscriptionMetadata(sub));
|
||||||
|
subscriptions_api.updateSubscriptionPropertyMultiple(subscriptions, {downloading: false, child_process: null});
|
||||||
// runs initially, then runs every ${subscriptionCheckInterval} seconds
|
// runs initially, then runs every ${subscriptionCheckInterval} seconds
|
||||||
const watchSubscriptionsInterval = function() {
|
const watchSubscriptionsInterval = function() {
|
||||||
watchSubscriptions();
|
watchSubscriptions();
|
||||||
@@ -656,36 +652,20 @@ function generateEnvVarConfigItem(key) {
|
|||||||
|
|
||||||
// currently only works for single urls
|
// currently only works for single urls
|
||||||
async function getUrlInfos(url) {
|
async function getUrlInfos(url) {
|
||||||
let startDate = Date.now();
|
const {parsed_output, err} = await youtubedl_api.runYoutubeDL(url, ['--dump-json']);
|
||||||
let result = [];
|
if (!parsed_output || parsed_output.length !== 1) {
|
||||||
return new Promise(resolve => {
|
logger.error(`Failed to retrieve available formats for url: ${url}`);
|
||||||
youtubedl.exec(url, ['--dump-json'], {maxBuffer: Infinity}, (err, output) => {
|
if (err) logger.error(err);
|
||||||
let new_date = Date.now();
|
return null;
|
||||||
let difference = (new_date - startDate)/1000;
|
}
|
||||||
logger.debug(`URL info retrieval delay: ${difference} seconds.`);
|
return parsed_output[0];
|
||||||
if (err) {
|
|
||||||
logger.error(`Error during retrieving formats for ${url}: ${err}`);
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
let try_putput = null;
|
|
||||||
try {
|
|
||||||
try_putput = JSON.parse(output);
|
|
||||||
result = try_putput;
|
|
||||||
} catch(e) {
|
|
||||||
logger.error(`Failed to retrieve available formats for url: ${url}`);
|
|
||||||
}
|
|
||||||
resolve(result);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// youtube-dl functions
|
// youtube-dl functions
|
||||||
|
|
||||||
async function startYoutubeDL() {
|
async function startYoutubeDL() {
|
||||||
// auto update youtube-dl
|
// auto update youtube-dl
|
||||||
youtubedl_api.verifyBinaryExistsLinux();
|
await youtubedl_api.checkForYoutubeDLUpdate();
|
||||||
const update_available = await youtubedl_api.checkForYoutubeDLUpdate();
|
|
||||||
if (update_available) await youtubedl_api.updateYoutubeDL(update_available);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(function(req, res, next) {
|
app.use(function(req, res, next) {
|
||||||
@@ -1211,10 +1191,10 @@ app.post('/api/subscribe', optionalJwt, async (req, res) => {
|
|||||||
|
|
||||||
app.post('/api/unsubscribe', optionalJwt, async (req, res) => {
|
app.post('/api/unsubscribe', optionalJwt, async (req, res) => {
|
||||||
let deleteMode = req.body.deleteMode
|
let deleteMode = req.body.deleteMode
|
||||||
let sub = req.body.sub;
|
let sub_id = req.body.sub_id;
|
||||||
let user_uid = req.isAuthenticated() ? req.user.uid : null;
|
let user_uid = req.isAuthenticated() ? req.user.uid : null;
|
||||||
|
|
||||||
let result_obj = subscriptions_api.unsubscribe(sub, deleteMode, user_uid);
|
let result_obj = subscriptions_api.unsubscribe(sub_id, deleteMode, user_uid);
|
||||||
if (result_obj.success) {
|
if (result_obj.success) {
|
||||||
res.send({
|
res.send({
|
||||||
success: result_obj.success
|
success: result_obj.success
|
||||||
@@ -1304,6 +1284,36 @@ app.post('/api/updateSubscription', optionalJwt, async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/checkSubscription', optionalJwt, async (req, res) => {
|
||||||
|
let sub_id = req.body.sub_id;
|
||||||
|
let user_uid = req.isAuthenticated() ? req.user.uid : null;
|
||||||
|
|
||||||
|
const success = subscriptions_api.getVideosForSub(sub_id, user_uid);
|
||||||
|
res.send({
|
||||||
|
success: success
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/cancelCheckSubscription', optionalJwt, async (req, res) => {
|
||||||
|
let sub_id = req.body.sub_id;
|
||||||
|
let user_uid = req.isAuthenticated() ? req.user.uid : null;
|
||||||
|
|
||||||
|
const success = subscriptions_api.cancelCheckSubscription(sub_id, user_uid);
|
||||||
|
res.send({
|
||||||
|
success: success
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/cancelSubscriptionCheck', optionalJwt, async (req, res) => {
|
||||||
|
let sub_id = req.body.sub_id;
|
||||||
|
let user_uid = req.isAuthenticated() ? req.user.uid : null;
|
||||||
|
|
||||||
|
const success = subscriptions_api.getVideosForSub(sub_id, user_uid);
|
||||||
|
res.send({
|
||||||
|
success: success
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/getSubscriptions', optionalJwt, async (req, res) => {
|
app.post('/api/getSubscriptions', optionalJwt, async (req, res) => {
|
||||||
let user_uid = req.isAuthenticated() ? req.user.uid : null;
|
let user_uid = req.isAuthenticated() ? req.user.uid : null;
|
||||||
|
|
||||||
@@ -1928,9 +1938,38 @@ app.post('/api/clearAllLogs', optionalJwt, async function(req, res) {
|
|||||||
|
|
||||||
// user authentication
|
// user authentication
|
||||||
|
|
||||||
app.post('/api/auth/register'
|
app.post('/api/auth/register', optionalJwt, async (req, res) => {
|
||||||
, optionalJwt
|
const userid = req.body.userid;
|
||||||
, auth_api.registerUser);
|
const username = req.body.username;
|
||||||
|
const plaintextPassword = req.body.password;
|
||||||
|
|
||||||
|
if (userid !== 'admin' && !config_api.getConfigItem('ytdl_allow_registration') && !req.isAuthenticated() && (!req.user || !exports.userHasPermission(req.user.uid, 'settings'))) {
|
||||||
|
logger.error(`Registration failed for user ${userid}. Registration is disabled.`);
|
||||||
|
res.sendStatus(409);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plaintextPassword === "") {
|
||||||
|
logger.error(`Registration failed for user ${userid}. A password must be provided.`);
|
||||||
|
res.sendStatus(409);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userid || !username) {
|
||||||
|
logger.error(`Registration failed for user ${userid}. Username or userid is invalid.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const new_user = await auth_api.registerUser(userid, username, plaintextPassword);
|
||||||
|
|
||||||
|
if (!new_user) {
|
||||||
|
res.sendStatus(409);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send({
|
||||||
|
user: new_user
|
||||||
|
});
|
||||||
|
});
|
||||||
app.post('/api/auth/login'
|
app.post('/api/auth/login'
|
||||||
, auth_api.passport.authenticate(['local', 'ldapauth'], {})
|
, auth_api.passport.authenticate(['local', 'ldapauth'], {})
|
||||||
, auth_api.generateJWT
|
, auth_api.generateJWT
|
||||||
@@ -1982,18 +2021,7 @@ app.post('/api/updateUser', optionalJwt, async (req, res) => {
|
|||||||
app.post('/api/deleteUser', optionalJwt, async (req, res) => {
|
app.post('/api/deleteUser', optionalJwt, async (req, res) => {
|
||||||
let uid = req.body.uid;
|
let uid = req.body.uid;
|
||||||
try {
|
try {
|
||||||
let success = false;
|
const success = await auth_api.deleteUser(uid);
|
||||||
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
|
|
||||||
const user_folder = path.join(__dirname, usersFileFolder, uid);
|
|
||||||
const user_db_obj = await db_api.getRecord('users', {uid: uid});
|
|
||||||
if (user_db_obj) {
|
|
||||||
// user exists, let's delete
|
|
||||||
await fs.remove(user_folder);
|
|
||||||
await db_api.removeRecord('users', {uid: uid});
|
|
||||||
success = true;
|
|
||||||
} else {
|
|
||||||
logger.error(`Could not find user with uid ${uid}`);
|
|
||||||
}
|
|
||||||
res.send({success: success});
|
res.send({success: success});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(err);
|
logger.error(err);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
const config_api = require('../config');
|
const config_api = require('../config');
|
||||||
const consts = require('../consts');
|
const CONSTS = require('../consts');
|
||||||
const logger = require('../logger');
|
const logger = require('../logger');
|
||||||
const db_api = require('../db');
|
const db_api = require('../db');
|
||||||
|
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { uuid } = require('uuidv4');
|
const { uuid } = require('uuidv4');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
var LocalStrategy = require('passport-local').Strategy;
|
var LocalStrategy = require('passport-local').Strategy;
|
||||||
var LdapStrategy = require('passport-ldapauth');
|
var LdapStrategy = require('passport-ldapauth');
|
||||||
@@ -16,7 +18,7 @@ var JwtStrategy = require('passport-jwt').Strategy,
|
|||||||
let SERVER_SECRET = null;
|
let SERVER_SECRET = null;
|
||||||
let JWT_EXPIRATION = null;
|
let JWT_EXPIRATION = null;
|
||||||
let opts = null;
|
let opts = null;
|
||||||
let saltRounds = null;
|
let saltRounds = 10;
|
||||||
|
|
||||||
exports.initialize = function () {
|
exports.initialize = function () {
|
||||||
/*************************
|
/*************************
|
||||||
@@ -31,8 +33,6 @@ exports.initialize = function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
saltRounds = 10;
|
|
||||||
|
|
||||||
// Sometimes this value is not properly typed: https://github.com/Tzahi12345/YoutubeDL-Material/issues/813
|
// Sometimes this value is not properly typed: https://github.com/Tzahi12345/YoutubeDL-Material/issues/813
|
||||||
JWT_EXPIRATION = config_api.getConfigItem('ytdl_jwt_expiration');
|
JWT_EXPIRATION = config_api.getConfigItem('ytdl_jwt_expiration');
|
||||||
if (!(+JWT_EXPIRATION)) {
|
if (!(+JWT_EXPIRATION)) {
|
||||||
@@ -68,7 +68,7 @@ exports.initialize = function () {
|
|||||||
const setupRoles = async () => {
|
const setupRoles = async () => {
|
||||||
const required_roles = {
|
const required_roles = {
|
||||||
admin: {
|
admin: {
|
||||||
permissions: consts.AVAILABLE_PERMISSIONS
|
permissions: CONSTS.AVAILABLE_PERMISSIONS
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
permissions: [
|
permissions: [
|
||||||
@@ -106,55 +106,41 @@ exports.passport.deserializeUser(function(user, done) {
|
|||||||
/***************************************
|
/***************************************
|
||||||
* Register user with hashed password
|
* Register user with hashed password
|
||||||
**************************************/
|
**************************************/
|
||||||
exports.registerUser = async function(req, res) {
|
|
||||||
var userid = req.body.userid;
|
|
||||||
var username = req.body.username;
|
|
||||||
var plaintextPassword = req.body.password;
|
|
||||||
|
|
||||||
if (userid !== 'admin' && !config_api.getConfigItem('ytdl_allow_registration') && !req.isAuthenticated() && (!req.user || !exports.userHasPermission(req.user.uid, 'settings'))) {
|
exports.registerUser = async (userid, username, plaintextPassword) => {
|
||||||
res.sendStatus(409);
|
const hash = await bcrypt.hash(plaintextPassword, saltRounds);
|
||||||
logger.error(`Registration failed for user ${userid}. Registration is disabled.`);
|
const new_user = generateUserObject(userid, username, hash);
|
||||||
return;
|
// check if user exists
|
||||||
|
if (await db_api.getRecord('users', {uid: userid})) {
|
||||||
|
// user id is taken!
|
||||||
|
logger.error('Registration failed: UID is already taken!');
|
||||||
|
return null;
|
||||||
|
} else if (await db_api.getRecord('users', {name: username})) {
|
||||||
|
// user name is taken!
|
||||||
|
logger.error('Registration failed: User name is already taken!');
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
// add to db
|
||||||
|
await db_api.insertRecordIntoTable('users', new_user);
|
||||||
|
logger.verbose(`New user created: ${new_user.name}`);
|
||||||
|
return new_user;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (plaintextPassword === "") {
|
exports.deleteUser = async (uid) => {
|
||||||
res.sendStatus(400);
|
let success = false;
|
||||||
logger.error(`Registration failed for user ${userid}. A password must be provided.`);
|
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
|
||||||
return;
|
const user_folder = path.join(__dirname, usersFileFolder, uid);
|
||||||
|
const user_db_obj = await db_api.getRecord('users', {uid: uid});
|
||||||
|
if (user_db_obj) {
|
||||||
|
// user exists, let's delete
|
||||||
|
await fs.remove(user_folder);
|
||||||
|
await db_api.removeRecord('users', {uid: uid});
|
||||||
|
success = true;
|
||||||
|
} else {
|
||||||
|
logger.error(`Could not find user with uid ${uid}`);
|
||||||
}
|
}
|
||||||
|
return success;
|
||||||
bcrypt.hash(plaintextPassword, saltRounds)
|
|
||||||
.then(async function(hash) {
|
|
||||||
let new_user = generateUserObject(userid, username, hash);
|
|
||||||
// check if user exists
|
|
||||||
if (await db_api.getRecord('users', {uid: userid})) {
|
|
||||||
// user id is taken!
|
|
||||||
logger.error('Registration failed: UID is already taken!');
|
|
||||||
res.status(409).send('UID is already taken!');
|
|
||||||
} else if (await db_api.getRecord('users', {name: username})) {
|
|
||||||
// user name is taken!
|
|
||||||
logger.error('Registration failed: User name is already taken!');
|
|
||||||
res.status(409).send('User name is already taken!');
|
|
||||||
} else {
|
|
||||||
// add to db
|
|
||||||
await db_api.insertRecordIntoTable('users', new_user);
|
|
||||||
logger.verbose(`New user created: ${new_user.name}`);
|
|
||||||
res.send({
|
|
||||||
user: new_user
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(function(result) {
|
|
||||||
|
|
||||||
})
|
|
||||||
.catch(function(err) {
|
|
||||||
logger.error(err);
|
|
||||||
if( err.code == 'ER_DUP_ENTRY' ) {
|
|
||||||
res.status(409).send('UserId already taken');
|
|
||||||
} else {
|
|
||||||
res.sendStatus(409);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/***************************************
|
/***************************************
|
||||||
@@ -235,7 +221,7 @@ exports.returnAuthResponse = async function(req, res) {
|
|||||||
user: req.user,
|
user: req.user,
|
||||||
token: req.token,
|
token: req.token,
|
||||||
permissions: await exports.userPermissions(req.user.uid),
|
permissions: await exports.userPermissions(req.user.uid),
|
||||||
available_permissions: consts['AVAILABLE_PERMISSIONS']
|
available_permissions: CONSTS.AVAILABLE_PERMISSIONS
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,7 +305,7 @@ exports.getUserVideos = async function(user_uid, type) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
exports.getUserVideo = async function(user_uid, file_uid, requireSharing = false) {
|
exports.getUserVideo = async function(user_uid, file_uid, requireSharing = false) {
|
||||||
let file = await db_api.getRecord('files', {file_uid: file_uid});
|
let file = await db_api.getRecord('files', {uid: file_uid});
|
||||||
|
|
||||||
// prevent unauthorized users from accessing the file info
|
// prevent unauthorized users from accessing the file info
|
||||||
if (file && !file['sharingEnabled'] && requireSharing) file = null;
|
if (file && !file['sharingEnabled'] && requireSharing) file = null;
|
||||||
@@ -406,8 +392,8 @@ exports.userPermissions = async function(user_uid) {
|
|||||||
const role_obj = await db_api.getRecord('roles', {key: role});
|
const role_obj = await db_api.getRecord('roles', {key: role});
|
||||||
const role_permissions = role_obj['permissions'];
|
const role_permissions = role_obj['permissions'];
|
||||||
|
|
||||||
for (let i = 0; i < consts['AVAILABLE_PERMISSIONS'].length; i++) {
|
for (let i = 0; i < CONSTS.AVAILABLE_PERMISSIONS.length; i++) {
|
||||||
let permission = consts['AVAILABLE_PERMISSIONS'][i];
|
let permission = CONSTS.AVAILABLE_PERMISSIONS[i];
|
||||||
|
|
||||||
const user_has_explicit_permission = user_obj['permissions'].includes(permission);
|
const user_has_explicit_permission = user_obj['permissions'].includes(permission);
|
||||||
const permission_in_overrides = user_obj['permission_overrides'].includes(permission);
|
const permission_in_overrides = user_obj['permission_overrides'].includes(permission);
|
||||||
|
|||||||
@@ -32,10 +32,8 @@ async function categorize(file_jsons) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < file_jsons.length; i++) {
|
for (const file_json of file_jsons) {
|
||||||
const file_json = file_jsons[i];
|
for (const category of categories) {
|
||||||
for (let j = 0; j < categories.length; j++) {
|
|
||||||
const category = categories[j];
|
|
||||||
const rules = category['rules'];
|
const rules = category['rules'];
|
||||||
|
|
||||||
// if rules for current category apply, then that is the selected category
|
// if rules for current category apply, then that is the selected category
|
||||||
|
|||||||
@@ -269,7 +269,8 @@ exports.AVAILABLE_PERMISSIONS = [
|
|||||||
'tasks_manager'
|
'tasks_manager'
|
||||||
];
|
];
|
||||||
|
|
||||||
exports.DETAILS_BIN_PATH = 'node_modules/youtube-dl/bin/details'
|
exports.DETAILS_BIN_PATH = 'appdata/youtube-dl.json'
|
||||||
|
exports.OUTDATED_YOUTUBEDL_VERSION = "2020.00.00";
|
||||||
|
|
||||||
// args that have a value after it (e.g. -o <output> or -f <format>)
|
// args that have a value after it (e.g. -o <output> or -f <format>)
|
||||||
const YTDL_ARGS_WITH_VALUES = [
|
const YTDL_ARGS_WITH_VALUES = [
|
||||||
@@ -347,9 +348,11 @@ const YTDL_ARGS_WITH_VALUES = [
|
|||||||
'--convert-subs'
|
'--convert-subs'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
exports.SUBSCRIPTION_BACKUP_PATH = 'subscription_backup.json'
|
||||||
|
|
||||||
// we're using a Set here for performance
|
// we're using a Set here for performance
|
||||||
exports.YTDL_ARGS_WITH_VALUES = new Set(YTDL_ARGS_WITH_VALUES);
|
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';
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ const path = require('path');
|
|||||||
const NodeID3 = require('node-id3')
|
const NodeID3 = require('node-id3')
|
||||||
const Mutex = require('async-mutex').Mutex;
|
const Mutex = require('async-mutex').Mutex;
|
||||||
|
|
||||||
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');
|
||||||
@@ -20,11 +19,13 @@ const archive_api = require('./archive');
|
|||||||
const mutex = new Mutex();
|
const mutex = new Mutex();
|
||||||
let should_check_downloads = true;
|
let should_check_downloads = true;
|
||||||
|
|
||||||
|
const download_to_child_process = {};
|
||||||
|
|
||||||
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,
|
||||||
@@ -83,8 +84,11 @@ exports.pauseDownload = async (download_uid) => {
|
|||||||
} else if (download['finished']) {
|
} else if (download['finished']) {
|
||||||
logger.info(`Download ${download_uid} could not be paused before completing.`);
|
logger.info(`Download ${download_uid} could not be paused before completing.`);
|
||||||
return false;
|
return false;
|
||||||
|
} else {
|
||||||
|
logger.info(`Pausing download ${download_uid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
killActiveDownload(download);
|
||||||
return await db_api.updateRecord('download_queue', {uid: download_uid}, {paused: true, running: false});
|
return await db_api.updateRecord('download_queue', {uid: download_uid}, {paused: true, running: false});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,21 +123,28 @@ exports.cancelDownload = async (download_uid) => {
|
|||||||
} else if (download['finished']) {
|
} else if (download['finished']) {
|
||||||
logger.info(`Download ${download_uid} could not be cancelled before completing.`);
|
logger.info(`Download ${download_uid} could not be cancelled before completing.`);
|
||||||
return false;
|
return false;
|
||||||
|
} else {
|
||||||
|
logger.info(`Cancelling download ${download_uid}`);
|
||||||
}
|
}
|
||||||
return await db_api.updateRecord('download_queue', {uid: download_uid}, {cancelled: true, running: false});
|
|
||||||
|
killActiveDownload(download);
|
||||||
|
await handleDownloadError(download_uid, 'Cancelled', 'cancelled');
|
||||||
|
return await db_api.updateRecord('download_queue', {uid: download_uid}, {cancelled: true});
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.clearDownload = async (download_uid) => {
|
exports.clearDownload = async (download_uid) => {
|
||||||
return await db_api.removeRecord('download_queue', {uid: download_uid});
|
return await db_api.removeRecord('download_queue', {uid: download_uid});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDownloadError(download, error_message, error_type = null) {
|
async function handleDownloadError(download_uid, error_message, error_type = null) {
|
||||||
if (!download || !download['uid']) return;
|
if (!download_uid) return;
|
||||||
|
const download = await db_api.getRecord('download_queue', {uid: download_uid});
|
||||||
|
if (!download || download['error']) return;
|
||||||
notifications_api.sendDownloadErrorNotification(download, download['user_uid'], error_message, error_type);
|
notifications_api.sendDownloadErrorNotification(download, download['user_uid'], error_message, error_type);
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -179,22 +190,30 @@ async function checkDownloads() {
|
|||||||
if (waiting_download['sub_id']) {
|
if (waiting_download['sub_id']) {
|
||||||
const sub_missing = !(await db_api.getRecord('subscriptions', {id: waiting_download['sub_id']}));
|
const sub_missing = !(await db_api.getRecord('subscriptions', {id: waiting_download['sub_id']}));
|
||||||
if (sub_missing) {
|
if (sub_missing) {
|
||||||
handleDownloadError(waiting_download, `Download failed as subscription with id '${waiting_download['sub_id']}' is missing!`, 'sub_id_missing');
|
handleDownloadError(waiting_download['uid'], `Download failed as subscription with id '${waiting_download['sub_id']}' is missing!`, 'sub_id_missing');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// move to next step
|
// move to next step
|
||||||
running_downloads_count++;
|
running_downloads_count++;
|
||||||
if (waiting_download['step_index'] === 0) {
|
if (waiting_download['step_index'] === 0) {
|
||||||
collectInfo(waiting_download['uid']);
|
exports.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']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectInfo(download_uid) {
|
function killActiveDownload(download) {
|
||||||
|
const child_process = download_to_child_process[download['uid']];
|
||||||
|
if (download['step_index'] === 2 && child_process) {
|
||||||
|
youtubedl_api.killYoutubeDLProcess(child_process);
|
||||||
|
delete download_to_child_process[download['uid']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.collectInfo = async (download_uid) => {
|
||||||
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;
|
||||||
@@ -217,21 +236,21 @@ async function collectInfo(download_uid) {
|
|||||||
// get video info prior to download
|
// get video info prior to download
|
||||||
let info = download['prefetched_info'] ? download['prefetched_info'] : await exports.getVideoInfoByURL(url, args, download_uid);
|
let info = download['prefetched_info'] ? download['prefetched_info'] : await exports.getVideoInfoByURL(url, args, download_uid);
|
||||||
|
|
||||||
if (!info) {
|
if (!info || info.length === 0) {
|
||||||
// info failed, error presumably already recorded
|
// info failed, error presumably already recorded
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// in subscriptions we don't care if archive mode is enabled, but we already removed archived videos from subs by this point
|
// in subscriptions we don't care if archive mode is enabled, but we already removed archived videos from subs by this point
|
||||||
const useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
|
const useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
|
||||||
if (useYoutubeDLArchive && !options.ignoreArchive) {
|
if (useYoutubeDLArchive && !options.ignoreArchive && info.length === 1) {
|
||||||
const exists_in_archive = await archive_api.existsInArchive(info['extractor'], info['id'], type, download['user_uid'], download['sub_id']);
|
const info_obj = info[0];
|
||||||
|
const exists_in_archive = await archive_api.existsInArchive(info['extractor'], info_obj['id'], type, download['user_uid'], download['sub_id']);
|
||||||
if (exists_in_archive) {
|
if (exists_in_archive) {
|
||||||
const error = `File '${info['title']}' already exists in archive! Disable the archive or override to continue downloading.`;
|
const error = `File '${info_obj['title']}' already exists in archive! Disable the archive or override to continue downloading.`;
|
||||||
logger.warn(error);
|
logger.warn(error);
|
||||||
if (download_uid) {
|
if (download_uid) {
|
||||||
const download = await db_api.getRecord('download_queue', {uid: download_uid});
|
await handleDownloadError(download_uid, error, 'exists_in_archive');
|
||||||
await handleDownloadError(download, error, 'exists_in_archive');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,7 +259,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 === 1 || 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,26 +278,22 @@ 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 title = info.length > 1 ? info[0]['playlist_title'] || info[0]['playlist'] : info[0]['title'];
|
||||||
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,
|
||||||
options: options,
|
options: options,
|
||||||
files_to_check_for_progress: files_to_check_for_progress,
|
files_to_check_for_progress: files_to_check_for_progress,
|
||||||
expected_file_size: expected_file_size,
|
expected_file_size: expected_file_size,
|
||||||
title: playlist_title ? playlist_title : info['title'],
|
title: title,
|
||||||
category: stripped_category,
|
category: stripped_category,
|
||||||
prefetched_info: null
|
prefetched_info: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadQueuedFile(download_uid) {
|
exports.downloadQueuedFile = async(download_uid, customDownloadHandler = null) => {
|
||||||
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,121 +321,112 @@ 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) {
|
let {child_process, callback} = await youtubedl_api.runYoutubeDL(url, args, customDownloadHandler);
|
||||||
const file_objs = [];
|
if (child_process) download_to_child_process[download['uid']] = child_process;
|
||||||
let end_time = Date.now();
|
const {parsed_output, err} = await callback;
|
||||||
let difference = (end_time - start_time)/1000;
|
clearInterval(download_checker);
|
||||||
logger.debug(`${type === 'audio' ? 'Audio' : 'Video'} download delay: ${difference} seconds.`);
|
let end_time = Date.now();
|
||||||
clearInterval(download_checker);
|
let difference = (end_time - start_time)/1000;
|
||||||
if (err) {
|
logger.debug(`${type === 'audio' ? 'Audio' : 'Video'} download delay: ${difference} seconds.`);
|
||||||
logger.error(err.stderr);
|
if (!parsed_output) {
|
||||||
await handleDownloadError(download, err.stderr, 'unknown_error');
|
const errored_download = await db_api.getRecord('download_queue', {uid: download_uid});
|
||||||
|
if (errored_download && errored_download['paused']) return;
|
||||||
|
logger.error(err.toString());
|
||||||
|
await handleDownloadError(download_uid, err.toString(), '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_uid, 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
|
||||||
|
container = await files_api.createPlaylist(download['title'], 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_uid, 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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// helper functions
|
// helper functions
|
||||||
|
|
||||||
exports.generateArgs = async (url, type, options, user_uid = null, simulated = false) => {
|
exports.generateArgs = async (url, type, options, user_uid = null, simulated = false) => {
|
||||||
const default_downloader = utils.getCurrentDownloader() || config_api.getConfigItem('ytdl_default_downloader');
|
const default_downloader = config_api.getConfigItem('ytdl_default_downloader');
|
||||||
|
|
||||||
if (!simulated && (default_downloader === 'youtube-dl' || default_downloader === 'youtube-dlc')) {
|
if (!simulated && (default_downloader === 'youtube-dl' || default_downloader === 'youtube-dlc')) {
|
||||||
logger.warn('It is recommended you use yt-dlp! To prevent failed downloads, change the downloader in your settings menu to yt-dlp and restart your instance.')
|
logger.warn('It is recommended you use yt-dlp! To prevent failed downloads, change the downloader in your settings menu to yt-dlp and restart your instance.')
|
||||||
@@ -551,58 +557,30 @@ exports.generateArgs = async (url, type, options, user_uid = null, simulated = f
|
|||||||
}
|
}
|
||||||
|
|
||||||
exports.getVideoInfoByURL = async (url, args = [], download_uid = null) => {
|
exports.getVideoInfoByURL = async (url, args = [], download_uid = null) => {
|
||||||
return new Promise(resolve => {
|
// remove bad args
|
||||||
// remove bad args
|
const temp_args = utils.filterArgs(args, ['--no-simulate']);
|
||||||
const temp_args = utils.filterArgs(args, ['--no-simulate']);
|
const new_args = [...temp_args];
|
||||||
const new_args = [...temp_args];
|
|
||||||
|
|
||||||
const archiveArgIndex = new_args.indexOf('--download-archive');
|
const archiveArgIndex = new_args.indexOf('--download-archive');
|
||||||
if (archiveArgIndex !== -1) {
|
if (archiveArgIndex !== -1) {
|
||||||
new_args.splice(archiveArgIndex, 2);
|
new_args.splice(archiveArgIndex, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
new_args.push('--dump-json');
|
||||||
|
|
||||||
|
let {callback} = await youtubedl_api.runYoutubeDL(url, new_args);
|
||||||
|
const {parsed_output, err} = await callback;
|
||||||
|
if (!parsed_output || parsed_output.length === 0) {
|
||||||
|
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}`;
|
||||||
|
logger.error(error_message);
|
||||||
|
if (download_uid) {
|
||||||
|
await handleDownloadError(download_uid, error_message, 'info_retrieve_failed');
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
new_args.push('--dump-json');
|
return parsed_output;
|
||||||
|
|
||||||
youtubedl.exec(url, new_args, {maxBuffer: Infinity}, async (err, output) => {
|
|
||||||
if (output) {
|
|
||||||
let outputs = [];
|
|
||||||
try {
|
|
||||||
for (let i = 0; i < output.length; i++) {
|
|
||||||
let output_json = null;
|
|
||||||
try {
|
|
||||||
output_json = JSON.parse(output[i]);
|
|
||||||
} catch(e) {
|
|
||||||
output_json = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!output_json) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
outputs.push(output_json);
|
|
||||||
}
|
|
||||||
resolve(outputs.length === 1 ? outputs[0] : outputs);
|
|
||||||
} catch(e) {
|
|
||||||
const error = `Error while retrieving info on video with URL ${url} with the following message: output JSON could not be parsed. Output JSON: ${output}`;
|
|
||||||
logger.error(error);
|
|
||||||
if (download_uid) {
|
|
||||||
const download = await db_api.getRecord('download_queue', {uid: download_uid});
|
|
||||||
await handleDownloadError(download, error, 'parse_failed');
|
|
||||||
}
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let error_message = `Error while retrieving info on video with URL ${url} with the following message: ${err}`;
|
|
||||||
if (err.stderr) error_message += `\n\n${err.stderr}`;
|
|
||||||
logger.error(error_message);
|
|
||||||
if (download_uid) {
|
|
||||||
const download = await db_api.getRecord('download_queue', {uid: download_uid});
|
|
||||||
await handleDownloadError(download, error_message, 'info_retrieve_failed');
|
|
||||||
}
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterArgs(args, isAudio) {
|
function filterArgs(args, isAudio) {
|
||||||
@@ -621,6 +599,7 @@ async function checkDownloadPercent(download_uid) {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const download = await db_api.getRecord('download_queue', {uid: download_uid});
|
const download = await db_api.getRecord('download_queue', {uid: download_uid});
|
||||||
|
if (!download) return;
|
||||||
const files_to_check_for_progress = download['files_to_check_for_progress'];
|
const files_to_check_for_progress = download['files_to_check_for_progress'];
|
||||||
const resulting_file_size = download['expected_file_size'];
|
const resulting_file_size = download['expected_file_size'];
|
||||||
|
|
||||||
|
|||||||
@@ -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 "$@"
|
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
"command-exists": "^1.2.9",
|
"command-exists": "^1.2.9",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"config": "^3.2.3",
|
"config": "^3.2.3",
|
||||||
|
"execa": "^5.1.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
"feed": "^4.2.2",
|
"feed": "^4.2.2",
|
||||||
@@ -61,10 +62,10 @@
|
|||||||
"read-last-lines": "^1.7.2",
|
"read-last-lines": "^1.7.2",
|
||||||
"rxjs": "^7.3.0",
|
"rxjs": "^7.3.0",
|
||||||
"shortid": "^2.2.15",
|
"shortid": "^2.2.15",
|
||||||
|
"tree-kill": "^1.2.2",
|
||||||
"unzipper": "^0.10.10",
|
"unzipper": "^0.10.10",
|
||||||
"uuidv4": "^6.2.13",
|
"uuidv4": "^6.2.13",
|
||||||
"winston": "^3.7.2",
|
"winston": "^3.7.2",
|
||||||
"xmlbuilder2": "^3.0.2",
|
"xmlbuilder2": "^3.0.2"
|
||||||
"youtube-dl": "^3.0.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const youtubedl = require('youtube-dl');
|
|
||||||
|
|
||||||
|
const youtubedl_api = require('./youtube-dl');
|
||||||
const config_api = require('./config');
|
const config_api = require('./config');
|
||||||
const archive_api = require('./archive');
|
const archive_api = require('./archive');
|
||||||
const utils = require('./utils');
|
const utils = require('./utils');
|
||||||
const logger = require('./logger');
|
const logger = require('./logger');
|
||||||
|
const CONSTS = require('./consts');
|
||||||
|
|
||||||
const debugMode = process.env.YTDL_MODE === 'debug';
|
const debugMode = process.env.YTDL_MODE === 'debug';
|
||||||
|
|
||||||
const db_api = require('./db');
|
const db_api = require('./db');
|
||||||
const downloader_api = require('./downloader');
|
const downloader_api = require('./downloader');
|
||||||
|
|
||||||
async function subscribe(sub, user_uid = null) {
|
exports.subscribe = async (sub, user_uid = null, skip_get_info = false) => {
|
||||||
const result_obj = {
|
const result_obj = {
|
||||||
success: false,
|
success: false,
|
||||||
error: ''
|
error: ''
|
||||||
};
|
};
|
||||||
return new Promise(async resolve => {
|
return new Promise(async resolve => {
|
||||||
// sub should just have url and name. here we will get isPlaylist and path
|
// sub should just have url and name. here we will get isPlaylist and path
|
||||||
sub.isPlaylist = sub.url.includes('playlist');
|
sub.isPlaylist = sub.isPlaylist || sub.url.includes('playlist');
|
||||||
sub.videos = [];
|
sub.videos = [];
|
||||||
|
|
||||||
let url_exists = !!(await db_api.getRecord('subscriptions', {url: sub.url, user_uid: user_uid}));
|
let url_exists = !!(await db_api.getRecord('subscriptions', {url: sub.url, user_uid: user_uid}));
|
||||||
@@ -32,12 +33,13 @@ async function subscribe(sub, user_uid = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 = await getSubscriptionInfo(sub);
|
let success = skip_get_info ? true : await getSubscriptionInfo(sub);
|
||||||
|
exports.writeSubscriptionMetadata(sub);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
getVideosForSub(sub, user_uid);
|
if (!sub.paused) exports.getVideosForSub(sub.id);
|
||||||
} else {
|
} else {
|
||||||
logger.error('Subscribe: Failed to get subscription info. Subscribe failed.')
|
logger.error('Subscribe: Failed to get subscription info. Subscribe failed.')
|
||||||
}
|
}
|
||||||
@@ -61,55 +63,41 @@ async function getSubscriptionInfo(sub) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise(async resolve => {
|
let {callback} = await youtubedl_api.runYoutubeDL(sub.url, downloadConfig);
|
||||||
youtubedl.exec(sub.url, downloadConfig, {maxBuffer: Infinity}, async (err, output) => {
|
const {parsed_output, err} = await callback;
|
||||||
if (debugMode) {
|
if (err) {
|
||||||
logger.info('Subscribe: got info for subscription ' + sub.id);
|
logger.error(err.stderr);
|
||||||
}
|
return false;
|
||||||
if (err) {
|
}
|
||||||
logger.error(err.stderr);
|
logger.verbose('Subscribe: got info for subscription ' + sub.id);
|
||||||
resolve(false);
|
for (const output_json of parsed_output) {
|
||||||
} else if (output) {
|
if (!output_json) {
|
||||||
if (output.length === 0 || (output.length === 1 && output[0] === '')) {
|
continue;
|
||||||
logger.verbose('Could not get info for ' + sub.id);
|
}
|
||||||
resolve(false);
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
if (!sub.name) {
|
|
||||||
if (sub.isPlaylist) {
|
|
||||||
sub.name = output_json.playlist_title ? output_json.playlist_title : output_json.playlist;
|
|
||||||
} else {
|
|
||||||
sub.name = output_json.uploader;
|
|
||||||
}
|
|
||||||
// if it's now valid, update
|
|
||||||
if (sub.name) {
|
|
||||||
let sub_name = sub.name;
|
|
||||||
const sub_name_exists = await db_api.getRecord('subscriptions', {name: sub.name, isPlaylist: sub.isPlaylist, user_uid: sub.user_uid});
|
|
||||||
if (sub_name_exists) sub_name += ` - ${sub.id}`;
|
|
||||||
await db_api.updateRecord('subscriptions', {id: sub.id}, {name: sub_name});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: get even more info
|
if (!sub.name) {
|
||||||
|
if (sub.isPlaylist) {
|
||||||
resolve(true);
|
sub.name = output_json.playlist_title ? output_json.playlist_title : output_json.playlist;
|
||||||
}
|
} else {
|
||||||
resolve(false);
|
sub.name = output_json.uploader;
|
||||||
}
|
}
|
||||||
});
|
// if it's now valid, update
|
||||||
});
|
if (sub.name) {
|
||||||
|
let sub_name = sub.name;
|
||||||
|
const sub_name_exists = await db_api.getRecord('subscriptions', {name: sub.name, isPlaylist: sub.isPlaylist, user_uid: sub.user_uid});
|
||||||
|
if (sub_name_exists) sub_name += ` - ${sub.id}`;
|
||||||
|
await db_api.updateRecord('subscriptions', {id: sub.id}, {name: sub_name});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unsubscribe(sub, deleteMode, user_uid = null) {
|
exports.unsubscribe = async (sub_id, deleteMode, user_uid = null) => {
|
||||||
|
const sub = await exports.getSubscription(sub_id);
|
||||||
let basePath = null;
|
let basePath = null;
|
||||||
if (user_uid)
|
if (user_uid)
|
||||||
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
|
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
|
||||||
@@ -132,6 +120,7 @@ async function unsubscribe(sub, deleteMode, user_uid = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await killSubDownloads(sub_id, true);
|
||||||
await db_api.removeRecord('subscriptions', {id: id});
|
await db_api.removeRecord('subscriptions', {id: id});
|
||||||
await db_api.removeAllRecords('files', {sub_id: id});
|
await db_api.removeAllRecords('files', {sub_id: id});
|
||||||
|
|
||||||
@@ -148,7 +137,7 @@ async function unsubscribe(sub, deleteMode, user_uid = null) {
|
|||||||
await db_api.removeAllRecords('archives', {sub_id: sub.id});
|
await db_api.removeAllRecords('archives', {sub_id: sub.id});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null, user_uid = null) {
|
exports.deleteSubscriptionFile = async (sub, file, deleteForever, file_uid = null, user_uid = null) => {
|
||||||
if (typeof sub === 'string') {
|
if (typeof sub === 'string') {
|
||||||
// TODO: fix bad workaround where sub is a sub_id
|
// TODO: fix bad workaround where sub is a sub_id
|
||||||
sub = await db_api.getRecord('subscriptions', {sub_id: sub});
|
sub = await db_api.getRecord('subscriptions', {sub_id: sub});
|
||||||
@@ -216,12 +205,18 @@ async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getVideosForSub(sub, user_uid = null) {
|
exports.getVideosForSub = async (sub_id) => {
|
||||||
const latest_sub_obj = await getSubscription(sub.id);
|
const sub = await exports.getSubscription(sub_id);
|
||||||
if (!latest_sub_obj || latest_sub_obj['downloading']) {
|
if (!sub || sub['downloading']) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_getVideosForSub(sub);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _getVideosForSub(sub) {
|
||||||
|
const user_uid = sub['user_uid'];
|
||||||
updateSubscriptionProperty(sub, {downloading: true}, user_uid);
|
updateSubscriptionProperty(sub, {downloading: true}, user_uid);
|
||||||
|
|
||||||
// get basePath
|
// get basePath
|
||||||
@@ -239,84 +234,52 @@ async function getVideosForSub(sub, user_uid = null) {
|
|||||||
// get videos
|
// get videos
|
||||||
logger.verbose(`Subscription: getting list of videos to download for ${sub.name} with args: ${downloadConfig.join(',')}`);
|
logger.verbose(`Subscription: getting list of videos to download for ${sub.name} with args: ${downloadConfig.join(',')}`);
|
||||||
|
|
||||||
return new Promise(async resolve => {
|
let {child_process, callback} = await youtubedl_api.runYoutubeDL(sub.url, downloadConfig);
|
||||||
youtubedl.exec(sub.url, downloadConfig, {maxBuffer: Infinity}, async function(err, output) {
|
updateSubscriptionProperty(sub, {child_process: child_process}, user_uid);
|
||||||
// cleanup
|
const {parsed_output, err} = await callback;
|
||||||
updateSubscriptionProperty(sub, {downloading: false}, user_uid);
|
updateSubscriptionProperty(sub, {downloading: false, child_process: null}, user_uid);
|
||||||
|
if (!parsed_output) {
|
||||||
|
logger.error('Subscription check failed!');
|
||||||
|
if (err) logger.error(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// remove temporary archive file if it exists
|
// remove temporary archive file if it exists
|
||||||
const archive_path = path.join(appendedBasePath, 'archive.txt');
|
const archive_path = path.join(appendedBasePath, 'archive.txt');
|
||||||
const archive_exists = await fs.pathExists(archive_path);
|
const archive_exists = await fs.pathExists(archive_path);
|
||||||
if (archive_exists) {
|
if (archive_exists) {
|
||||||
await fs.unlink(archive_path);
|
await fs.unlink(archive_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.verbose('Subscription: finished check for ' + sub.name);
|
logger.verbose('Subscription: finished check for ' + sub.name);
|
||||||
if (err && !output) {
|
const files_to_download = await handleOutputJSON(parsed_output, sub, user_uid);
|
||||||
logger.error(err.stderr ? err.stderr : err.message);
|
return files_to_download;
|
||||||
if (err.stderr.includes('This video is unavailable') || err.stderr.includes('Private video')) {
|
|
||||||
logger.info('An error was encountered with at least one video, backup method will be used.')
|
|
||||||
try {
|
|
||||||
const outputs = err.stdout.split(/\r\n|\r|\n/);
|
|
||||||
const files_to_download = await handleOutputJSON(outputs, sub, user_uid);
|
|
||||||
resolve(files_to_download);
|
|
||||||
} catch(e) {
|
|
||||||
logger.error('Backup method failed. See error below:');
|
|
||||||
logger.error(e);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.error('Subscription check failed!');
|
|
||||||
}
|
|
||||||
resolve(false);
|
|
||||||
} else if (output) {
|
|
||||||
const files_to_download = await handleOutputJSON(output, sub, user_uid);
|
|
||||||
resolve(files_to_download);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, err => {
|
|
||||||
logger.error(err);
|
|
||||||
updateSubscriptionProperty(sub, {downloading: false}, user_uid);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateOptionsForSubscriptionDownload(sub, user_uid) {
|
exports.generateOptionsForSubscriptionDownload = (sub, user_uid) => {
|
||||||
let basePath = null;
|
let basePath = null;
|
||||||
if (user_uid)
|
if (user_uid)
|
||||||
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
|
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
|
||||||
@@ -371,10 +334,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(',,');
|
||||||
@@ -408,7 +374,7 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
|
|||||||
downloadConfig.push('-r', rate_limit);
|
downloadConfig.push('-r', rate_limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
const default_downloader = utils.getCurrentDownloader() || config_api.getConfigItem('ytdl_default_downloader');
|
const default_downloader = config_api.getConfigItem('ytdl_default_downloader');
|
||||||
if (default_downloader === 'yt-dlp') {
|
if (default_downloader === 'yt-dlp') {
|
||||||
downloadConfig.push('--no-clean-info-json');
|
downloadConfig.push('--no-clean-info-json');
|
||||||
}
|
}
|
||||||
@@ -439,36 +405,66 @@ async function getFilesToDownload(sub, output_jsons) {
|
|||||||
return files_to_download;
|
return files_to_download;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exports.cancelCheckSubscription = async (sub_id) => {
|
||||||
|
const sub = await exports.getSubscription(sub_id);
|
||||||
|
if (!sub['downloading'] && !sub['child_process']) {
|
||||||
|
logger.error('Failed to cancel subscription check, verify that it is still running!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function getSubscriptions(user_uid = null) {
|
// if check is ongoing
|
||||||
|
if (sub['child_process']) {
|
||||||
|
const child_process = sub['child_process'];
|
||||||
|
youtubedl_api.killYoutubeDLProcess(child_process);
|
||||||
|
}
|
||||||
|
|
||||||
|
// cancel activate video downloads
|
||||||
|
await killSubDownloads(sub_id);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function killSubDownloads(sub_id, remove_downloads = false) {
|
||||||
|
const sub_downloads = await db_api.getRecords('download_queue', {sub_id: sub_id});
|
||||||
|
for (const sub_download of sub_downloads) {
|
||||||
|
if (sub_download['running'])
|
||||||
|
await downloader_api.cancelDownload(sub_download['uid']);
|
||||||
|
if (remove_downloads)
|
||||||
|
await db_api.removeRecord('download_queue', {uid: sub_download['uid']});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.getSubscriptions = async (user_uid = null) => {
|
||||||
|
// TODO: fix issue where the downloading property may not match getSubscription()
|
||||||
return await db_api.getRecords('subscriptions', {user_uid: user_uid});
|
return await db_api.getRecords('subscriptions', {user_uid: user_uid});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAllSubscriptions() {
|
exports.getAllSubscriptions = async () => {
|
||||||
const all_subs = await db_api.getRecords('subscriptions');
|
const all_subs = await db_api.getRecords('subscriptions');
|
||||||
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
|
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
|
||||||
return all_subs.filter(sub => !!(sub.user_uid) === !!multiUserMode);
|
return all_subs.filter(sub => !!(sub.user_uid) === !!multiUserMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSubscription(subID) {
|
exports.getSubscription = async (subID) => {
|
||||||
// stringify and parse because we may override the 'downloading' property
|
// stringify and parse because we may override the 'downloading' property
|
||||||
const sub = JSON.parse(JSON.stringify(await db_api.getRecord('subscriptions', {id: subID})));
|
const sub = JSON.parse(JSON.stringify(await db_api.getRecord('subscriptions', {id: subID})));
|
||||||
// now with the download_queue, we may need to override 'downloading'
|
// now with the download_queue, we may need to override 'downloading'
|
||||||
const current_downloads = await db_api.getRecords('download_queue', {running: true, sub_id: sub.id}, true);
|
const current_downloads = await db_api.getRecords('download_queue', {running: true, sub_id: subID}, true);
|
||||||
if (!sub['downloading']) sub['downloading'] = current_downloads > 0;
|
if (!sub['downloading']) sub['downloading'] = current_downloads > 0;
|
||||||
return sub;
|
return sub;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSubscriptionByName(subName, user_uid = null) {
|
exports.getSubscriptionByName = async (subName, user_uid = null) => {
|
||||||
return await db_api.getRecord('subscriptions', {name: subName, user_uid: user_uid});
|
return await db_api.getRecord('subscriptions', {name: subName, user_uid: user_uid});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateSubscription(sub) {
|
exports.updateSubscription = async (sub) => {
|
||||||
await db_api.updateRecord('subscriptions', {id: sub.id}, sub);
|
await db_api.updateRecord('subscriptions', {id: sub.id}, sub);
|
||||||
|
exports.writeSubscriptionMetadata(sub);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateSubscriptionPropertyMultiple(subs, assignment_obj) {
|
exports.updateSubscriptionPropertyMultiple = async (subs, assignment_obj) => {
|
||||||
subs.forEach(async sub => {
|
subs.forEach(async sub => {
|
||||||
await updateSubscriptionProperty(sub, assignment_obj);
|
await updateSubscriptionProperty(sub, assignment_obj);
|
||||||
});
|
});
|
||||||
@@ -480,6 +476,16 @@ async function updateSubscriptionProperty(sub, assignment_obj) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exports.writeSubscriptionMetadata = (sub) => {
|
||||||
|
let basePath = sub.user_uid ? path.join(config_api.getConfigItem('ytdl_users_base_path'), sub.user_uid, 'subscriptions')
|
||||||
|
: config_api.getConfigItem('ytdl_subscriptions_base_path');
|
||||||
|
const appendedBasePath = getAppendedBasePath(sub, basePath);
|
||||||
|
const metadata_path = path.join(appendedBasePath, CONSTS.SUBSCRIPTION_BACKUP_PATH);
|
||||||
|
|
||||||
|
fs.ensureDirSync(appendedBasePath);
|
||||||
|
fs.writeJSONSync(metadata_path, sub);
|
||||||
|
}
|
||||||
|
|
||||||
async function setFreshUploads(sub) {
|
async function setFreshUploads(sub) {
|
||||||
const sub_files = await db_api.getRecords('files', {sub_id: sub.id});
|
const sub_files = await db_api.getRecords('files', {sub_id: sub.id});
|
||||||
if (!sub_files) return;
|
if (!sub_files) return;
|
||||||
@@ -508,24 +514,22 @@ async function checkVideoIfBetterExists(file_obj, sub, user_uid) {
|
|||||||
const downloadConfig = await generateArgsForSubscription(sub, user_uid, true, new_path);
|
const downloadConfig = await generateArgsForSubscription(sub, user_uid, true, new_path);
|
||||||
logger.verbose(`Checking if a better version of the fresh upload ${file_obj['id']} exists.`);
|
logger.verbose(`Checking if a better version of the fresh upload ${file_obj['id']} exists.`);
|
||||||
// simulate a download to verify that a better version exists
|
// simulate a download to verify that a better version exists
|
||||||
youtubedl.getInfo(file_obj['url'], downloadConfig, async (err, output) => {
|
|
||||||
if (err) {
|
const info = await downloader_api.getVideoInfoByURL(file_obj['url'], downloadConfig);
|
||||||
// video is not available anymore for whatever reason
|
if (info && info.length === 1) {
|
||||||
} else if (output) {
|
const metric_to_compare = sub.type === 'audio' ? 'abr' : 'height';
|
||||||
const metric_to_compare = sub.type === 'audio' ? 'abr' : 'height';
|
if (info[metric_to_compare] > file_obj[metric_to_compare]) {
|
||||||
if (output[metric_to_compare] > file_obj[metric_to_compare]) {
|
// download new video as the simulated one is better
|
||||||
// download new video as the simulated one is better
|
let {callback} = await youtubedl_api.runYoutubeDL(sub.url, downloadConfig);
|
||||||
youtubedl.exec(file_obj['url'], downloadConfig, {maxBuffer: Infinity}, async (err, output) => {
|
const {parsed_output, err} = await callback;
|
||||||
if (err) {
|
if (err) {
|
||||||
logger.verbose(`Failed to download better version of video ${file_obj['id']}`);
|
logger.verbose(`Failed to download better version of video ${file_obj['id']}`);
|
||||||
} else if (output) {
|
} else if (parsed_output) {
|
||||||
logger.verbose(`Successfully upgraded video ${file_obj['id']}'s ${metric_to_compare} from ${file_obj[metric_to_compare]} to ${output[metric_to_compare]}`);
|
logger.verbose(`Successfully upgraded video ${file_obj['id']}'s ${metric_to_compare} from ${file_obj[metric_to_compare]} to ${info[metric_to_compare]}`);
|
||||||
await db_api.setVideoProperty(file_obj['uid'], {[metric_to_compare]: output[metric_to_compare]});
|
await db_api.setVideoProperty(file_obj['uid'], {[metric_to_compare]: info[metric_to_compare]});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
await db_api.setVideoProperty(file_obj['uid'], {'fresh_upload': false});
|
await db_api.setVideoProperty(file_obj['uid'], {'fresh_upload': false});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,17 +538,3 @@ async function checkVideoIfBetterExists(file_obj, sub, user_uid) {
|
|||||||
function getAppendedBasePath(sub, base_path) {
|
function getAppendedBasePath(sub, base_path) {
|
||||||
return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name);
|
return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getSubscription : getSubscription,
|
|
||||||
getSubscriptionByName : getSubscriptionByName,
|
|
||||||
getSubscriptions : getSubscriptions,
|
|
||||||
getAllSubscriptions : getAllSubscriptions,
|
|
||||||
updateSubscription : updateSubscription,
|
|
||||||
subscribe : subscribe,
|
|
||||||
unsubscribe : unsubscribe,
|
|
||||||
deleteSubscriptionFile : deleteSubscriptionFile,
|
|
||||||
getVideosForSub : getVideosForSub,
|
|
||||||
updateSubscriptionPropertyMultiple : updateSubscriptionPropertyMultiple,
|
|
||||||
generateOptionsForSubscriptionDownload: generateOptionsForSubscriptionDownload
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ const notifications_api = require('./notifications');
|
|||||||
const youtubedl_api = require('./youtube-dl');
|
const youtubedl_api = require('./youtube-dl');
|
||||||
const archive_api = require('./archive');
|
const archive_api = require('./archive');
|
||||||
const files_api = require('./files');
|
const files_api = require('./files');
|
||||||
|
const subscriptions_api = require('./subscriptions');
|
||||||
|
const config_api = require('./config');
|
||||||
|
const auth_api = require('./authentication/auth');
|
||||||
|
const utils = require('./utils');
|
||||||
|
const logger = require('./logger');
|
||||||
|
const CONSTS = require('./consts');
|
||||||
|
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const logger = require('./logger');
|
const path = require('path');
|
||||||
const scheduler = require('node-schedule');
|
const scheduler = require('node-schedule');
|
||||||
|
const { uuid } = require('uuidv4');
|
||||||
|
|
||||||
const TASKS = {
|
const TASKS = {
|
||||||
backup_local_db: {
|
backup_local_db: {
|
||||||
@@ -47,6 +54,11 @@ const TASKS = {
|
|||||||
run: archive_api.importArchives,
|
run: archive_api.importArchives,
|
||||||
title: 'Import legacy archives',
|
title: 'Import legacy archives',
|
||||||
job: null
|
job: null
|
||||||
|
},
|
||||||
|
rebuild_database: {
|
||||||
|
run: rebuildDB,
|
||||||
|
title: 'Rebuild database',
|
||||||
|
job: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,4 +277,68 @@ async function autoDeleteFiles(data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function rebuildDB() {
|
||||||
|
await db_api.backupDB();
|
||||||
|
let subs_to_add = await guessSubscriptions(false);
|
||||||
|
subs_to_add = subs_to_add.concat(await guessSubscriptions(true));
|
||||||
|
const users_to_add = await guessUsers();
|
||||||
|
for (const user_to_add of users_to_add) {
|
||||||
|
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
|
||||||
|
|
||||||
|
const user_exists = await db_api.getRecord('users', {uid: user_to_add});
|
||||||
|
if (!user_exists) {
|
||||||
|
await auth_api.registerUser(user_to_add, user_to_add, 'password');
|
||||||
|
logger.info(`Regenerated user ${user_to_add}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user_channel_subs = await guessSubscriptions(false, path.join(usersFileFolder, user_to_add), user_to_add);
|
||||||
|
const user_playlist_subs = await guessSubscriptions(true, path.join(usersFileFolder, user_to_add), user_to_add);
|
||||||
|
subs_to_add = subs_to_add.concat(user_channel_subs, user_playlist_subs);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sub_to_add of subs_to_add) {
|
||||||
|
const sub_exists = !!(await subscriptions_api.getSubscriptionByName(sub_to_add['name'], sub_to_add['user_uid']));
|
||||||
|
// TODO: we shouldn't be creating this here
|
||||||
|
const new_sub = Object.assign({}, sub_to_add, {paused: true});
|
||||||
|
if (!sub_exists) {
|
||||||
|
await subscriptions_api.subscribe(new_sub, sub_to_add['user_uid'], true);
|
||||||
|
logger.info(`Regenerated subscription ${sub_to_add['name']}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Importing unregistered files`);
|
||||||
|
await files_api.importUnregisteredFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
const guessUsers = async () => {
|
||||||
|
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
|
||||||
|
const userPaths = await utils.getDirectoriesInDirectory(usersFileFolder);
|
||||||
|
return userPaths.map(userPath => path.basename(userPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
const guessSubscriptions = async (isPlaylist, basePath = null) => {
|
||||||
|
const guessed_subs = [];
|
||||||
|
const subscriptionsFileFolder = config_api.getConfigItem('ytdl_subscriptions_base_path');
|
||||||
|
|
||||||
|
const subsSubPath = basePath ? path.join(basePath, 'subscriptions') : subscriptionsFileFolder;
|
||||||
|
const subsPath = path.join(subsSubPath, isPlaylist ? 'playlists' : 'channels');
|
||||||
|
|
||||||
|
const subs = await utils.getDirectoriesInDirectory(subsPath);
|
||||||
|
for (const subPath of subs) {
|
||||||
|
const sub_backup_path = path.join(subPath, CONSTS.SUBSCRIPTION_BACKUP_PATH);
|
||||||
|
if (!fs.existsSync(sub_backup_path)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sub_backup = fs.readJSONSync(sub_backup_path)
|
||||||
|
delete sub_backup['_id'];
|
||||||
|
guessed_subs.push(sub_backup);
|
||||||
|
} catch(err) {
|
||||||
|
logger.warn(`Failed to reimport subscription in path ${subPath}`)
|
||||||
|
logger.warn(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return guessed_subs;
|
||||||
|
}
|
||||||
|
|
||||||
exports.TASKS = TASKS;
|
exports.TASKS = TASKS;
|
||||||
File diff suppressed because one or more lines are too long
1
backend/test/sample_mp3.info.json
Normal file
1
backend/test/sample_mp3.info.json
Normal file
File diff suppressed because one or more lines are too long
1
backend/test/sample_mp4.info.json
Normal file
1
backend/test/sample_mp4.info.json
Normal file
File diff suppressed because one or more lines are too long
@@ -3,7 +3,11 @@ 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 fs = require('fs-extra');
|
||||||
|
const { uuid } = require('uuidv4');
|
||||||
|
const NodeID3 = require('node-id3');
|
||||||
|
const exec = util.promisify(require('child_process').exec);
|
||||||
|
|
||||||
const FileSync = require('lowdb/adapters/FileSync');
|
const FileSync = require('lowdb/adapters/FileSync');
|
||||||
|
|
||||||
@@ -41,11 +45,11 @@ 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 fs = require('fs-extra');
|
const youtubedl_api = require('../youtube-dl');
|
||||||
const { uuid } = require('uuidv4');
|
const config_api = require('../config');
|
||||||
const NodeID3 = require('node-id3');
|
const CONSTS = require('../consts');
|
||||||
|
|
||||||
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 +72,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 +90,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 +118,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 +172,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 +284,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,16 +342,23 @@ describe('Database', async function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Multi User', async function() {
|
describe('Multi User', async function() {
|
||||||
let user = null;
|
this.timeout(120000);
|
||||||
const user_to_test = 'admin';
|
const user_to_test = 'test_user';
|
||||||
const sub_to_test = 'dc834388-3454-41bf-a618-e11cb8c7de1c';
|
const user_password = 'test_pass';
|
||||||
const playlist_to_test = 'ysabVZz4x';
|
const sub_to_test = '';
|
||||||
|
const playlist_to_test = '';
|
||||||
beforeEach(async function() {
|
beforeEach(async function() {
|
||||||
await db_api.connectToDB();
|
// await db_api.connectToDB();
|
||||||
user = await auth_api.login('admin', 'pass');
|
await auth_api.deleteUser(user_to_test);
|
||||||
});
|
});
|
||||||
describe('Authentication', function() {
|
describe('Basic', function() {
|
||||||
it('login', async function() {
|
it('Register', async function() {
|
||||||
|
const user = await auth_api.registerUser(user_to_test, user_to_test, user_password);
|
||||||
|
assert(user);
|
||||||
|
});
|
||||||
|
it('Login', async function() {
|
||||||
|
await auth_api.registerUser(user_to_test, user_to_test, user_password);
|
||||||
|
const user = await auth_api.login(user_to_test, user_password);
|
||||||
assert(user);
|
assert(user);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -362,18 +374,18 @@ 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}, user_to_test);
|
await db_api.setVideoProperty(video_to_test, {sharingEnabled: false});
|
||||||
const video_obj = auth_api.getUserVideo('admin', 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('admin', 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);
|
||||||
@@ -429,35 +441,100 @@ 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 playlist_url = 'https://www.youtube.com/playlist?list=PLbZT16X07RLhqK-ZgSkRuUyiz9B_WLdNK';
|
||||||
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'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createCategory(url) {
|
||||||
|
// get info
|
||||||
|
const args = await downloader_api.generateArgs(url, 'video', options, null, true);
|
||||||
|
const [info] = await downloader_api.getVideoInfoByURL(url, args);
|
||||||
|
|
||||||
|
// create category
|
||||||
|
await db_api.removeAllRecords('categories');
|
||||||
|
const new_category = {
|
||||||
|
name: 'test_category',
|
||||||
|
uid: uuid(),
|
||||||
|
rules: [],
|
||||||
|
custom_output: ''
|
||||||
|
};
|
||||||
|
await db_api.insertRecordIntoTable('categories', new_category);
|
||||||
|
await db_api.pushToRecordsArray('categories', {name: 'test_category'}, 'rules', {
|
||||||
|
preceding_operator: null,
|
||||||
|
comparator: 'includes',
|
||||||
|
property: 'title',
|
||||||
|
value: info['title']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
config_api.setConfigItem('ytdl_allow_playlist_categorization', true);
|
||||||
});
|
});
|
||||||
|
|
||||||
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('Downloader - categorize', async function() {
|
||||||
|
this.timeout(300000);
|
||||||
|
await createCategory(url);
|
||||||
|
// collect info
|
||||||
|
const returned_download = await downloader_api.createDownload(url, 'video', options);
|
||||||
|
await downloader_api.collectInfo(returned_download['uid']);
|
||||||
|
assert(returned_download['category']);
|
||||||
|
assert(returned_download['category']['name'] === 'test_category');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Downloader - categorize playlist', async function() {
|
||||||
|
this.timeout(300000);
|
||||||
|
await createCategory(playlist_url);
|
||||||
|
// collect info
|
||||||
|
const returned_download_pass = await downloader_api.createDownload(playlist_url, 'video', options);
|
||||||
|
await downloader_api.collectInfo(returned_download_pass['uid']);
|
||||||
|
assert(returned_download_pass['category']);
|
||||||
|
assert(returned_download_pass['category']['name'] === 'test_category');
|
||||||
|
|
||||||
|
// test with playlist categorization disabled
|
||||||
|
config_api.setConfigItem('ytdl_allow_playlist_categorization', false);
|
||||||
|
const returned_download_fail = await downloader_api.createDownload(playlist_url, 'video', options);
|
||||||
|
await downloader_api.collectInfo(returned_download_fail['uid']);
|
||||||
|
assert(!returned_download_fail['category']);
|
||||||
});
|
});
|
||||||
|
|
||||||
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'],
|
||||||
@@ -465,14 +542,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() {
|
||||||
@@ -487,7 +563,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);
|
||||||
@@ -500,7 +576,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);
|
||||||
@@ -527,11 +603,19 @@ 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 = '1790315420';
|
||||||
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));
|
||||||
|
|
||||||
@@ -541,10 +625,109 @@ describe('Downloader', function() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('youtube-dl', async function() {
|
||||||
|
beforeEach(async function () {
|
||||||
|
if (fs.existsSync(CONSTS.DETAILS_BIN_PATH)) fs.unlinkSync(CONSTS.DETAILS_BIN_PATH);
|
||||||
|
await youtubedl_api.checkForYoutubeDLUpdate();
|
||||||
|
});
|
||||||
|
it('Check latest version', async function() {
|
||||||
|
this.timeout(300000);
|
||||||
|
const original_fork = config_api.getConfigItem('ytdl_default_downloader');
|
||||||
|
const latest_version = await youtubedl_api.getLatestUpdateVersion(original_fork);
|
||||||
|
assert(latest_version > CONSTS.OUTDATED_YOUTUBEDL_VERSION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Update youtube-dl', async function() {
|
||||||
|
this.timeout(300000);
|
||||||
|
const original_fork = config_api.getConfigItem('ytdl_default_downloader');
|
||||||
|
const binary_path = path.join('test', 'test_binary');
|
||||||
|
for (const youtubedl_fork in youtubedl_api.youtubedl_forks) {
|
||||||
|
config_api.setConfigItem('ytdl_default_downloader', youtubedl_fork);
|
||||||
|
const latest_version = await youtubedl_api.checkForYoutubeDLUpdate();
|
||||||
|
await youtubedl_api.updateYoutubeDL(latest_version, binary_path);
|
||||||
|
assert(fs.existsSync(binary_path));
|
||||||
|
if (fs.existsSync(binary_path)) fs.unlinkSync(binary_path);
|
||||||
|
}
|
||||||
|
config_api.setConfigItem('ytdl_default_downloader', original_fork);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Run process', async function() {
|
||||||
|
this.timeout(300000);
|
||||||
|
const downloader_api = require('../downloader');
|
||||||
|
const url = 'https://www.youtube.com/watch?v=hpigjnKl7nI';
|
||||||
|
const args = await downloader_api.generateArgs(url, 'video', {}, null, true);
|
||||||
|
const {child_process} = await youtubedl_api.runYoutubeDL(url, args);
|
||||||
|
assert(child_process);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Subscriptions', function() {
|
||||||
|
const new_sub = {
|
||||||
|
name: 'test_sub',
|
||||||
|
url: 'https://www.youtube.com/channel/UCzofo-P8yMMCOv8rsPfIR-g',
|
||||||
|
maxQuality: null,
|
||||||
|
id: uuid(),
|
||||||
|
user_uid: null,
|
||||||
|
type: 'video',
|
||||||
|
paused: true
|
||||||
|
};
|
||||||
|
beforeEach(async function() {
|
||||||
|
await db_api.removeAllRecords('subscriptions');
|
||||||
|
});
|
||||||
|
it('Subscribe', async function () {
|
||||||
|
const success = await subscriptions_api.subscribe(new_sub, null, true);
|
||||||
|
assert(success);
|
||||||
|
const sub_exists = await db_api.getRecord('subscriptions', {id: new_sub['id']});
|
||||||
|
assert(sub_exists);
|
||||||
|
});
|
||||||
|
it('Unsubscribe', async function () {
|
||||||
|
await subscriptions_api.subscribe(new_sub, null, true);
|
||||||
|
await subscriptions_api.unsubscribe(new_sub);
|
||||||
|
const sub_exists = await db_api.getRecord('subscriptions', {id: new_sub['id']});
|
||||||
|
assert(!sub_exists);
|
||||||
|
});
|
||||||
|
it('Delete subscription file', async function () {
|
||||||
|
|
||||||
|
});
|
||||||
|
it('Get subscription by name', async function () {
|
||||||
|
await subscriptions_api.subscribe(new_sub, null, true);
|
||||||
|
const sub_by_name = await subscriptions_api.getSubscriptionByName('test_sub');
|
||||||
|
assert(sub_by_name);
|
||||||
|
});
|
||||||
|
it('Get subscriptions', async function() {
|
||||||
|
await subscriptions_api.subscribe(new_sub, null, true);
|
||||||
|
const subs = await subscriptions_api.getSubscriptions(null);
|
||||||
|
assert(subs && subs.length === 1);
|
||||||
|
});
|
||||||
|
it('Update subscription', async function () {
|
||||||
|
await subscriptions_api.subscribe(new_sub, null, true);
|
||||||
|
const sub_update = Object.assign({}, new_sub, {name: 'updated_name'});
|
||||||
|
await subscriptions_api.updateSubscription(sub_update);
|
||||||
|
const updated_sub = await db_api.getRecord('subscriptions', {id: new_sub['id']});
|
||||||
|
assert(updated_sub['name'] === 'updated_name');
|
||||||
|
});
|
||||||
|
it('Update subscription property', async function () {
|
||||||
|
await subscriptions_api.subscribe(new_sub, null, true);
|
||||||
|
const sub_update = Object.assign({}, new_sub, {name: 'updated_name'});
|
||||||
|
await subscriptions_api.updateSubscriptionPropertyMultiple([sub_update], {name: 'updated_name'});
|
||||||
|
const updated_sub = await db_api.getRecord('subscriptions', {id: new_sub['id']});
|
||||||
|
assert(updated_sub['name'] === 'updated_name');
|
||||||
|
});
|
||||||
|
it('Write subscription metadata', async function() {
|
||||||
|
const metadata_path = path.join('subscriptions', 'channels', 'test_sub', 'subscription_backup.json');
|
||||||
|
if (fs.existsSync(metadata_path)) fs.unlinkSync(metadata_path);
|
||||||
|
await subscriptions_api.subscribe(new_sub, null, true);
|
||||||
|
assert(fs.existsSync(metadata_path));
|
||||||
|
});
|
||||||
|
it('Fresh uploads', async 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 = {
|
||||||
@@ -563,7 +746,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() {
|
||||||
@@ -573,7 +756,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() {
|
||||||
@@ -593,27 +776,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', {path: 'test/missing_file.mp4'});
|
||||||
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() {
|
||||||
@@ -653,12 +838,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() {
|
||||||
@@ -672,7 +857,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);
|
||||||
@@ -703,9 +887,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);
|
||||||
@@ -751,14 +935,14 @@ 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(),
|
||||||
rules: [],
|
rules: [],
|
||||||
custom_output: ''
|
custom_output: ''
|
||||||
};
|
};
|
||||||
|
await db_api.removeAllRecords('categories', {name: 'test_category'});
|
||||||
await db_api.insertRecordIntoTable('categories', new_category);
|
await db_api.insertRecordIntoTable('categories', new_category);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -799,7 +983,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');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -852,4 +1035,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}`);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -241,11 +241,6 @@ exports.addUIDsToCategory = (category, files) => {
|
|||||||
return files_that_match;
|
return files_that_match;
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.getCurrentDownloader = () => {
|
|
||||||
const details_json = fs.readJSONSync(CONSTS.DETAILS_BIN_PATH);
|
|
||||||
return details_json['downloader'];
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.recFindByExt = async (base, ext, files, result, recursive = true) => {
|
exports.recFindByExt = async (base, ext, files, result, recursive = true) => {
|
||||||
files = files || (await fs.readdir(base))
|
files = files || (await fs.readdir(base))
|
||||||
result = result || []
|
result = result || []
|
||||||
@@ -347,7 +342,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 +352,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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -519,6 +514,51 @@ exports.convertFlatObjectToNestedObject = (obj) => {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exports.getDirectoriesInDirectory = async (basePath) => {
|
||||||
|
try {
|
||||||
|
const files = await fs.readdir(basePath, { withFileTypes: true });
|
||||||
|
return files
|
||||||
|
.filter((file) => file.isDirectory())
|
||||||
|
.map((file) => path.join(basePath, file.name));
|
||||||
|
} catch (err) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -1,141 +1,159 @@
|
|||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const fetch = require('node-fetch');
|
const fetch = require('node-fetch');
|
||||||
|
const path = require('path');
|
||||||
|
const execa = require('execa');
|
||||||
|
const kill = require('tree-kill');
|
||||||
|
|
||||||
const logger = require('./logger');
|
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 OUTDATED_VERSION = "2020.00.00";
|
|
||||||
|
|
||||||
const is_windows = process.platform === 'win32';
|
const is_windows = process.platform === 'win32';
|
||||||
|
|
||||||
const download_sources = {
|
exports.youtubedl_forks = {
|
||||||
'youtube-dl': {
|
'youtube-dl': {
|
||||||
'tags_url': 'https://api.github.com/repos/ytdl-org/youtube-dl/tags',
|
'download_url': 'https://github.com/ytdl-org/youtube-dl/releases/latest/download/youtube-dl',
|
||||||
'func': downloadLatestYoutubeDLBinary
|
'tags_url': 'https://api.github.com/repos/ytdl-org/youtube-dl/tags'
|
||||||
},
|
},
|
||||||
'youtube-dlc': {
|
'youtube-dlc': {
|
||||||
'tags_url': 'https://api.github.com/repos/blackjack4494/yt-dlc/tags',
|
'download_url': 'https://github.com/blackjack4494/yt-dlc/releases/latest/download/youtube-dlc',
|
||||||
'func': downloadLatestYoutubeDLCBinary
|
'tags_url': 'https://api.github.com/repos/blackjack4494/yt-dlc/tags'
|
||||||
},
|
},
|
||||||
'yt-dlp': {
|
'yt-dlp': {
|
||||||
'tags_url': 'https://api.github.com/repos/yt-dlp/yt-dlp/tags',
|
'download_url': 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp',
|
||||||
'func': downloadLatestYoutubeDLPBinary
|
'tags_url': 'https://api.github.com/repos/yt-dlp/yt-dlp/tags'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.checkForYoutubeDLUpdate = async () => {
|
exports.runYoutubeDL = async (url, args, customDownloadHandler = null) => {
|
||||||
return new Promise(async resolve => {
|
const output_file_path = getYoutubeDLPath();
|
||||||
const default_downloader = config_api.getConfigItem('ytdl_default_downloader');
|
if (!fs.existsSync(output_file_path)) await exports.checkForYoutubeDLUpdate();
|
||||||
const tags_url = download_sources[default_downloader]['tags_url'];
|
let callback = null;
|
||||||
// get current version
|
let child_process = null;
|
||||||
let current_app_details_exists = fs.existsSync(CONSTS.DETAILS_BIN_PATH);
|
if (customDownloadHandler) {
|
||||||
if (!current_app_details_exists) {
|
callback = runYoutubeDLCustom(url, args, customDownloadHandler);
|
||||||
logger.warn(`Failed to get youtube-dl binary details at location '${CONSTS.DETAILS_BIN_PATH}'. Generating file...`);
|
} else {
|
||||||
fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, {"version": OUTDATED_VERSION, "downloader": default_downloader});
|
({callback, child_process} = await runYoutubeDLProcess(url, args));
|
||||||
}
|
}
|
||||||
let current_app_details = JSON.parse(fs.readFileSync(CONSTS.DETAILS_BIN_PATH));
|
|
||||||
let current_version = current_app_details['version'];
|
|
||||||
let current_downloader = current_app_details['downloader'];
|
|
||||||
let stored_binary_path = current_app_details['path'];
|
|
||||||
if (!stored_binary_path || typeof stored_binary_path !== 'string') {
|
|
||||||
// logger.info(`INFO: Failed to get youtube-dl binary path at location: ${CONSTS.DETAILS_BIN_PATH}, attempting to guess actual path...`);
|
|
||||||
const guessed_base_path = 'node_modules/youtube-dl/bin/';
|
|
||||||
const guessed_file_path = guessed_base_path + 'youtube-dl' + (is_windows ? '.exe' : '');
|
|
||||||
if (fs.existsSync(guessed_file_path)) {
|
|
||||||
stored_binary_path = guessed_file_path;
|
|
||||||
// logger.info('INFO: Guess successful! Update process continuing...')
|
|
||||||
} else {
|
|
||||||
logger.error(`Guess '${guessed_file_path}' is not correct. Cancelling update check. Verify that your youtube-dl binaries exist by running npm install.`);
|
|
||||||
resolve(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// got version, now let's check the latest version from the youtube-dl API
|
return {child_process, callback};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run youtube-dl directly (not cancellable)
|
||||||
fetch(tags_url, {method: 'Get'})
|
const runYoutubeDLCustom = async (url, args, customDownloadHandler) => {
|
||||||
.then(async res => res.json())
|
const downloadHandler = customDownloadHandler;
|
||||||
.then(async (json) => {
|
return new Promise(resolve => {
|
||||||
// check if the versions are different
|
downloadHandler(url, args, {maxBuffer: Infinity}, async function(err, output) {
|
||||||
if (!json || !json[0]) {
|
const parsed_output = utils.parseOutputJSON(output, err);
|
||||||
logger.error(`Failed to check ${default_downloader} version for an update.`)
|
resolve({parsed_output, err});
|
||||||
resolve(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const latest_update_version = json[0]['name'];
|
|
||||||
if (current_version !== latest_update_version || default_downloader !== current_downloader) {
|
|
||||||
// versions different or different downloader is being used, download new update
|
|
||||||
resolve(latest_update_version);
|
|
||||||
} else {
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
logger.error(`Failed to check ${default_downloader} version for an update.`)
|
|
||||||
logger.error(err);
|
|
||||||
resolve(null);
|
|
||||||
return;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.updateYoutubeDL = async (latest_update_version) => {
|
// Run youtube-dl in a subprocess (cancellable)
|
||||||
const default_downloader = config_api.getConfigItem('ytdl_default_downloader');
|
const runYoutubeDLProcess = async (url, args, youtubedl_fork = config_api.getConfigItem('ytdl_default_downloader')) => {
|
||||||
await download_sources[default_downloader]['func'](latest_update_version);
|
const youtubedl_path = getYoutubeDLPath(youtubedl_fork);
|
||||||
|
const binary_exists = fs.existsSync(youtubedl_path);
|
||||||
|
if (!binary_exists) {
|
||||||
|
const err = `Could not find path for ${youtubedl_fork} at ${youtubedl_path}`;
|
||||||
|
logger.error(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const child_process = execa(getYoutubeDLPath(youtubedl_fork), [url, ...args], {maxBuffer: Infinity});
|
||||||
|
const callback = new Promise(async resolve => {
|
||||||
|
try {
|
||||||
|
const {stdout, stderr} = await child_process;
|
||||||
|
const parsed_output = utils.parseOutputJSON(stdout.trim().split(/\r?\n/), stderr);
|
||||||
|
resolve({parsed_output, err: stderr});
|
||||||
|
} catch (e) {
|
||||||
|
resolve({parsed_output: null, err: e})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {child_process, callback}
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.verifyBinaryExistsLinux = () => {
|
function getYoutubeDLPath(youtubedl_fork = config_api.getConfigItem('ytdl_default_downloader')) {
|
||||||
const details_json = fs.readJSONSync(CONSTS.DETAILS_BIN_PATH);
|
const binary_file_name = youtubedl_fork + (is_windows ? '.exe' : '');
|
||||||
if (!is_windows && details_json && (!details_json['path'] || details_json['path'].includes('.exe'))) {
|
const binary_path = path.join('appdata', 'bin', binary_file_name);
|
||||||
details_json['path'] = 'node_modules/youtube-dl/bin/youtube-dl';
|
return binary_path;
|
||||||
details_json['exec'] = 'youtube-dl';
|
}
|
||||||
details_json['version'] = OUTDATED_VERSION;
|
|
||||||
fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, details_json);
|
|
||||||
|
|
||||||
utils.restartServer();
|
exports.killYoutubeDLProcess = async (child_process) => {
|
||||||
|
kill(child_process.pid, 'SIGKILL');
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.checkForYoutubeDLUpdate = async () => {
|
||||||
|
const selected_fork = config_api.getConfigItem('ytdl_default_downloader');
|
||||||
|
const output_file_path = getYoutubeDLPath();
|
||||||
|
// get current version
|
||||||
|
let current_app_details_exists = fs.existsSync(CONSTS.DETAILS_BIN_PATH);
|
||||||
|
if (!current_app_details_exists) {
|
||||||
|
logger.warn(`Failed to get youtube-dl binary details at location '${CONSTS.DETAILS_BIN_PATH}'. Generating file...`);
|
||||||
|
updateDetailsJSON(CONSTS.OUTDATED_YOUTUBEDL_VERSION, selected_fork, output_file_path);
|
||||||
|
}
|
||||||
|
const current_app_details = JSON.parse(fs.readFileSync(CONSTS.DETAILS_BIN_PATH));
|
||||||
|
const current_version = current_app_details['version'];
|
||||||
|
const current_fork = current_app_details['downloader'];
|
||||||
|
|
||||||
|
const latest_version = await exports.getLatestUpdateVersion(selected_fork);
|
||||||
|
// if the binary does not exist, or default_downloader doesn't match existing fork, or if the fork has been updated, redownload
|
||||||
|
// TODO: don't redownload if fork already exists
|
||||||
|
if (!fs.existsSync(output_file_path) || current_fork !== selected_fork || !current_version || current_version !== latest_version) {
|
||||||
|
logger.warn(`Updating ${selected_fork} binary to '${output_file_path}', downloading...`);
|
||||||
|
await exports.updateYoutubeDL(latest_version);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadLatestYoutubeDLBinary(new_version) {
|
exports.updateYoutubeDL = async (latest_update_version, custom_output_path = null) => {
|
||||||
const file_ext = is_windows ? '.exe' : '';
|
await fs.ensureDir(path.join('appdata', 'bin'));
|
||||||
|
const default_downloader = config_api.getConfigItem('ytdl_default_downloader');
|
||||||
const download_url = `https://github.com/ytdl-org/youtube-dl/releases/latest/download/youtube-dl${file_ext}`;
|
await downloadLatestYoutubeDLBinaryGeneric(default_downloader, latest_update_version, custom_output_path);
|
||||||
const output_path = `node_modules/youtube-dl/bin/youtube-dl${file_ext}`;
|
|
||||||
|
|
||||||
await utils.fetchFile(download_url, output_path, `youtube-dl ${new_version}`);
|
|
||||||
|
|
||||||
updateDetailsJSON(new_version, 'youtube-dl');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadLatestYoutubeDLCBinary(new_version) {
|
async function downloadLatestYoutubeDLBinaryGeneric(youtubedl_fork, new_version, custom_output_path = null) {
|
||||||
const file_ext = is_windows ? '.exe' : '';
|
const file_ext = is_windows ? '.exe' : '';
|
||||||
|
|
||||||
const download_url = `https://github.com/blackjack4494/yt-dlc/releases/latest/download/youtube-dlc${file_ext}`;
|
// build the URL
|
||||||
const output_path = `node_modules/youtube-dl/bin/youtube-dl${file_ext}`;
|
const download_url = `${exports.youtubedl_forks[youtubedl_fork]['download_url']}${file_ext}`;
|
||||||
|
const output_path = custom_output_path || getYoutubeDLPath(youtubedl_fork);
|
||||||
|
|
||||||
await utils.fetchFile(download_url, output_path, `youtube-dlc ${new_version}`);
|
await utils.fetchFile(download_url, output_path, `${youtubedl_fork} ${new_version}`);
|
||||||
|
fs.chmod(output_path, 0o777);
|
||||||
|
|
||||||
updateDetailsJSON(new_version, 'youtube-dlc');
|
updateDetailsJSON(new_version, youtubedl_fork, output_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.getLatestUpdateVersion = async (youtubedl_fork) => {
|
||||||
|
const tags_url = exports.youtubedl_forks[youtubedl_fork]['tags_url'];
|
||||||
|
return new Promise(resolve => {
|
||||||
|
fetch(tags_url, {method: 'Get'})
|
||||||
|
.then(async res => res.json())
|
||||||
|
.then(async (json) => {
|
||||||
|
if (!json || !json[0]) {
|
||||||
|
logger.error(`Failed to check ${youtubedl_fork} version for an update.`)
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const latest_update_version = json[0]['name'];
|
||||||
|
resolve(latest_update_version);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
logger.error(`Failed to check ${youtubedl_fork} version for an update.`)
|
||||||
|
logger.error(err);
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadLatestYoutubeDLPBinary(new_version) {
|
function updateDetailsJSON(new_version, fork, output_path) {
|
||||||
const file_ext = is_windows ? '.exe' : '';
|
const file_ext = is_windows ? '.exe' : '';
|
||||||
|
const details_json = fs.existsSync(CONSTS.DETAILS_BIN_PATH) ? fs.readJSONSync(CONSTS.DETAILS_BIN_PATH) : {};
|
||||||
const download_url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp${file_ext}`;
|
if (!details_json[fork]) details_json[fork] = {};
|
||||||
const output_path = `node_modules/youtube-dl/bin/youtube-dl${file_ext}`;
|
const fork_json = details_json[fork];
|
||||||
|
fork_json['version'] = new_version;
|
||||||
await utils.fetchFile(download_url, output_path, `yt-dlp ${new_version}`);
|
fork_json['downloader'] = fork;
|
||||||
|
fork_json['path'] = output_path; // unused
|
||||||
updateDetailsJSON(new_version, 'yt-dlp');
|
fork_json['exec'] = fork + file_ext; // unused
|
||||||
}
|
|
||||||
|
|
||||||
function updateDetailsJSON(new_version, downloader) {
|
|
||||||
const details_json = fs.readJSONSync(CONSTS.DETAILS_BIN_PATH);
|
|
||||||
if (new_version) details_json['version'] = new_version;
|
|
||||||
details_json['downloader'] = downloader;
|
|
||||||
fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, details_json);
|
fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, details_json);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type { ChangeRolePermissionsRequest } from './models/ChangeRolePermission
|
|||||||
export type { ChangeUserPermissionsRequest } from './models/ChangeUserPermissionsRequest';
|
export type { ChangeUserPermissionsRequest } from './models/ChangeUserPermissionsRequest';
|
||||||
export type { CheckConcurrentStreamRequest } from './models/CheckConcurrentStreamRequest';
|
export type { CheckConcurrentStreamRequest } from './models/CheckConcurrentStreamRequest';
|
||||||
export type { CheckConcurrentStreamResponse } from './models/CheckConcurrentStreamResponse';
|
export type { CheckConcurrentStreamResponse } from './models/CheckConcurrentStreamResponse';
|
||||||
|
export type { CheckSubscriptionRequest } from './models/CheckSubscriptionRequest';
|
||||||
export type { ClearDownloadsRequest } from './models/ClearDownloadsRequest';
|
export type { ClearDownloadsRequest } from './models/ClearDownloadsRequest';
|
||||||
export type { ConcurrentStream } from './models/ConcurrentStream';
|
export type { ConcurrentStream } from './models/ConcurrentStream';
|
||||||
export type { Config } from './models/Config';
|
export type { Config } from './models/Config';
|
||||||
@@ -104,6 +105,7 @@ export type { SubscriptionRequestData } from './models/SubscriptionRequestData';
|
|||||||
export type { SuccessObject } from './models/SuccessObject';
|
export type { SuccessObject } from './models/SuccessObject';
|
||||||
export type { TableInfo } from './models/TableInfo';
|
export type { TableInfo } from './models/TableInfo';
|
||||||
export type { Task } from './models/Task';
|
export type { Task } from './models/Task';
|
||||||
|
export { TaskType } from './models/TaskType';
|
||||||
export type { TestConnectionStringRequest } from './models/TestConnectionStringRequest';
|
export type { TestConnectionStringRequest } from './models/TestConnectionStringRequest';
|
||||||
export type { TestConnectionStringResponse } from './models/TestConnectionStringResponse';
|
export type { TestConnectionStringResponse } from './models/TestConnectionStringResponse';
|
||||||
export type { TransferDBRequest } from './models/TransferDBRequest';
|
export type { TransferDBRequest } from './models/TransferDBRequest';
|
||||||
|
|||||||
7
src/api-types/models/CheckSubscriptionRequest.ts
Normal file
7
src/api-types/models/CheckSubscriptionRequest.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
export type CheckSubscriptionRequest = {
|
||||||
|
sub_id: string;
|
||||||
|
};
|
||||||
@@ -3,5 +3,5 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
|
|
||||||
export type Config = {
|
export type Config = {
|
||||||
YoutubeDLMaterial: Record<string, any>;
|
YoutubeDLMaterial: any;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type Download = {
|
|||||||
running: boolean;
|
running: boolean;
|
||||||
finished: boolean;
|
finished: boolean;
|
||||||
paused: boolean;
|
paused: boolean;
|
||||||
|
cancelled?: boolean;
|
||||||
finished_step: boolean;
|
finished_step: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -26,5 +27,5 @@ export type Download = {
|
|||||||
user_uid?: string;
|
user_uid?: string;
|
||||||
sub_id?: string;
|
sub_id?: string;
|
||||||
sub_name?: string;
|
sub_name?: string;
|
||||||
prefetched_info?: Record<string, any>;
|
prefetched_info?: any;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,6 @@
|
|||||||
export type GetFileFormatsResponse = {
|
export type GetFileFormatsResponse = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
result: {
|
result: {
|
||||||
formats?: Array<Record<string, any>>;
|
formats?: Array<any>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ import type { Subscription } from './Subscription';
|
|||||||
|
|
||||||
export type GetSubscriptionResponse = {
|
export type GetSubscriptionResponse = {
|
||||||
subscription: Subscription;
|
subscription: Subscription;
|
||||||
files: Array<Record<string, any>>;
|
files: Array<any>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
|
|
||||||
|
import type { TaskType } from './TaskType';
|
||||||
|
|
||||||
export type GetTaskRequest = {
|
export type GetTaskRequest = {
|
||||||
task_key: string;
|
task_key: TaskType;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ export type Notification = {
|
|||||||
user_uid?: string;
|
user_uid?: string;
|
||||||
action?: Array<NotificationAction>;
|
action?: Array<NotificationAction>;
|
||||||
read: boolean;
|
read: boolean;
|
||||||
data?: Record<string, any>;
|
data?: any;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ export type Subscription = {
|
|||||||
type: FileType;
|
type: FileType;
|
||||||
user_uid: string | null;
|
user_uid: string | null;
|
||||||
isPlaylist: boolean;
|
isPlaylist: boolean;
|
||||||
|
child_process?: any;
|
||||||
archive?: string;
|
archive?: string;
|
||||||
timerange?: string;
|
timerange?: string;
|
||||||
custom_args?: string;
|
custom_args?: string;
|
||||||
custom_output?: string;
|
custom_output?: string;
|
||||||
videos: Array<Record<string, any>>;
|
downloading?: boolean;
|
||||||
|
paused?: boolean;
|
||||||
|
videos: Array<any>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,16 +3,17 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
|
|
||||||
import type { Schedule } from './Schedule';
|
import type { Schedule } from './Schedule';
|
||||||
|
import type { TaskType } from './TaskType';
|
||||||
|
|
||||||
export type Task = {
|
export type Task = {
|
||||||
key: string;
|
key: TaskType;
|
||||||
title?: string;
|
title?: string;
|
||||||
last_ran: number;
|
last_ran: number;
|
||||||
last_confirmed: number;
|
last_confirmed: number;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
confirming: boolean;
|
confirming: boolean;
|
||||||
data: Record<string, any>;
|
data: any;
|
||||||
error: string;
|
error: string;
|
||||||
schedule: Schedule;
|
schedule: Schedule;
|
||||||
options?: Record<string, any>;
|
options?: any;
|
||||||
};
|
};
|
||||||
|
|||||||
14
src/api-types/models/TaskType.ts
Normal file
14
src/api-types/models/TaskType.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
export enum TaskType {
|
||||||
|
BACKUP_LOCAL_DB = 'backup_local_db',
|
||||||
|
MISSING_FILES_CHECK = 'missing_files_check',
|
||||||
|
MISSING_DB_RECORDS = 'missing_db_records',
|
||||||
|
DUPLICATE_FILES_CHECK = 'duplicate_files_check',
|
||||||
|
YOUTUBEDL_UPDATE_CHECK = 'youtubedl_update_check',
|
||||||
|
DELETE_OLD_FILES = 'delete_old_files',
|
||||||
|
IMPORT_LEGACY_ARCHIVES = 'import_legacy_archives',
|
||||||
|
REBUILD_DATABASE = 'rebuild_database',
|
||||||
|
}
|
||||||
@@ -2,10 +2,8 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
|
|
||||||
import type { SubscriptionRequestData } from './SubscriptionRequestData';
|
|
||||||
|
|
||||||
export type UnsubscribeRequest = {
|
export type UnsubscribeRequest = {
|
||||||
sub: SubscriptionRequestData;
|
sub_id: string;
|
||||||
/**
|
/**
|
||||||
* Defaults to false
|
* Defaults to false
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,5 +10,5 @@ export type UpdateFileRequest = {
|
|||||||
/**
|
/**
|
||||||
* Object with fields to update as keys and their new values
|
* Object with fields to update as keys and their new values
|
||||||
*/
|
*/
|
||||||
change_obj: Record<string, any>;
|
change_obj: any;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
|
|
||||||
|
import type { TaskType } from './TaskType';
|
||||||
|
|
||||||
export type UpdateTaskDataRequest = {
|
export type UpdateTaskDataRequest = {
|
||||||
task_key: string;
|
task_key: TaskType;
|
||||||
new_data: Record<string, any>;
|
new_data: any;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
|
|
||||||
|
import type { TaskType } from './TaskType';
|
||||||
|
|
||||||
export type UpdateTaskOptionsRequest = {
|
export type UpdateTaskOptionsRequest = {
|
||||||
task_key: string;
|
task_key: TaskType;
|
||||||
new_options: Record<string, any>;
|
new_options: any;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
|
|
||||||
import type { Schedule } from './Schedule';
|
import type { Schedule } from './Schedule';
|
||||||
|
import type { TaskType } from './TaskType';
|
||||||
|
|
||||||
export type UpdateTaskScheduleRequest = {
|
export type UpdateTaskScheduleRequest = {
|
||||||
task_key: string;
|
task_key: TaskType;
|
||||||
new_schedule: Schedule;
|
new_schedule: Schedule;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,72 @@ 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'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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) => !download.finished && !download.paused && !download.cancelled,
|
||||||
|
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 +112,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 +203,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 +219,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 +235,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 +247,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 +296,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 +316,22 @@ export class DownloadsComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recalculateColumns() {
|
||||||
|
if (this.innerWidth < 650) this.displayedColumns = this.displayedColumnsSmall;
|
||||||
|
else this.displayedColumns = this.displayedColumnsBig;
|
||||||
|
|
||||||
|
this.actionsFlex = this.uids || this.innerWidth < 800 ? 1 : 2;
|
||||||
|
|
||||||
|
if (this.innerWidth < 800 && !this.uids || this.innerWidth < 1100 && this.uids) this.minimizeButtons = true;
|
||||||
|
else this.minimizeButtons = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DownloadAction {
|
||||||
|
tooltip: string,
|
||||||
|
action: (download: Download) => void,
|
||||||
|
show: (download: Download) => boolean,
|
||||||
|
icon: string,
|
||||||
|
loading?: (download: Download) => boolean
|
||||||
|
}
|
||||||
@@ -1,30 +1,32 @@
|
|||||||
<div class="card-radius mat-elevation-z2" *ngFor="let notification of notifications; let i = index;">
|
<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>
|
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Component, Inject } from '@angular/core';
|
import { Component, Inject } from '@angular/core';
|
||||||
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||||
import { Task } from 'api-types';
|
import { Task, TaskType } from 'api-types';
|
||||||
import { PostsService } from 'app/posts.services';
|
import { PostsService } from 'app/posts.services';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -9,7 +9,7 @@ import { PostsService } from 'app/posts.services';
|
|||||||
styleUrls: ['./task-settings.component.scss']
|
styleUrls: ['./task-settings.component.scss']
|
||||||
})
|
})
|
||||||
export class TaskSettingsComponent {
|
export class TaskSettingsComponent {
|
||||||
task_key: string;
|
task_key: TaskType;
|
||||||
new_options = {};
|
new_options = {};
|
||||||
task: Task = null;
|
task: Task = null;
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
<mat-header-cell *matHeaderCellDef> <ng-container i18n="Actions">Actions</ng-container> </mat-header-cell>
|
<mat-header-cell *matHeaderCellDef> <ng-container i18n="Actions">Actions</ng-container> </mat-header-cell>
|
||||||
<mat-cell *matCellDef="let element">
|
<mat-cell *matCellDef="let element">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-start">
|
||||||
<div *ngIf="element.data?.uids?.length > 0 || (!element.data?.uids && element.data)" class="col-12 mt-2" style="display: flex; justify-content: center;">
|
<div *ngIf="element.data?.uids?.length > 0 || (!element.data?.uids && element.data)" class="col-12 mt-2" style="display: flex; justify-content: center;">
|
||||||
<ng-container>
|
<ng-container>
|
||||||
<button (click)="confirmTask(element.key)" [disabled]="element.running || element.confirming" mat-stroked-button>
|
<button (click)="confirmTask(element.key)" [disabled]="element.running || element.confirming" mat-stroked-button>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { ConfirmDialogComponent } from 'app/dialogs/confirm-dialog/confirm-dialo
|
|||||||
import { RestoreDbDialogComponent } from 'app/dialogs/restore-db-dialog/restore-db-dialog.component';
|
import { RestoreDbDialogComponent } from 'app/dialogs/restore-db-dialog/restore-db-dialog.component';
|
||||||
import { UpdateTaskScheduleDialogComponent } from 'app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component';
|
import { UpdateTaskScheduleDialogComponent } from 'app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component';
|
||||||
import { PostsService } from 'app/posts.services';
|
import { PostsService } from 'app/posts.services';
|
||||||
import { Task } from 'api-types';
|
import { Task, TaskType } from 'api-types';
|
||||||
import { TaskSettingsComponent } from '../task-settings/task-settings.component';
|
import { TaskSettingsComponent } from '../task-settings/task-settings.component';
|
||||||
import { Clipboard } from '@angular/cdk/clipboard';
|
import { Clipboard } from '@angular/cdk/clipboard';
|
||||||
|
|
||||||
@@ -28,6 +28,15 @@ export class TasksComponent implements OnInit {
|
|||||||
|
|
||||||
db_backups = [];
|
db_backups = [];
|
||||||
|
|
||||||
|
TASKS_TO_REQUIRE_DIALOG: { [key in TaskType]? : {dialogTitle: string, dialogText: string, submitText: string, warnSubmitColor: boolean}} = {
|
||||||
|
[TaskType.REBUILD_DATABASE]: {
|
||||||
|
dialogTitle: $localize`Rebuild database`,
|
||||||
|
dialogText: $localize`Are you sure you want to rebuild the database? All missing users, subscriptions, and files will be reimported. Note that if missing users are detected, they will be created with the password: 'password'. A backup of your current database will be created.`,
|
||||||
|
submitText: $localize`Rebuild database`,
|
||||||
|
warnSubmitColor: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewChild(MatPaginator) paginator: MatPaginator;
|
@ViewChild(MatPaginator) paginator: MatPaginator;
|
||||||
@ViewChild(MatSort) sort: MatSort;
|
@ViewChild(MatSort) sort: MatSort;
|
||||||
|
|
||||||
@@ -79,7 +88,29 @@ export class TasksComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
runTask(task_key: string): void {
|
runTask(task_key: TaskType): void {
|
||||||
|
const taskToRequireDialog = this.TASKS_TO_REQUIRE_DIALOG[task_key];
|
||||||
|
if (taskToRequireDialog) {
|
||||||
|
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||||
|
data: {
|
||||||
|
dialogTitle: taskToRequireDialog['dialogTitle'],
|
||||||
|
dialogText: taskToRequireDialog['dialogText'],
|
||||||
|
submitText: taskToRequireDialog['submitText'],
|
||||||
|
warnSubmitColor: taskToRequireDialog['warnSubmitColor']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dialogRef.afterClosed().subscribe(confirmed => {
|
||||||
|
if (confirmed) {
|
||||||
|
this._runTask(task_key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._runTask(task_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
_runTask(task_key: TaskType): void {
|
||||||
this.postsService.runTask(task_key).subscribe(res => {
|
this.postsService.runTask(task_key).subscribe(res => {
|
||||||
this.getTasks();
|
this.getTasks();
|
||||||
this.getDBBackups();
|
this.getDBBackups();
|
||||||
@@ -91,7 +122,7 @@ export class TasksComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmTask(task_key: string): void {
|
confirmTask(task_key: TaskType): void {
|
||||||
this.postsService.confirmTask(task_key).subscribe(res => {
|
this.postsService.confirmTask(task_key).subscribe(res => {
|
||||||
this.getTasks();
|
this.getTasks();
|
||||||
if (res['success']) this.postsService.openSnackBar($localize`Successfully confirmed task!`);
|
if (res['success']) this.postsService.openSnackBar($localize`Successfully confirmed task!`);
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const CURRENT_VERSION = 'v4.3.1';
|
export const CURRENT_VERSION = 'v4.3.2';
|
||||||
|
|||||||
@@ -35,36 +35,6 @@
|
|||||||
<p>
|
<p>
|
||||||
<ng-container i18n="About bug prefix">Found a bug or have a suggestion?</ng-container> <a [href]="issuesLink" target="_blank"><ng-container i18n="About bug click here">Click here</ng-container></a> <ng-container i18n="About bug suffix">to create an issue!</ng-container>
|
<ng-container i18n="About bug prefix">Found a bug or have a suggestion?</ng-container> <a [href]="issuesLink" target="_blank"><ng-container i18n="About bug click here">Click here</ng-container></a> <ng-container i18n="About bug suffix">to create an issue!</ng-container>
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Component, OnInit, Inject } from '@angular/core';
|
|||||||
import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||||
import { PostsService } from 'app/posts.services';
|
import { PostsService } from 'app/posts.services';
|
||||||
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component';
|
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component';
|
||||||
|
import { Subscription } from 'api-types';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-subscription-info-dialog',
|
selector: 'app-subscription-info-dialog',
|
||||||
@@ -10,7 +11,7 @@ import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.compone
|
|||||||
})
|
})
|
||||||
export class SubscriptionInfoDialogComponent implements OnInit {
|
export class SubscriptionInfoDialogComponent implements OnInit {
|
||||||
|
|
||||||
sub = null;
|
sub: Subscription = null;
|
||||||
unsubbedEmitter = null;
|
unsubbedEmitter = null;
|
||||||
|
|
||||||
constructor(public dialogRef: MatDialogRef<SubscriptionInfoDialogComponent>,
|
constructor(public dialogRef: MatDialogRef<SubscriptionInfoDialogComponent>,
|
||||||
@@ -41,7 +42,7 @@ export class SubscriptionInfoDialogComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
unsubscribe() {
|
unsubscribe() {
|
||||||
this.postsService.unsubscribe(this.sub, true).subscribe(res => {
|
this.postsService.unsubscribe(this.sub.id, true).subscribe(res => {
|
||||||
this.unsubbedEmitter.emit(true);
|
this.unsubbedEmitter.emit(true);
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -169,6 +169,8 @@ export class MainComponent implements OnInit {
|
|||||||
argsChangedSubject: Subject<boolean> = new Subject<boolean>();
|
argsChangedSubject: Subject<boolean> = new Subject<boolean>();
|
||||||
simulatedOutput = '';
|
simulatedOutput = '';
|
||||||
|
|
||||||
|
interval_id = null;
|
||||||
|
|
||||||
constructor(public postsService: PostsService, private youtubeSearch: YoutubeSearchService, public snackBar: MatSnackBar,
|
constructor(public postsService: PostsService, private youtubeSearch: YoutubeSearchService, public snackBar: MatSnackBar,
|
||||||
private router: Router, public dialog: MatDialog, private platform: Platform, private route: ActivatedRoute) {
|
private router: Router, public dialog: MatDialog, private platform: Platform, private route: ActivatedRoute) {
|
||||||
this.audioOnly = false;
|
this.audioOnly = false;
|
||||||
@@ -232,11 +234,12 @@ export class MainComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// get downloads routine
|
// get downloads routine
|
||||||
setInterval(() => {
|
if (this.interval_id) { clearInterval(this.interval_id) }
|
||||||
|
this.interval_id = setInterval(() => {
|
||||||
if (this.current_download) {
|
if (this.current_download) {
|
||||||
this.getCurrentDownload();
|
this.getCurrentDownload();
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 1000);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -294,6 +297,10 @@ export class MainComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.interval_id) { clearInterval(this.interval_id) }
|
||||||
|
}
|
||||||
|
|
||||||
// download helpers
|
// download helpers
|
||||||
downloadHelper(container: DatabaseFile | Playlist, type: string, is_playlist = false, force_view = false, navigate_mode = false): void {
|
downloadHelper(container: DatabaseFile | Playlist, type: string, is_playlist = false, force_view = false, navigate_mode = false): void {
|
||||||
this.downloadingfile = false;
|
this.downloadingfile = false;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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> <mat-icon>update</mat-icon></button>
|
|
||||||
|
|
||||||
</div> -->
|
|
||||||
</mat-drawer-container>
|
</mat-drawer-container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -112,9 +112,11 @@ import {
|
|||||||
ImportArchiveRequest,
|
ImportArchiveRequest,
|
||||||
Archive,
|
Archive,
|
||||||
Subscription,
|
Subscription,
|
||||||
RestartDownloadResponse
|
RestartDownloadResponse,
|
||||||
|
TaskType,
|
||||||
|
CheckSubscriptionRequest
|
||||||
} 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';
|
||||||
|
|
||||||
@@ -565,8 +567,18 @@ export class PostsService implements CanActivate {
|
|||||||
return this.http.post<SuccessObject>(this.path + 'updateSubscription', {subscription: subscription}, this.httpOptions);
|
return this.http.post<SuccessObject>(this.path + 'updateSubscription', {subscription: subscription}, this.httpOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
unsubscribe(sub: SubscriptionRequestData, deleteMode = false) {
|
checkSubscription(sub_id: string) {
|
||||||
const body: UnsubscribeRequest = {sub: sub, deleteMode: deleteMode};
|
const body: CheckSubscriptionRequest = {sub_id: sub_id};
|
||||||
|
return this.http.post<SuccessObject>(this.path + 'checkSubscription', body, this.httpOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelCheckSubscription(sub_id: string) {
|
||||||
|
const body: CheckSubscriptionRequest = {sub_id: sub_id};
|
||||||
|
return this.http.post<SuccessObject>(this.path + 'cancelCheckSubscription', body, this.httpOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe(sub_id: string, deleteMode = false) {
|
||||||
|
const body: UnsubscribeRequest = {sub_id: sub_id, deleteMode: deleteMode};
|
||||||
return this.http.post<UnsubscribeResponse>(this.path + 'unsubscribe', body, this.httpOptions)
|
return this.http.post<UnsubscribeResponse>(this.path + 'unsubscribe', body, this.httpOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,32 +652,32 @@ export class PostsService implements CanActivate {
|
|||||||
return this.http.post<SuccessObject>(this.path + 'resetTasks', {}, this.httpOptions);
|
return this.http.post<SuccessObject>(this.path + 'resetTasks', {}, this.httpOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
getTask(task_key: string) {
|
getTask(task_key: TaskType) {
|
||||||
const body: GetTaskRequest = {task_key: task_key};
|
const body: GetTaskRequest = {task_key: task_key};
|
||||||
return this.http.post<GetTaskResponse>(this.path + 'getTask', body, this.httpOptions);
|
return this.http.post<GetTaskResponse>(this.path + 'getTask', body, this.httpOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
runTask(task_key: string) {
|
runTask(task_key: TaskType) {
|
||||||
const body: GetTaskRequest = {task_key: task_key};
|
const body: GetTaskRequest = {task_key: task_key};
|
||||||
return this.http.post<SuccessObject>(this.path + 'runTask', body, this.httpOptions);
|
return this.http.post<SuccessObject>(this.path + 'runTask', body, this.httpOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmTask(task_key: string) {
|
confirmTask(task_key: TaskType) {
|
||||||
const body: GetTaskRequest = {task_key: task_key};
|
const body: GetTaskRequest = {task_key: task_key};
|
||||||
return this.http.post<SuccessObject>(this.path + 'confirmTask', body, this.httpOptions);
|
return this.http.post<SuccessObject>(this.path + 'confirmTask', body, this.httpOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTaskSchedule(task_key: string, schedule: Schedule) {
|
updateTaskSchedule(task_key: TaskType, schedule: Schedule) {
|
||||||
const body: UpdateTaskScheduleRequest = {task_key: task_key, new_schedule: schedule};
|
const body: UpdateTaskScheduleRequest = {task_key: task_key, new_schedule: schedule};
|
||||||
return this.http.post<SuccessObject>(this.path + 'updateTaskSchedule', body, this.httpOptions);
|
return this.http.post<SuccessObject>(this.path + 'updateTaskSchedule', body, this.httpOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTaskData(task_key: string, data: any) {
|
updateTaskData(task_key: TaskType, data: any) {
|
||||||
const body: UpdateTaskDataRequest = {task_key: task_key, new_data: data};
|
const body: UpdateTaskDataRequest = {task_key: task_key, new_data: data};
|
||||||
return this.http.post<SuccessObject>(this.path + 'updateTaskData', body, this.httpOptions);
|
return this.http.post<SuccessObject>(this.path + 'updateTaskData', body, this.httpOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTaskOptions(task_key: string, options: any) {
|
updateTaskOptions(task_key: TaskType, options: any) {
|
||||||
const body: UpdateTaskOptionsRequest = {task_key: task_key, new_options: options};
|
const body: UpdateTaskOptionsRequest = {task_key: task_key, new_options: options};
|
||||||
return this.http.post<SuccessObject>(this.path + 'updateTaskOptions', body, this.httpOptions);
|
return this.http.post<SuccessObject>(this.path + 'updateTaskOptions', body, this.httpOptions);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<div style="margin-bottom: 15px;">
|
<div style="margin-bottom: 15px;">
|
||||||
<h2 style="text-align: center;" *ngIf="subscription">
|
<h2 style="text-align: center;" *ngIf="subscription">
|
||||||
{{subscription.name}} <ng-container *ngIf="subscription.paused" i18n="Paused suffix">(Paused)</ng-container>
|
{{subscription.name}} <ng-container *ngIf="subscription.paused" i18n="Paused suffix">(Paused)</ng-container>
|
||||||
|
<button class="edit-button" (click)="editSubscription()" [disabled]="downloading" matTooltip="Edit" i18n-matTooltip="Edit" mat-icon-button><mat-icon class="save-icon">edit</mat-icon></button>
|
||||||
</h2>
|
</h2>
|
||||||
<mat-progress-bar style="width: 80%; margin: 0 auto; margin-top: 15px;" *ngIf="subscription && subscription.downloading" mode="indeterminate"></mat-progress-bar>
|
<mat-progress-bar style="width: 80%; margin: 0 auto; margin-top: 15px;" *ngIf="subscription && subscription.downloading" mode="indeterminate"></mat-progress-bar>
|
||||||
</div>
|
</div>
|
||||||
@@ -13,7 +14,14 @@
|
|||||||
<div style="margin-bottom: 100px;" *ngIf="subscription">
|
<div style="margin-bottom: 100px;" *ngIf="subscription">
|
||||||
<app-recent-videos #recentVideos [sub_id]="subscription.id"></app-recent-videos>
|
<app-recent-videos #recentVideos [sub_id]="subscription.id"></app-recent-videos>
|
||||||
</div>
|
</div>
|
||||||
<button class="edit-button" color="primary" (click)="editSubscription()" [disabled]="downloading" matTooltip="Edit" i18n-matTooltip="Edit" mat-fab><mat-icon class="save-icon">edit</mat-icon></button>
|
<div class="check-button">
|
||||||
|
<ng-container *ngIf="subscription.downloading">
|
||||||
|
<button color="primary" (click)="cancelCheckSubscription()" [disabled]="cancel_clicked" matTooltip="Cancel subscription check" i18n-matTooltip="Cancel subscription check" mat-fab><mat-icon class="save-icon">cancel</mat-icon></button>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="!subscription.downloading">
|
||||||
|
<button color="primary" (click)="checkSubscription()" [disabled]="check_clicked" matTooltip="Check subscription" i18n-matTooltip="Check subscription" mat-fab><mat-icon class="save-icon">youtube_searched_for</mat-icon></button>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
<button class="watch-button" color="primary" (click)="watchSubscription()" matTooltip="Play all" i18n-matTooltip="Play all" mat-fab><mat-icon class="save-icon">video_library</mat-icon></button>
|
<button class="watch-button" color="primary" (click)="watchSubscription()" matTooltip="Play all" i18n-matTooltip="Play all" mat-fab><mat-icon class="save-icon">video_library</mat-icon></button>
|
||||||
<button class="save-button" color="primary" (click)="downloadContent()" [disabled]="downloading" matTooltip="Download zip" i18n-matTooltip="Download zip" mat-fab><mat-icon class="save-icon">save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="50"></mat-spinner></button>
|
<button class="save-button" color="primary" (click)="downloadContent()" [disabled]="downloading" matTooltip="Download zip" i18n-matTooltip="Download zip" mat-fab><mat-icon class="save-icon">save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="50"></mat-spinner></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,13 +58,19 @@
|
|||||||
bottom: 25px;
|
bottom: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-button {
|
.check-button {
|
||||||
left: 25px;
|
left: 25px;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 25px;
|
bottom: 25px;
|
||||||
z-index: 99999;
|
z-index: 99999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-button {
|
||||||
|
right: 35px;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 99999;
|
||||||
|
}
|
||||||
|
|
||||||
.save-icon {
|
.save-icon {
|
||||||
bottom: 1px;
|
bottom: 1px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { PostsService } from 'app/posts.services';
|
|||||||
import { ActivatedRoute, Router, ParamMap } from '@angular/router';
|
import { ActivatedRoute, Router, ParamMap } from '@angular/router';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { EditSubscriptionDialogComponent } from 'app/dialogs/edit-subscription-dialog/edit-subscription-dialog.component';
|
import { EditSubscriptionDialogComponent } from 'app/dialogs/edit-subscription-dialog/edit-subscription-dialog.component';
|
||||||
|
import { Subscription } from 'api-types';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-subscription',
|
selector: 'app-subscription',
|
||||||
@@ -12,11 +13,13 @@ import { EditSubscriptionDialogComponent } from 'app/dialogs/edit-subscription-d
|
|||||||
export class SubscriptionComponent implements OnInit, OnDestroy {
|
export class SubscriptionComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
id = null;
|
id = null;
|
||||||
subscription = null;
|
subscription: Subscription = null;
|
||||||
use_youtubedl_archive = false;
|
use_youtubedl_archive = false;
|
||||||
descendingMode = true;
|
descendingMode = true;
|
||||||
downloading = false;
|
downloading = false;
|
||||||
sub_interval = null;
|
sub_interval = null;
|
||||||
|
check_clicked = false;
|
||||||
|
cancel_clicked = false;
|
||||||
|
|
||||||
constructor(private postsService: PostsService, private route: ActivatedRoute, private router: Router, private dialog: MatDialog) { }
|
constructor(private postsService: PostsService, private route: ActivatedRoute, private router: Router, private dialog: MatDialog) { }
|
||||||
|
|
||||||
@@ -90,4 +93,34 @@ export class SubscriptionComponent implements OnInit, OnDestroy {
|
|||||||
this.router.navigate(['/player', {sub_id: this.subscription.id}])
|
this.router.navigate(['/player', {sub_id: this.subscription.id}])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkSubscription(): void {
|
||||||
|
this.check_clicked = true;
|
||||||
|
this.postsService.checkSubscription(this.subscription.id).subscribe(res => {
|
||||||
|
this.check_clicked = false;
|
||||||
|
if (!res['success']) {
|
||||||
|
this.postsService.openSnackBar('Failed to check subscription!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, err => {
|
||||||
|
console.error(err);
|
||||||
|
this.check_clicked = false;
|
||||||
|
this.postsService.openSnackBar('Failed to check subscription!');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelCheckSubscription(): void {
|
||||||
|
this.cancel_clicked = true;
|
||||||
|
this.postsService.cancelCheckSubscription(this.subscription.id).subscribe(res => {
|
||||||
|
this.cancel_clicked = false;
|
||||||
|
if (!res['success']) {
|
||||||
|
this.postsService.openSnackBar('Failed to cancel check subscription!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, err => {
|
||||||
|
console.error(err);
|
||||||
|
this.cancel_clicked = false;
|
||||||
|
this.postsService.openSnackBar('Failed to cancel check subscription!');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4133,6 +4133,60 @@
|
|||||||
<context context-type="linenumber">18</context>
|
<context context-type="linenumber">18</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="c748ac656af9f13998206ef2c52018dd418b0483" datatype="html">
|
||||||
|
<source>Archives</source>
|
||||||
|
<target state="translated">Arsip</target>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/app.component.html</context>
|
||||||
|
<context context-type="linenumber">26</context>
|
||||||
|
</context-group>
|
||||||
|
<note priority="1" from="description">Archives menu label</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="5ca707824ab93066c7d9b44e1b8bf216725c2c22" datatype="html">
|
||||||
|
<source>Filter</source>
|
||||||
|
<target state="translated">Penyaringan</target>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
|
||||||
|
<context context-type="linenumber">3</context>
|
||||||
|
</context-group>
|
||||||
|
<note priority="1" from="description">Filter</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="45cc8ca94b5a50842a9a8ef804a5ab089a38ae5c" datatype="html">
|
||||||
|
<source>ID</source>
|
||||||
|
<target state="translated">ID</target>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
|
||||||
|
<context context-type="linenumber">47</context>
|
||||||
|
</context-group>
|
||||||
|
<note priority="1" from="description">ID</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="28da11220a3377ddce3c7948825d33101f142782" datatype="html">
|
||||||
|
<source>Extractor</source>
|
||||||
|
<target state="needs-translation">Extractor</target>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
|
||||||
|
<context context-type="linenumber">57</context>
|
||||||
|
</context-group>
|
||||||
|
<note priority="1" from="description">Extractor</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="c150a30bbbdb175b4d08820196a9acb66b167653" datatype="html">
|
||||||
|
<source>Archives empty</source>
|
||||||
|
<target state="translated">Arsip kosong</target>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
|
||||||
|
<context context-type="linenumber">72</context>
|
||||||
|
</context-group>
|
||||||
|
<note priority="1" from="description">Archives empty</note>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="51a161ce175abcd44f6c6cbd0e996681bf553ac3" datatype="html">
|
||||||
|
<source>Delete selected</source>
|
||||||
|
<target state="translated">Hapus yang dipilih</target>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
|
||||||
|
<context context-type="linenumber">77</context>
|
||||||
|
</context-group>
|
||||||
|
<note priority="1" from="description">Delete selected</note>
|
||||||
|
</trans-unit>
|
||||||
</body>
|
</body>
|
||||||
</file>
|
</file>
|
||||||
</xliff>
|
</xliff>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user