Compare commits

..

12 Commits

Author SHA1 Message Date
Isaac Abadi
c9c0247480 Tasks actions styling update 2023-05-21 00:12:58 -06:00
Isaac Abadi
2fcf5364d8 Rebuild database task now asks for confirmation before rebuilding
Fixed api types build errors
2023-05-21 00:11:48 -06:00
Isaac Abadi
a38fb0e2e0 Updated api models 2023-05-20 23:49:27 -06:00
Isaac Abadi
e6050969ec Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into rebuild-database-task 2023-05-20 23:44:49 -06:00
Isaac Abadi
958300c281 Added typing to task key 2023-05-20 23:44:14 -06:00
Isaac Abadi
078408236c Code cleanup
Default user password after rebuild is now 'password'
2023-05-20 18:27:08 -06:00
Isaac Abadi
03122b4c81 Subscription metadata is now backed up
Rebuild database now used subscription metadata backup
2023-05-18 23:32:12 -04:00
Isaac Abadi
3deb1e8459 updated subscriptions.js export syntax 2023-05-18 22:23:48 -04:00
Tzahi12345
35fcf44e1a Fixed rebuild db log messages
Rebuild db now imports unregistered files
2023-05-08 02:08:01 -04:00
Tzahi12345
bad6080730 Adds task to rebuild database 2023-05-08 02:00:43 -04:00
Tzahi12345
2a7b62272e Merge remote-tracking branch 'origin/testing-improvements' 2023-05-07 23:36:35 -04:00
Isaac Abadi
0c46b044da Improved tests for multi-user mode 2023-05-06 23:29:20 -04:00
78 changed files with 8706 additions and 15946 deletions

View File

@@ -1,39 +0,0 @@
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
{
"name": "Node.js",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/javascript-node:0-18-bullseye",
"features": {
"ghcr.io/devcontainers-contrib/features/jshint:2": {},
"ghcr.io/devcontainers-contrib/features/angular-cli:2": {},
"ghcr.io/devcontainers/features/github-cli:1": {}
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [4200, 17442],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "npm install && cd backend && npm install",
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"ms-python.python",
"Angular.ng-template",
"dbaeumer.vscode-eslint",
"waderyan.gitblame",
"42Crunch.vscode-openapi",
"christian-kohler.npm-intellisense",
"redhat.vscode-yaml",
"hbenl.vscode-mocha-test-adapter",
"DavidAnson.vscode-markdownlint"
]
}
}
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: checkout code - name: checkout code
uses: actions/checkout@v3 uses: actions/checkout@v2
- name: setup node - name: setup node
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
@@ -65,7 +65,7 @@ jobs:
if: contains(github.ref, '/tags/v') if: contains(github.ref, '/tags/v')
steps: steps:
- name: checkout code - name: checkout code
uses: actions/checkout@v3 uses: actions/checkout@v2
- name: create release - name: create release
id: create_release id: create_release
uses: actions/create-release@v1 uses: actions/create-release@v1
@@ -81,7 +81,7 @@ jobs:
draft: true draft: true
prerelease: false prerelease: false
- name: download build artifact - name: download build artifact
uses: actions/download-artifact@v3 uses: actions/download-artifact@v1
with: with:
name: youtubedl-material name: youtubedl-material
path: ${{runner.temp}}/youtubedl-material path: ${{runner.temp}}/youtubedl-material

View File

@@ -30,7 +30,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v2
with: with:
# We must fetch at least the immediate parents so that if this is # We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head. # a pull request then we can checkout the head.
@@ -43,7 +43,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v2 uses: github/codeql-action/init@v1
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -54,7 +54,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v2 uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl # 📚 https://git.io/JvXDl
@@ -68,4 +68,4 @@ jobs:
# make release # make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2 uses: github/codeql-action/analyze@v1

View File

@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: checkout code - name: checkout code
uses: actions/checkout@v3 uses: actions/checkout@v2
- name: Set hash - name: Set hash
id: vars id: vars
run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)" run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
@@ -24,11 +24,11 @@ jobs:
json: '{"type": "docker", "tag": "nightly", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}' json: '{"type": "docker", "tag": "nightly", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}'
dir: 'backend/' dir: 'backend/'
- name: setup platform emulator - name: setup platform emulator
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v1
- name: setup multi-arch docker build - name: setup multi-arch docker build
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v1
- name: build & push images - name: build & push images
uses: docker/build-push-action@v4 uses: docker/build-push-action@v2
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile

View File

@@ -15,7 +15,7 @@ jobs:
steps: steps:
- name: checkout code - name: checkout code
uses: actions/checkout@v3 uses: actions/checkout@v2
- name: Set hash - name: Set hash
id: vars id: vars
@@ -57,7 +57,7 @@ jobs:
type=raw,value=latest type=raw,value=latest
- name: setup platform emulator - name: setup platform emulator
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v1
- name: setup multi-arch docker build - name: setup multi-arch docker build
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
@@ -76,7 +76,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: build & push images - name: build & push images
uses: docker/build-push-action@v4 uses: docker/build-push-action@v2
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile

View File

@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: checkout code - name: checkout code
uses: actions/checkout@v3 uses: actions/checkout@v2
- name: Set hash - name: Set hash
id: vars id: vars
@@ -41,7 +41,7 @@ jobs:
dir: 'backend/' dir: 'backend/'
- name: setup platform emulator - name: setup platform emulator
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v1
- name: setup multi-arch docker build - name: setup multi-arch docker build
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
@@ -76,11 +76,11 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: build & push images - name: build & push images
uses: docker/build-push-action@v4 uses: docker/build-push-action@v2
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64/v8,linux/arm/v7 platforms: linux/amd64,linux/arm64/v8
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 }}

View File

@@ -1,40 +0,0 @@
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@v3
- name: 'Cache node_modules'
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-v${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-v${{ matrix.node }}-
working-directory: ./backend
- uses: FedericoCarboni/setup-ffmpeg@v2
id: setup-ffmpeg
- name: Install Dependencies
run: npm install
working-directory: ./backend
- name: Run All Node.js Tests
run: npm run test
working-directory: ./backend

View File

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

View File

@@ -19,23 +19,26 @@ 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
ENV npm_config_cache=/app/.npm # 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 libatomic1 && \ apt install -y --no-install-recommends curl ca-certificates tzdata libicu70 && \
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

View File

@@ -293,48 +293,6 @@ 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:
@@ -2023,11 +1981,11 @@ components:
type: string type: string
UnsubscribeRequest: UnsubscribeRequest:
required: required:
- sub_id - sub
type: object type: object
properties: properties:
sub_id: sub:
type: string $ref: '#/components/schemas/SubscriptionRequestData'
deleteMode: deleteMode:
type: boolean type: boolean
description: Defaults to false description: Defaults to false
@@ -2040,13 +1998,6 @@ 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:
@@ -2732,8 +2683,6 @@ components:
type: boolean type: boolean
paused: paused:
type: boolean type: boolean
cancelled:
type: boolean
finished_step: finished_step:
type: boolean type: boolean
url: url:
@@ -2892,8 +2841,6 @@ components:
nullable: true nullable: true
isPlaylist: isPlaylist:
type: boolean type: boolean
child_process:
type: object
archive: archive:
type: string type: string
timerange: timerange:
@@ -2902,10 +2849,6 @@ 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:

View File

@@ -66,14 +66,6 @@
} }
] ]
}, },
"codespaces": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.codespaces.ts"
}
]
},
"es": { "es": {
"localize": ["es"] "localize": ["es"]
} }
@@ -91,9 +83,6 @@
}, },
"es": { "es": {
"browserTarget": "youtube-dl-material:build:es" "browserTarget": "youtube-dl-material:build:es"
},
"codespaces": {
"browserTarget": "youtube-dl-material:build:codespaces"
} }
} }
}, },

View File

@@ -1,4 +1,4 @@
const { v4: uuid } = require('uuid'); const { uuid } = require('uuidv4');
const fs = require('fs-extra'); const fs = require('fs-extra');
const { promisify } = require('util'); const { promisify } = require('util');
const auth_api = require('./authentication/auth'); const auth_api = require('./authentication/auth');
@@ -20,6 +20,11 @@ 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');
@@ -30,7 +35,6 @@ const twitch_api = require('./twitch');
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 notifications_api = require('./notifications');
var app = express(); var app = express();
@@ -532,9 +536,15 @@ async function loadConfig() {
// set downloading to false // set downloading to false
let subscriptions = await subscriptions_api.getAllSubscriptions(); let subscriptions = await subscriptions_api.getAllSubscriptions();
subscriptions.forEach(async sub => subscriptions_api.writeSubscriptionMetadata(sub)); subscriptions.forEach(async sub => subscriptions_api.writeSubscriptionMetadata(sub));
subscriptions_api.updateSubscriptionPropertyMultiple(subscriptions, {downloading: false, child_process: null}); subscriptions_api.updateSubscriptionPropertyMultiple(subscriptions, {downloading: false});
// runs initially, then runs every ${subscriptionCheckInterval} seconds // runs initially, then runs every ${subscriptionCheckInterval} seconds
subscriptions_api.watchSubscriptionsInterval(); const watchSubscriptionsInterval = function() {
watchSubscriptions();
const subscriptionsCheckInterval = config_api.getConfigItem('ytdl_subscriptions_check_interval');
setTimeout(watchSubscriptionsInterval, subscriptionsCheckInterval*1000);
}
watchSubscriptionsInterval();
} }
// start the server here // start the server here
@@ -564,8 +574,63 @@ function loadConfigValues() {
utils.updateLoggerLevel(logger_level); utils.updateLoggerLevel(logger_level);
} }
function calculateSubcriptionRetrievalDelay(subscriptions_amount) {
// frequency is once every 5 mins by default
const subscriptionsCheckInterval = config_api.getConfigItem('ytdl_subscriptions_check_interval');
let interval_in_ms = subscriptionsCheckInterval * 1000;
const subinterval_in_ms = interval_in_ms/subscriptions_amount;
return subinterval_in_ms;
}
async function watchSubscriptions() {
let subscriptions = await subscriptions_api.getAllSubscriptions();
if (!subscriptions) return;
// auto pause deprecated streamingOnly mode
const streaming_only_subs = subscriptions.filter(sub => sub.streamingOnly);
subscriptions_api.updateSubscriptionPropertyMultiple(streaming_only_subs, {paused: true});
const valid_subscriptions = subscriptions.filter(sub => !sub.paused && !sub.streamingOnly);
let subscriptions_amount = valid_subscriptions.length;
let delay_interval = calculateSubcriptionRetrievalDelay(subscriptions_amount);
let current_delay = 0;
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
for (let i = 0; i < valid_subscriptions.length; i++) {
let sub = valid_subscriptions[i];
// don't check the sub if the last check for the same subscription has not completed
if (subscription_timeouts[sub.id]) {
logger.verbose(`Subscription: skipped checking ${sub.name} as the last check for ${sub.name} has not completed.`);
continue;
}
if (!sub.name) {
logger.verbose(`Subscription: skipped check for subscription with uid ${sub.id} as name has not been retrieved yet.`);
continue;
}
logger.verbose('Watching ' + sub.name + ' with delay interval of ' + delay_interval);
setTimeout(async () => {
const multiUserModeChanged = config_api.getConfigItem('ytdl_multi_user_mode') !== multiUserMode;
if (multiUserModeChanged) {
logger.verbose(`Skipping subscription ${sub.name} due to multi-user mode change.`);
return;
}
await subscriptions_api.getVideosForSub(sub, sub.user_uid);
subscription_timeouts[sub.id] = false;
}, current_delay);
subscription_timeouts[sub.id] = true;
current_delay += delay_interval;
const subscriptionsCheckInterval = config_api.getConfigItem('ytdl_subscriptions_check_interval');
if (current_delay >= subscriptionsCheckInterval * 1000) current_delay = 0;
}
}
function getOrigin() { function getOrigin() {
if (process.env.CODESPACES) return `https://${process.env.CODESPACE_NAME}-4200.${process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}`;
return url_domain.origin; return url_domain.origin;
} }
@@ -592,20 +657,36 @@ function generateEnvVarConfigItem(key) {
// currently only works for single urls // currently only works for single urls
async function getUrlInfos(url) { async function getUrlInfos(url) {
const {parsed_output, err} = await youtubedl_api.runYoutubeDL(url, ['--dump-json']); let startDate = Date.now();
if (!parsed_output || parsed_output.length !== 1) { let result = [];
logger.error(`Failed to retrieve available formats for url: ${url}`); return new Promise(resolve => {
if (err) logger.error(err); youtubedl.exec(url, ['--dump-json'], {maxBuffer: Infinity}, (err, output) => {
return null; let new_date = Date.now();
} let difference = (new_date - startDate)/1000;
return parsed_output[0]; logger.debug(`URL info retrieval delay: ${difference} seconds.`);
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
await youtubedl_api.checkForYoutubeDLUpdate(); youtubedl_api.verifyBinaryExistsLinux();
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) {
@@ -625,7 +706,7 @@ app.use(function(req, res, next) {
next(); next();
} else if (req.query.apiKey && config_api.getConfigItem('ytdl_use_api_key') && req.query.apiKey === config_api.getConfigItem('ytdl_api_key')) { } else if (req.query.apiKey && config_api.getConfigItem('ytdl_use_api_key') && req.query.apiKey === config_api.getConfigItem('ytdl_api_key')) {
next(); next();
} else if (req.path.includes('/api/stream/') || req.path.includes('/api/thumbnail/') || req.path.includes('/api/rss') || req.path.includes('/api/telegramRequest')) { } else if (req.path.includes('/api/stream/') || req.path.includes('/api/thumbnail/') || req.path.includes('/api/rss')) {
next(); next();
} else { } else {
logger.verbose(`Rejecting request - invalid API use for endpoint: ${req.path}. API key received: ${req.query.apiKey}`); logger.verbose(`Rejecting request - invalid API use for endpoint: ${req.path}. API key received: ${req.query.apiKey}`);
@@ -1131,10 +1212,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_id = req.body.sub_id; let sub = req.body.sub;
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_id, deleteMode, user_uid); let result_obj = subscriptions_api.unsubscribe(sub, deleteMode, user_uid);
if (result_obj.success) { if (result_obj.success) {
res.send({ res.send({
success: result_obj.success success: result_obj.success
@@ -1204,49 +1285,21 @@ app.post('/api/getSubscription', optionalJwt, async (req, res) => {
}); });
app.post('/api/downloadVideosForSubscription', optionalJwt, async (req, res) => { app.post('/api/downloadVideosForSubscription', optionalJwt, async (req, res) => {
const subID = req.body.subID; let subID = req.body.subID;
let user_uid = req.isAuthenticated() ? req.user.uid : null;
const sub = subscriptions_api.getSubscription(subID); let sub = subscriptions_api.getSubscription(subID, user_uid);
subscriptions_api.getVideosForSub(sub.id); subscriptions_api.getVideosForSub(sub, user_uid);
res.send({ res.send({
success: true success: true
}); });
}); });
app.post('/api/updateSubscription', optionalJwt, async (req, res) => { app.post('/api/updateSubscription', optionalJwt, async (req, res) => {
const updated_sub = req.body.subscription; let updated_sub = req.body.subscription;
const success = subscriptions_api.updateSubscription(updated_sub);
res.send({
success: success
});
});
app.post('/api/checkSubscription', optionalJwt, async (req, res) => {
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;
const success = subscriptions_api.getVideosForSub(sub_id, user_uid); let success = subscriptions_api.updateSubscription(updated_sub, 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({ res.send({
success: success success: success
}); });
@@ -1590,7 +1643,6 @@ app.get('/api/stream', optionalJwt, async (req, res) => {
} }
if (!fs.existsSync(file_path)) { if (!fs.existsSync(file_path)) {
logger.error(`File ${file_path} could not be found! UID: ${uid}, ID: ${file_obj && file_obj.id}`); logger.error(`File ${file_path} could not be found! UID: ${uid}, ID: ${file_obj && file_obj.id}`);
return;
} }
const stat = fs.statSync(file_path); const stat = fs.statSync(file_path);
const fileSize = stat.size; const fileSize = stat.size;
@@ -1725,10 +1777,6 @@ app.post('/api/cancelDownload', optionalJwt, async (req, res) => {
app.post('/api/getTasks', optionalJwt, async (req, res) => { app.post('/api/getTasks', optionalJwt, async (req, res) => {
const tasks = await db_api.getRecords('tasks'); const tasks = await db_api.getRecords('tasks');
for (let task of tasks) { for (let task of tasks) {
if (!tasks_api.TASKS[task['key']]) {
logger.verbose(`Task ${task['key']} does not exist!`);
continue;
}
if (task['schedule']) task['next_invocation'] = tasks_api.TASKS[task['key']]['job'].nextInvocation().getTime(); if (task['schedule']) task['next_invocation'] = tasks_api.TASKS[task['key']]['job'].nextInvocation().getTime();
} }
res.send({tasks: tasks}); res.send({tasks: tasks});
@@ -1897,10 +1945,6 @@ app.post('/api/auth/register', optionalJwt, async (req, res) => {
res.sendStatus(409); res.sendStatus(409);
return; return;
} }
if (!userid || !username) {
logger.error(`Registration failed for user ${userid}. Username or userid is invalid.`);
}
const new_user = await auth_api.registerUser(userid, username, plaintextPassword); const new_user = await auth_api.registerUser(userid, username, plaintextPassword);
@@ -2037,25 +2081,6 @@ app.post('/api/deleteAllNotifications', optionalJwt, async (req, res) => {
res.send({success: success}); res.send({success: success});
}); });
app.post('/api/telegramRequest', async (req, res) => {
if (!req.body.message && !req.body.message.text) {
logger.error('Invalid Telegram request received!');
res.sendStatus(400);
return;
}
const text = req.body.message.text;
const regex_exp = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)?/gi;
const url_regex = new RegExp(regex_exp);
if (text.match(url_regex)) {
downloader_api.createDownload(text, 'video', {}, req.query.user_uid ? req.query.user_uid : null);
res.sendStatus(200);
} else {
logger.error('Invalid Telegram request received! Make sure you only send a valid URL.');
notifications_api.sendTelegramNotification({title: 'Invalid Telegram Request', body: 'Make sure you only send a valid URL.', url: text});
res.sendStatus(400);
}
});
// rss feed // rss feed
app.get('/api/rss', async function (req, res) { app.get('/api/rss', async function (req, res) {
@@ -2123,8 +2148,6 @@ app.use(function(req, res, next) {
let index_path = path.join(__dirname, 'public', 'index.html'); let index_path = path.join(__dirname, 'public', 'index.html');
res.setHeader('Content-Type', 'text/html');
fs.createReadStream(index_path).pipe(res); fs.createReadStream(index_path).pipe(res);
}); });

View File

@@ -49,7 +49,6 @@
"use_telegram_API": false, "use_telegram_API": false,
"telegram_bot_token": "", "telegram_bot_token": "",
"telegram_chat_id": "", "telegram_chat_id": "",
"telegram_webhook_proxy": "",
"webhook_URL": "", "webhook_URL": "",
"discord_webhook_URL": "", "discord_webhook_URL": "",
"slack_webhook_URL": "" "slack_webhook_URL": ""

View File

@@ -1,6 +1,6 @@
const path = require('path'); const path = require('path');
const fs = require('fs-extra'); const fs = require('fs-extra');
const { v4: uuid } = require('uuid'); const { uuid } = require('uuidv4');
const db_api = require('./db'); const db_api = require('./db');

View File

@@ -4,7 +4,7 @@ const logger = require('../logger');
const db_api = require('../db'); const db_api = require('../db');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { v4: uuid } = require('uuid'); const { uuid } = require('uuidv4');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const fs = require('fs-extra'); const fs = require('fs-extra');
const path = require('path'); const path = require('path');

View File

@@ -32,8 +32,10 @@ async function categorize(file_jsons) {
return null; return null;
} }
for (const file_json of file_jsons) { for (let i = 0; i < file_jsons.length; i++) {
for (const category of categories) { const file_json = file_jsons[i];
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

View File

@@ -1,26 +1,22 @@
const logger = require('./logger'); const logger = require('./logger');
const fs = require('fs'); const fs = require('fs');
const { BehaviorSubject } = require('rxjs');
exports.CONFIG_ITEMS = require('./consts.js')['CONFIG_ITEMS'];
exports.descriptors = {}; // to get rid of file locks when needed, TODO: move to youtube-dl.js
let CONFIG_ITEMS = require('./consts.js')['CONFIG_ITEMS'];
const debugMode = process.env.YTDL_MODE === 'debug'; const debugMode = process.env.YTDL_MODE === 'debug';
let configPath = debugMode ? '../src/assets/default.json' : 'appdata/default.json'; let configPath = debugMode ? '../src/assets/default.json' : 'appdata/default.json';
exports.config_updated = new BehaviorSubject();
exports.initialize = () => { function initialize() {
ensureConfigFileExists(); ensureConfigFileExists();
ensureConfigItemsExist(); ensureConfigItemsExist();
} }
function ensureConfigItemsExist() { function ensureConfigItemsExist() {
const config_keys = Object.keys(exports.CONFIG_ITEMS); const config_keys = Object.keys(CONFIG_ITEMS);
for (let i = 0; i < config_keys.length; i++) { for (let i = 0; i < config_keys.length; i++) {
const config_key = config_keys[i]; const config_key = config_keys[i];
exports.getConfigItem(config_key); getConfigItem(config_key);
} }
} }
@@ -61,17 +57,17 @@ function getElementNameInConfig(path) {
/** /**
* Check if config exists. If not, write default config to config path * Check if config exists. If not, write default config to config path
*/ */
exports.configExistsCheck = () => { function configExistsCheck() {
let exists = fs.existsSync(configPath); let exists = fs.existsSync(configPath);
if (!exists) { if (!exists) {
exports.setConfigFile(DEFAULT_CONFIG); setConfigFile(DEFAULT_CONFIG);
} }
} }
/* /*
* Gets config file and returns as a json * Gets config file and returns as a json
*/ */
exports.getConfigFile = () => { function getConfigFile() {
try { try {
let raw_data = fs.readFileSync(configPath); let raw_data = fs.readFileSync(configPath);
let parsed_data = JSON.parse(raw_data); let parsed_data = JSON.parse(raw_data);
@@ -82,40 +78,35 @@ exports.getConfigFile = () => {
} }
} }
exports.setConfigFile = (config) => { function setConfigFile(config) {
try { try {
const old_config = exports.getConfigFile();
fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const changes = exports.findChangedConfigItems(old_config, config);
if (changes.length > 0) {
for (const change of changes) exports.config_updated.next(change);
}
return true; return true;
} catch(e) { } catch(e) {
return false; return false;
} }
} }
exports.getConfigItem = (key) => { function getConfigItem(key) {
let config_json = exports.getConfigFile(); let config_json = getConfigFile();
if (!exports.CONFIG_ITEMS[key]) { if (!CONFIG_ITEMS[key]) {
logger.error(`Config item with key '${key}' is not recognized.`); logger.error(`Config item with key '${key}' is not recognized.`);
return null; return null;
} }
let path = exports.CONFIG_ITEMS[key]['path']; let path = CONFIG_ITEMS[key]['path'];
const val = Object.byString(config_json, path); const val = Object.byString(config_json, path);
if (val === undefined && Object.byString(DEFAULT_CONFIG, path) !== undefined) { if (val === undefined && Object.byString(DEFAULT_CONFIG, path) !== undefined) {
logger.warn(`Cannot find config with key '${key}'. Creating one with the default value...`); logger.warn(`Cannot find config with key '${key}'. Creating one with the default value...`);
exports.setConfigItem(key, Object.byString(DEFAULT_CONFIG, path)); setConfigItem(key, Object.byString(DEFAULT_CONFIG, path));
return Object.byString(DEFAULT_CONFIG, path); return Object.byString(DEFAULT_CONFIG, path);
} }
return Object.byString(config_json, path); return Object.byString(config_json, path);
} }
exports.setConfigItem = (key, value) => { function setConfigItem(key, value) {
let success = false; let success = false;
let config_json = exports.getConfigFile(); let config_json = getConfigFile();
let path = exports.CONFIG_ITEMS[key]['path']; let path = CONFIG_ITEMS[key]['path'];
let element_name = getElementNameInConfig(path); let element_name = getElementNameInConfig(path);
let parent_path = getParentPath(path); let parent_path = getParentPath(path);
let parent_object = Object.byString(config_json, parent_path); let parent_object = Object.byString(config_json, parent_path);
@@ -127,18 +118,20 @@ exports.setConfigItem = (key, value) => {
parent_parent_object[parent_parent_single_key] = {}; parent_parent_object[parent_parent_single_key] = {};
parent_object = Object.byString(config_json, parent_path); parent_object = Object.byString(config_json, parent_path);
} }
if (value === 'false') value = false;
if (value === 'true') value = true;
parent_object[element_name] = value;
success = exports.setConfigFile(config_json); if (value === 'false' || value === 'true') {
parent_object[element_name] = (value === 'true');
} else {
parent_object[element_name] = value;
}
success = setConfigFile(config_json);
return success; return success;
} }
exports.setConfigItems = (items) => { function setConfigItems(items) {
let success = false; let success = false;
let config_json = exports.getConfigFile(); let config_json = getConfigFile();
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
let key = items[i].key; let key = items[i].key;
let value = items[i].value; let value = items[i].value;
@@ -148,7 +141,7 @@ exports.setConfigItems = (items) => {
value = (value === 'true'); value = (value === 'true');
} }
let item_path = exports.CONFIG_ITEMS[key]['path']; let item_path = CONFIG_ITEMS[key]['path'];
let item_parent_path = getParentPath(item_path); let item_parent_path = getParentPath(item_path);
let item_element_name = getElementNameInConfig(item_path); let item_element_name = getElementNameInConfig(item_path);
@@ -156,41 +149,28 @@ exports.setConfigItems = (items) => {
item_parent_object[item_element_name] = value; item_parent_object[item_element_name] = value;
} }
success = exports.setConfigFile(config_json); success = setConfigFile(config_json);
return success; return success;
} }
exports.globalArgsRequiresSafeDownload = () => { function globalArgsRequiresSafeDownload() {
const globalArgs = exports.getConfigItem('ytdl_custom_args').split(',,'); const globalArgs = getConfigItem('ytdl_custom_args').split(',,');
const argsThatRequireSafeDownload = ['--write-sub', '--write-srt', '--proxy']; const argsThatRequireSafeDownload = ['--write-sub', '--write-srt', '--proxy'];
const failedArgs = globalArgs.filter(arg => argsThatRequireSafeDownload.includes(arg)); const failedArgs = globalArgs.filter(arg => argsThatRequireSafeDownload.includes(arg));
return failedArgs && failedArgs.length > 0; return failedArgs && failedArgs.length > 0;
} }
exports.findChangedConfigItems = (old_config, new_config, path = '', changedConfigItems = [], depth = 0) => { module.exports = {
if (typeof old_config === 'object' && typeof new_config === 'object' && depth < 3) { getConfigItem: getConfigItem,
for (const key in old_config) { setConfigItem: setConfigItem,
if (Object.prototype.hasOwnProperty.call(new_config, key)) { setConfigItems: setConfigItems,
exports.findChangedConfigItems(old_config[key], new_config[key], `${path}${path ? '.' : ''}${key}`, changedConfigItems, depth + 1); getConfigFile: getConfigFile,
} setConfigFile: setConfigFile,
} configExistsCheck: configExistsCheck,
} else { CONFIG_ITEMS: CONFIG_ITEMS,
if (JSON.stringify(old_config) !== JSON.stringify(new_config)) { initialize: initialize,
const key = getConfigItemKeyByPath(path); descriptors: {},
changedConfigItems.push({ globalArgsRequiresSafeDownload: globalArgsRequiresSafeDownload
key: key ? key : path.split('.')[path.split('.').length - 1], // return key in CONFIG_ITEMS or the object key
old_value: JSON.parse(JSON.stringify(old_config)),
new_value: JSON.parse(JSON.stringify(new_config))
});
}
}
return changedConfigItems;
}
function getConfigItemKeyByPath(path) {
const found_item = Object.values(exports.CONFIG_ITEMS).find(item => item.path === path);
if (found_item) return found_item['key'];
else return null;
} }
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
@@ -239,7 +219,6 @@ const DEFAULT_CONFIG = {
"use_telegram_API": false, "use_telegram_API": false,
"telegram_bot_token": "", "telegram_bot_token": "",
"telegram_chat_id": "", "telegram_chat_id": "",
"telegram_webhook_proxy": "",
"webhook_URL": "", "webhook_URL": "",
"discord_webhook_URL": "", "discord_webhook_URL": "",
"slack_webhook_URL": "", "slack_webhook_URL": "",

View File

@@ -154,10 +154,6 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_telegram_chat_id', 'key': 'ytdl_telegram_chat_id',
'path': 'YoutubeDLMaterial.API.telegram_chat_id' 'path': 'YoutubeDLMaterial.API.telegram_chat_id'
}, },
'ytdl_telegram_webhook_proxy': {
'key': 'ytdl_telegram_webhook_proxy',
'path': 'YoutubeDLMaterial.API.telegram_webhook_proxy'
},
'ytdl_webhook_url': { 'ytdl_webhook_url': {
'key': 'ytdl_webhook_url', 'key': 'ytdl_webhook_url',
'path': 'YoutubeDLMaterial.API.webhook_URL' 'path': 'YoutubeDLMaterial.API.webhook_URL'
@@ -273,8 +269,7 @@ exports.AVAILABLE_PERMISSIONS = [
'tasks_manager' 'tasks_manager'
]; ];
exports.DETAILS_BIN_PATH = 'appdata/youtube-dl.json' exports.DETAILS_BIN_PATH = 'node_modules/youtube-dl/bin/details'
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 = [
@@ -359,4 +354,4 @@ exports.YTDL_ARGS_WITH_VALUES = new Set(YTDL_ARGS_WITH_VALUES);
exports.ICON_URL = 'https://i.imgur.com/IKOlr0N.png'; exports.ICON_URL = 'https://i.imgur.com/IKOlr0N.png';
exports.CURRENT_VERSION = 'v4.3.2'; exports.CURRENT_VERSION = 'v4.3.1';

View File

@@ -1,6 +1,7 @@
const fs = require('fs-extra') const fs = require('fs-extra')
const path = require('path') const path = require('path')
const { MongoClient } = require("mongodb"); const { MongoClient } = require("mongodb");
const { uuid } = require('uuidv4');
const _ = require('lodash'); const _ = require('lodash');
const config_api = require('./config'); const config_api = require('./config');
@@ -10,8 +11,9 @@ 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);
@@ -71,6 +73,10 @@ 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) {
@@ -79,18 +85,11 @@ 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, db_name = 'local_db.json') => { exports.initialize = (input_db, input_users_db) => {
setDB(input_db, input_users_db); setDB(input_db, input_users_db);
// must be done here to prevent getConfigItem from being called before init // must be done here to prevent getConfigItem from being called before init
using_local_db = config_api.getConfigItem('ytdl_use_local_db'); using_local_db = config_api.getConfigItem('ytdl_use_local_db');
const local_adapter = new FileSync(`./appdata/${db_name}`);
local_db = low(local_adapter);
const local_db_defaults = {}
tables_list.forEach(table => {local_db_defaults[table] = []});
local_db.defaults(local_db_defaults).write();
} }
exports.connectToDB = async (retries = 5, no_fallback = false, custom_connection_string = null) => { exports.connectToDB = async (retries = 5, no_fallback = false, custom_connection_string = null) => {

View File

@@ -1,11 +1,12 @@
const fs = require('fs-extra'); const fs = require('fs-extra');
const { v4: uuid } = require('uuid'); const { uuid } = require('uuidv4');
const path = require('path'); 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');
@@ -19,13 +20,11 @@ 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) {
exports.setupDownloads(); setupDownloads();
} else { } else {
db_api.database_initialized_bs.subscribe(init => { db_api.database_initialized_bs.subscribe(init => {
if (init) exports.setupDownloads(); if (init) setupDownloads();
}); });
} }
@@ -48,7 +47,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, paused = false) => { exports.createDownload = async (url, type, options, user_uid = null, sub_id = null, sub_name = null, prefetched_info = null) => {
return await mutex.runExclusive(async () => { return await mutex.runExclusive(async () => {
const download = { const download = {
url: url, url: url,
@@ -61,7 +60,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: paused, paused: false,
running: false, running: false,
finished_step: true, finished_step: true,
error: null, error: null,
@@ -84,11 +83,8 @@ 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});
} }
@@ -123,28 +119,21 @@ 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_uid, error_message, error_type = null) { async function handleDownloadError(download, error_message, error_type = null) {
if (!download_uid) return; if (!download || !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});
} }
exports.setupDownloads = async () => { async function setupDownloads() {
await fixDownloadState(); await fixDownloadState();
setInterval(checkDownloads, 1000); setInterval(checkDownloads, 1000);
} }
@@ -190,30 +179,22 @@ 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['uid'], `Download failed as subscription with id '${waiting_download['sub_id']}' is missing!`, 'sub_id_missing'); handleDownloadError(waiting_download, `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) {
exports.collectInfo(waiting_download['uid']); collectInfo(waiting_download['uid']);
} else if (waiting_download['step_index'] === 1) { } else if (waiting_download['step_index'] === 1) {
exports.downloadQueuedFile(waiting_download['uid']); downloadQueuedFile(waiting_download['uid']);
} }
} }
} }
} }
function killActiveDownload(download) { async function collectInfo(download_uid) {
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;
@@ -236,21 +217,21 @@ exports.collectInfo = async (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 || info.length === 0) { if (!info) {
// 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 && info.length === 1) { if (useYoutubeDLArchive && !options.ignoreArchive) {
const info_obj = info[0]; const exists_in_archive = await archive_api.existsInArchive(info['extractor'], info['id'], type, download['user_uid'], download['sub_id']);
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_obj['title']}' already exists in archive! Disable the archive or override to continue downloading.`; const error = `File '${info['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) {
await handleDownloadError(download_uid, error, 'exists_in_archive'); const download = await db_api.getRecord('download_queue', {uid: download_uid});
await handleDownloadError(download, error, 'exists_in_archive');
return; return;
} }
} }
@@ -259,7 +240,7 @@ exports.collectInfo = async (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 (info.length === 1 || config_api.getConfigItem('ytdl_allow_playlist_categorization')) category = await categories_api.categorize(info); if (!Array.isArray(info) || 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']) {
@@ -278,22 +259,26 @@ exports.collectInfo = async (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
for (let info_obj of info) files_to_check_for_progress.push(utils.removeFileExtension(info_obj['_filename'])); if (Array.isArray(info)) {
for (let info_obj of info) files_to_check_for_progress.push(utils.removeFileExtension(info_obj['_filename']));
} else {
files_to_check_for_progress.push(utils.removeFileExtension(info['_filename']));
}
const title = info.length > 1 ? info[0]['playlist_title'] || info[0]['playlist'] : info[0]['title']; const playlist_title = Array.isArray(info) ? info[0]['playlist_title'] || info[0]['playlist'] : null;
await db_api.updateRecord('download_queue', {uid: download_uid}, {args: args, await db_api.updateRecord('download_queue', {uid: download_uid}, {args: args,
finished_step: true, finished_step: true,
running: false, running: false,
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: title, title: playlist_title ? playlist_title : info['title'],
category: stripped_category, category: stripped_category,
prefetched_info: null prefetched_info: null
}); });
} }
exports.downloadQueuedFile = async(download_uid, customDownloadHandler = null) => { async function downloadQueuedFile(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;
@@ -321,112 +306,121 @@ exports.downloadQueuedFile = async(download_uid, customDownloadHandler = null) =
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
let {child_process, callback} = await youtubedl_api.runYoutubeDL(url, args, customDownloadHandler); youtubedl.exec(url, args, {maxBuffer: Infinity}, async function(err, output) {
if (child_process) download_to_child_process[download['uid']] = child_process; const file_objs = [];
const {parsed_output, err} = await callback; let end_time = Date.now();
clearInterval(download_checker); let difference = (end_time - start_time)/1000;
let end_time = Date.now(); logger.debug(`${type === 'audio' ? 'Audio' : 'Video'} download delay: ${difference} seconds.`);
let difference = (end_time - start_time)/1000; clearInterval(download_checker);
logger.debug(`${type === 'audio' ? 'Audio' : 'Video'} download delay: ${difference} seconds.`); if (err) {
if (!parsed_output) { logger.error(err.stderr);
const errored_download = await db_api.getRecord('download_queue', {uid: download_uid}); await handleDownloadError(download, err.stderr, 'unknown_error');
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) {
for (const output_json of parsed_output) { // ERROR!
if (!output_json) { const error_message = `No output received for video download, check if it exists in your archive.`;
continue; await handleDownloadError(download, error_message, 'no_output');
logger.warn(error_message);
resolve(false);
return;
} }
// get filepath with no extension for (let i = 0; i < output.length; i++) {
const filepath_no_extension = utils.removeFileExtension(output_json['_filename']); let output_json = null;
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 { try {
fs.renameSync(output_json['_filename'] + '.webm', output_json['_filename']); // we have to do this because sometimes there will be leading characters before the actual json
logger.info('Renamed ' + file_name + '.webm to ' + file_name); 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) { } catch(e) {
logger.error(`Failed to rename file ${output_json['_filename']} to its appropriate extension.`); output_json = null;
} }
}
if (type === 'audio') { if (!output_json) {
let tags = { continue;
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']); // 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);
} }
if (config_api.getConfigItem('ytdl_generate_nfo_files')) { let container = null;
exports.generateNFOFile(output_json, `${filepath_no_extension}.nfo`);
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');
} }
if (options.cropFileSettings) { const file_uids = file_objs.map(file_obj => file_obj.uid);
await utils.cropFile(full_file_path, options.cropFileSettings.cropFileStart, options.cropFileSettings.cropFileEnd, ext); 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();
// 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 = config_api.getConfigItem('ytdl_default_downloader'); const default_downloader = utils.getCurrentDownloader() || 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.')
@@ -557,30 +551,58 @@ 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) => {
// remove bad args return new Promise(resolve => {
const temp_args = utils.filterArgs(args, ['--no-simulate']); // remove bad args
const new_args = [...temp_args]; const temp_args = utils.filterArgs(args, ['--no-simulate']);
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;
}
return parsed_output; new_args.push('--dump-json');
youtubedl.exec(url, new_args, {maxBuffer: Infinity}, async (err, output) => {
if (output) {
let outputs = [];
try {
for (let i = 0; i < output.length; i++) {
let output_json = null;
try {
output_json = JSON.parse(output[i]);
} catch(e) {
output_json = null;
}
if (!output_json) {
continue;
}
outputs.push(output_json);
}
resolve(outputs.length === 1 ? outputs[0] : outputs);
} catch(e) {
const error = `Error while retrieving info on video with URL ${url} with the following message: output JSON could not be parsed. Output JSON: ${output}`;
logger.error(error);
if (download_uid) {
const download = await db_api.getRecord('download_queue', {uid: download_uid});
await handleDownloadError(download, error, 'parse_failed');
}
resolve(null);
}
} 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) {
@@ -599,7 +621,6 @@ 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'];

View File

@@ -1,7 +1,17 @@
#!/bin/bash #!/bin/sh
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
echo "[entrypoint] setup permission, this may take a while" if [ "$*" = "$CMD" ] && [ "$(id -u)" = "0" ]; then
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." find . \! -user "$UID" -exec chown "$UID:$GID" -R '{}' + || echo "WARNING! Could not change directory ownership. If you manage permissions externally this is fine, otherwise you may experience issues when downloading or deleting videos."
exec gosu "$UID:$GID" "$@" exec gosu "$UID:$GID" "$0" "$@"
fi
exec "$@"

View File

@@ -1,6 +1,6 @@
const fs = require('fs-extra') const fs = require('fs-extra')
const path = require('path') const path = require('path')
const { v4: uuid } = require('uuid'); const { uuid } = require('uuidv4');
const config_api = require('./config'); const config_api = require('./config');
const db_api = require('./db'); const db_api = require('./db');

View File

@@ -4,12 +4,11 @@ const logger = require('./logger');
const utils = require('./utils'); const utils = require('./utils');
const consts = require('./consts'); const consts = require('./consts');
const { v4: uuid } = require('uuid'); const { uuid } = require('uuidv4');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const { gotify } = require("gotify"); const { gotify } = require("gotify");
const TelegramBotAPI = require('node-telegram-bot-api'); const TelegramBot = require('node-telegram-bot-api');
let telegram_bot = null;
const REST = require('@discordjs/rest').REST; const REST = require('@discordjs/rest').REST;
const API = require('@discordjs/core').API; const API = require('@discordjs/core').API;
const EmbedBuilder = require('@discordjs/builders').EmbedBuilder; const EmbedBuilder = require('@discordjs/builders').EmbedBuilder;
@@ -57,7 +56,7 @@ exports.sendNotification = async (notification) => {
sendGotifyNotification(data); sendGotifyNotification(data);
} }
if (config_api.getConfigItem('ytdl_use_telegram_API') && config_api.getConfigItem('ytdl_telegram_bot_token') && config_api.getConfigItem('ytdl_telegram_chat_id')) { if (config_api.getConfigItem('ytdl_use_telegram_API') && config_api.getConfigItem('ytdl_telegram_bot_token') && config_api.getConfigItem('ytdl_telegram_chat_id')) {
exports.sendTelegramNotification(data); sendTelegramNotification(data);
} }
if (config_api.getConfigItem('ytdl_webhook_url')) { if (config_api.getConfigItem('ytdl_webhook_url')) {
sendGenericNotification(data); sendGenericNotification(data);
@@ -114,8 +113,6 @@ function notificationEnabled(type) {
return config_api.getConfigItem('ytdl_enable_notifications') && (config_api.getConfigItem('ytdl_enable_all_notifications') || config_api.getConfigItem('ytdl_allowed_notification_types').includes(type)); return config_api.getConfigItem('ytdl_enable_notifications') && (config_api.getConfigItem('ytdl_enable_all_notifications') || config_api.getConfigItem('ytdl_allowed_notification_types').includes(type));
} }
// ntfy
function sendNtfyNotification({body, title, type, url, thumbnail}) { function sendNtfyNotification({body, title, type, url, thumbnail}) {
logger.verbose('Sending notification to ntfy'); logger.verbose('Sending notification to ntfy');
fetch(config_api.getConfigItem('ytdl_ntfy_topic_url'), { fetch(config_api.getConfigItem('ytdl_ntfy_topic_url'), {
@@ -130,8 +127,6 @@ function sendNtfyNotification({body, title, type, url, thumbnail}) {
}); });
} }
// Gotify
async function sendGotifyNotification({body, title, type, url, thumbnail}) { async function sendGotifyNotification({body, title, type, url, thumbnail}) {
logger.verbose('Sending notification to gotify'); logger.verbose('Sending notification to gotify');
await gotify({ await gotify({
@@ -150,50 +145,15 @@ async function sendGotifyNotification({body, title, type, url, thumbnail}) {
}); });
} }
// Telegram async function sendTelegramNotification({body, title, type, url, thumbnail}) {
setupTelegramBot();
config_api.config_updated.subscribe(change => {
const use_telegram_api = config_api.getConfigItem('ytdl_use_telegram_API');
const bot_token = config_api.getConfigItem('ytdl_telegram_bot_token');
if (!use_telegram_api || !bot_token) return;
if (!change) return;
if (change['key'] === 'ytdl_use_telegram_API' || change['key'] === 'ytdl_telegram_bot_token' || change['key'] === 'ytdl_telegram_webhook_proxy') {
logger.debug('Telegram bot setting up');
setupTelegramBot();
}
});
async function setupTelegramBot() {
const use_telegram_api = config_api.getConfigItem('ytdl_use_telegram_API');
const bot_token = config_api.getConfigItem('ytdl_telegram_bot_token');
if (!use_telegram_api || !bot_token) return;
telegram_bot = new TelegramBotAPI(bot_token);
const webhook_proxy = config_api.getConfigItem('ytdl_telegram_webhook_proxy');
const webhook_url = webhook_proxy ? webhook_proxy : `${utils.getBaseURL()}/api/telegramRequest`;
telegram_bot.setWebHook(webhook_url);
}
exports.sendTelegramNotification = async ({body, title, type, url, thumbnail}) => {
if (!telegram_bot){
logger.error('Telegram bot not found!');
return;
}
const chat_id = config_api.getConfigItem('ytdl_telegram_chat_id');
if (!chat_id){
logger.error('Telegram chat ID required!');
return;
}
logger.verbose('Sending notification to Telegram'); logger.verbose('Sending notification to Telegram');
if (thumbnail) await telegram_bot.sendPhoto(chat_id, thumbnail); const bot_token = config_api.getConfigItem('ytdl_telegram_bot_token');
telegram_bot.sendMessage(chat_id, `<b>${title}</b>\n\n${body}\n<a href="${url}">${url}</a>`, {parse_mode: 'HTML'}); const chat_id = config_api.getConfigItem('ytdl_telegram_chat_id');
const bot = new TelegramBot(bot_token);
if (thumbnail) await bot.sendPhoto(chat_id, thumbnail);
bot.sendMessage(chat_id, `<b>${title}</b>\n\n${body}\n<a href="${url}">${url}</a>`, {parse_mode: 'HTML'});
} }
// Discord
async function sendDiscordNotification({body, title, type, url, thumbnail}) { async function sendDiscordNotification({body, title, type, url, thumbnail}) {
const discord_webhook_url = config_api.getConfigItem('ytdl_discord_webhook_url'); const discord_webhook_url = config_api.getConfigItem('ytdl_discord_webhook_url');
const url_split = discord_webhook_url.split('webhooks/'); const url_split = discord_webhook_url.split('webhooks/');
@@ -217,8 +177,6 @@ async function sendDiscordNotification({body, title, type, url, thumbnail}) {
return result; return result;
} }
// Slack
function sendSlackNotification({body, title, type, url, thumbnail}) { function sendSlackNotification({body, title, type, url, thumbnail}) {
const slack_webhook_url = config_api.getConfigItem('ytdl_slack_webhook_url'); const slack_webhook_url = config_api.getConfigItem('ytdl_slack_webhook_url');
logger.verbose(`Sending slack notification to ${slack_webhook_url}`); logger.verbose(`Sending slack notification to ${slack_webhook_url}`);
@@ -278,8 +236,6 @@ function sendSlackNotification({body, title, type, url, thumbnail}) {
}); });
} }
// Generic
function sendGenericNotification(data) { function sendGenericNotification(data) {
const webhook_url = config_api.getConfigItem('ytdl_webhook_url'); const webhook_url = config_api.getConfigItem('ytdl_webhook_url');
logger.verbose(`Sending generic notification to ${webhook_url}`); logger.verbose(`Sending generic notification to ${webhook_url}`);

4229
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"description": "backend for YoutubeDL-Material", "description": "backend for YoutubeDL-Material",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "mocha test --exit -s 1000", "test": "echo \"Error: no test specified\" && exit 1",
"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,7 +33,6 @@
"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",
@@ -62,10 +61,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",
"uuid": "^9.0.1", "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"
} }
} }

View File

@@ -1,7 +1,7 @@
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');
@@ -33,13 +33,13 @@ exports.subscribe = async (sub, user_uid = null, skip_get_info = false) => {
} }
sub['user_uid'] = user_uid ? user_uid : undefined; sub['user_uid'] = user_uid ? user_uid : undefined;
await db_api.insertRecordIntoTable('subscriptions', JSON.parse(JSON.stringify(sub))); await db_api.insertRecordIntoTable('subscriptions', sub);
let success = skip_get_info ? true : await getSubscriptionInfo(sub); let success = skip_get_info ? true : await getSubscriptionInfo(sub);
exports.writeSubscriptionMetadata(sub); exports.writeSubscriptionMetadata(sub);
if (success) { if (success) {
if (!sub.paused) exports.getVideosForSub(sub.id); if (!sub.paused) exports.getVideosForSub(sub, user_uid);
} else { } else {
logger.error('Subscribe: Failed to get subscription info. Subscribe failed.') logger.error('Subscribe: Failed to get subscription info. Subscribe failed.')
} }
@@ -63,41 +63,55 @@ async function getSubscriptionInfo(sub) {
} }
} }
let {callback} = await youtubedl_api.runYoutubeDL(sub.url, downloadConfig); return new Promise(async resolve => {
const {parsed_output, err} = await callback; youtubedl.exec(sub.url, downloadConfig, {maxBuffer: Infinity}, async (err, output) => {
if (err) { if (debugMode) {
logger.error(err.stderr); logger.info('Subscribe: got info for subscription ' + sub.id);
return false;
}
logger.verbose('Subscribe: got info for subscription ' + sub.id);
for (const output_json of parsed_output) {
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 (err) {
if (sub.name) { logger.error(err.stderr);
let sub_name = sub.name; resolve(false);
const sub_name_exists = await db_api.getRecord('subscriptions', {name: sub.name, isPlaylist: sub.isPlaylist, user_uid: sub.user_uid}); } else if (output) {
if (sub_name_exists) sub_name += ` - ${sub.id}`; if (output.length === 0 || (output.length === 1 && output[0] === '')) {
await db_api.updateRecord('subscriptions', {id: sub.id}, {name: sub_name}); 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
resolve(true);
}
resolve(false);
} }
} });
});
return true;
}
return false;
} }
exports.unsubscribe = async (sub_id, deleteMode, user_uid = null) => { exports.unsubscribe = async (sub, 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');
@@ -120,7 +134,6 @@ exports.unsubscribe = async (sub_id, 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});
@@ -205,76 +218,12 @@ exports.deleteSubscriptionFile = async (sub, file, deleteForever, file_uid = nul
} }
} }
let current_sub_index = 0; // To keep track of the current subscription exports.getVideosForSub = async (sub, user_uid = null) => {
exports.watchSubscriptionsInterval = async () => { const latest_sub_obj = await exports.getSubscription(sub.id);
const subscriptions_check_interval = config_api.getConfigItem('ytdl_subscriptions_check_interval'); if (!latest_sub_obj || latest_sub_obj['downloading']) {
let parent_interval = setInterval(() => watchSubscriptions(), subscriptions_check_interval*1000);
watchSubscriptions();
config_api.config_updated.subscribe(change => {
if (!change) return;
if (change['key'] === 'ytdl_subscriptions_check_interval' || change['key'] === 'ytdl_multi_user_mode') {
current_sub_index = 0; // TODO: start after the last sub check
logger.verbose('Resetting sub check schedule due to config change');
clearInterval(parent_interval);
const new_interval = config_api.getConfigItem('ytdl_subscriptions_check_interval');
parent_interval = setInterval(() => watchSubscriptions(), new_interval*1000);
watchSubscriptions();
}
});
}
async function watchSubscriptions() {
const subscription_ids = await getValidSubscriptionsToCheck();
if (subscription_ids.length === 0) {
logger.info('Skipping subscription check as no valid subscriptions exist.');
return;
}
checkSubscription(subscription_ids[current_sub_index]);
current_sub_index = (current_sub_index + 1) % subscription_ids.length;
}
async function checkSubscription(sub_id) {
let sub = await exports.getSubscription(sub_id);
// don't check the sub if the last check for the same subscription has not completed
if (sub.downloading) {
logger.verbose(`Subscription: skipped checking ${sub.name} as it's downloading videos.`);
return;
}
if (!sub.name) {
logger.verbose(`Subscription: skipped check for subscription with uid ${sub.id} as name has not been retrieved yet.`);
return;
}
await exports.getVideosForSub(sub.id);
}
async function getValidSubscriptionsToCheck() {
const subscriptions = await exports.getAllSubscriptions();
if (!subscriptions) return;
// auto pause deprecated streamingOnly mode
const streaming_only_subs = subscriptions.filter(sub => sub.streamingOnly);
exports.updateSubscriptionPropertyMultiple(streaming_only_subs, {paused: true});
const valid_subscription_ids = subscriptions.filter(sub => !sub.paused && !sub.streamingOnly).map(sub => sub.id);
return valid_subscription_ids;
}
exports.getVideosForSub = async (sub_id) => {
const sub = await exports.getSubscription(sub_id);
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
@@ -292,46 +241,78 @@ async function _getVideosForSub(sub) {
// 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(',')}`);
let {child_process, callback} = await youtubedl_api.runYoutubeDL(sub.url, downloadConfig); return new Promise(async resolve => {
updateSubscriptionProperty(sub, {child_process: child_process}, user_uid); youtubedl.exec(sub.url, downloadConfig, {maxBuffer: Infinity}, async function(err, output) {
const {parsed_output, err} = await callback; // cleanup
updateSubscriptionProperty(sub, {downloading: false, child_process: null}, user_uid); updateSubscriptionProperty(sub, {downloading: false}, 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);
const files_to_download = await handleOutputJSON(parsed_output, sub, user_uid); if (err && !output) {
return files_to_download; logger.error(err.stderr ? err.stderr : err.message);
if (err.stderr.includes('This video is unavailable') || err.stderr.includes('Private video')) {
logger.info('An error was encountered with at least one video, backup method will be used.')
try {
const outputs = err.stdout.split(/\r\n|\r|\n/);
const files_to_download = await handleOutputJSON(outputs, sub, user_uid);
resolve(files_to_download);
} catch(e) {
logger.error('Backup method failed. See error below:');
logger.error(e);
}
} else {
logger.error('Subscription check failed!');
}
resolve(false);
} else if (output) {
const files_to_download = await handleOutputJSON(output, sub, user_uid);
resolve(files_to_download);
}
});
}, err => {
logger.error(err);
updateSubscriptionProperty(sub, {downloading: false}, user_uid);
});
} }
async function handleOutputJSON(output_jsons, sub, user_uid) { async function handleOutputJSON(output, 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_jsons.length === 0 || (output_jsons.length === 1 && output_jsons[0] === '')) { if (output.length === 0 || (output.length === 1 && output[0] === '')) {
logger.verbose('No additional videos to download for ' + sub.name); logger.verbose('No additional videos to download for ' + sub.name);
return []; return [];
} }
const output_jsons = [];
for (let i = 0; i < output.length; i++) {
let output_json = null;
try {
output_json = JSON.parse(output[i]);
output_jsons.push(output_json);
} catch(e) {
output_json = null;
}
if (!output_json) {
continue;
}
}
const files_to_download = await getFilesToDownload(sub, output_jsons); const files_to_download = await getFilesToDownload(sub, output_jsons);
const base_download_options = exports.generateOptionsForSubscriptionDownload(sub, user_uid); const base_download_options = exports.generateOptionsForSubscriptionDownload(sub, user_uid);
for (let j = 0; j < files_to_download.length; j++) { for (let j = 0; j < files_to_download.length; j++) {
const file_to_download = files_to_download[j]; const file_to_download = files_to_download[j];
file_to_download['formats'] = utils.stripPropertiesFromObject(file_to_download['formats'], ['format_id', 'filesize', 'filesize_approx']); // prevent download object from blowing up in size file_to_download['formats'] = utils.stripPropertiesFromObject(file_to_download['formats'], ['format_id', 'filesize', 'filesize_approx']); // prevent download object from blowing up in size
await downloader_api.createDownload(file_to_download['webpage_url'], sub.type || 'video', base_download_options, user_uid, sub.id, sub.name, [file_to_download]); await downloader_api.createDownload(file_to_download['webpage_url'], sub.type || 'video', base_download_options, user_uid, sub.id, sub.name, file_to_download);
} }
return files_to_download; return files_to_download;
@@ -392,13 +373,10 @@ 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);
const archive_count = archive_text.split('\n').length - 1; logger.verbose(`Generating temporary archive file for subscription ${sub.name} with ${archive_text.split('\n').length - 1} entries.`)
if (archive_count > 0) { const archive_path = path.join(appendedBasePath, 'archive.txt');
logger.verbose(`Generating temporary archive file for subscription ${sub.name} with ${archive_count} entries.`) await fs.writeFile(archive_path, archive_text);
const archive_path = path.join(appendedBasePath, 'archive.txt'); downloadConfig.push('--download-archive', archive_path);
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(',,');
@@ -432,7 +410,7 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
downloadConfig.push('-r', rate_limit); downloadConfig.push('-r', rate_limit);
} }
const default_downloader = config_api.getConfigItem('ytdl_default_downloader'); const default_downloader = utils.getCurrentDownloader() || 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');
} }
@@ -463,37 +441,8 @@ 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;
}
// 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) => { 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});
} }
@@ -539,8 +488,6 @@ exports.writeSubscriptionMetadata = (sub) => {
: config_api.getConfigItem('ytdl_subscriptions_base_path'); : config_api.getConfigItem('ytdl_subscriptions_base_path');
const appendedBasePath = getAppendedBasePath(sub, basePath); const appendedBasePath = getAppendedBasePath(sub, basePath);
const metadata_path = path.join(appendedBasePath, CONSTS.SUBSCRIPTION_BACKUP_PATH); const metadata_path = path.join(appendedBasePath, CONSTS.SUBSCRIPTION_BACKUP_PATH);
fs.ensureDirSync(appendedBasePath);
fs.writeJSONSync(metadata_path, sub); fs.writeJSONSync(metadata_path, sub);
} }
@@ -572,22 +519,24 @@ 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) => {
const info = await downloader_api.getVideoInfoByURL(file_obj['url'], downloadConfig); if (err) {
if (info && info.length === 1) { // video is not available anymore for whatever reason
const metric_to_compare = sub.type === 'audio' ? 'abr' : 'height'; } else if (output) {
if (info[metric_to_compare] > file_obj[metric_to_compare]) { const metric_to_compare = sub.type === 'audio' ? 'abr' : 'height';
// download new video as the simulated one is better if (output[metric_to_compare] > file_obj[metric_to_compare]) {
let {callback} = await youtubedl_api.runYoutubeDL(sub.url, downloadConfig); // download new video as the simulated one is better
const {parsed_output, err} = await callback; youtubedl.exec(file_obj['url'], downloadConfig, {maxBuffer: Infinity}, async (err, output) => {
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 (parsed_output) { } else if (output) {
logger.verbose(`Successfully upgraded video ${file_obj['id']}'s ${metric_to_compare} from ${file_obj[metric_to_compare]} to ${info[metric_to_compare]}`); logger.verbose(`Successfully upgraded video ${file_obj['id']}'s ${metric_to_compare} from ${file_obj[metric_to_compare]} to ${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'], {[metric_to_compare]: output[metric_to_compare]});
} }
} });
} }
}
});
await db_api.setVideoProperty(file_obj['uid'], {'fresh_upload': false}); await db_api.setVideoProperty(file_obj['uid'], {'fresh_upload': false});
} }

View File

@@ -13,6 +13,7 @@ const CONSTS = require('./consts');
const fs = require('fs-extra'); const fs = require('fs-extra');
const path = require('path'); 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: {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,11 +3,7 @@ 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 { v4: uuid } = require('uuid');
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');
@@ -45,11 +41,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 youtubedl_api = require('../youtube-dl'); const fs = require('fs-extra');
const config_api = require('../config'); const { uuid } = require('uuidv4');
const CONSTS = require('../consts'); const NodeID3 = require('node-id3');
db_api.initialize(db, users_db, 'local_db_test.json'); db_api.initialize(db, users_db);
const sample_video_json = { const sample_video_json = {
id: "Sample Video", id: "Sample Video",
@@ -72,9 +68,9 @@ const sample_video_json = {
} }
describe('Database', async function() { describe('Database', async function() {
describe.skip('Import', async function() { describe('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);
@@ -90,7 +86,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'});
@@ -118,8 +114,7 @@ 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;
const describe_skippable = use_local_db ? describe : describe.skip; describe(`Use local DB - ${use_local_db}`, async function() {
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);
@@ -172,7 +167,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');
assert(duplicates && duplicates.length === 2 && duplicates[0]['key'] === '2' && duplicates[1]['key'] === '4') console.log(duplicates);
}); });
it('Update record', async function() { it('Update record', async function() {
@@ -284,7 +279,7 @@ describe('Database', async function() {
assert(stats); assert(stats);
}); });
it.skip('Query speed', async function() { it('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 = [];
@@ -342,13 +337,12 @@ describe('Database', async function() {
}); });
describe('Multi User', async function() { describe('Multi User', async function() {
this.timeout(120000);
const user_to_test = 'test_user'; const user_to_test = 'test_user';
const user_password = 'test_pass'; const user_password = 'test_pass';
const sub_to_test = ''; const sub_to_test = '';
const playlist_to_test = ''; const playlist_to_test = '';
beforeEach(async function() { beforeEach(async function() {
// await db_api.connectToDB(); await db_api.connectToDB();
await auth_api.deleteUser(user_to_test); await auth_api.deleteUser(user_to_test);
}); });
describe('Basic', function() { describe('Basic', function() {
@@ -375,17 +369,17 @@ describe('Multi User', async function() {
it('Video access - disallowed', async function() { it('Video access - disallowed', async function() {
await db_api.setVideoProperty(video_to_test, {sharingEnabled: false}); await db_api.setVideoProperty(video_to_test, {sharingEnabled: false});
const video_obj = await auth_api.getUserVideo(user_to_test, video_to_test, true); const video_obj = 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 = await auth_api.getUserVideo(user_to_test, video_to_test, true); const video_obj = auth_api.getUserVideo(user_to_test, video_to_test, true);
assert(video_obj); assert(video_obj);
}); });
}); });
describe.skip('Zip generators', function() { describe('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);
@@ -402,7 +396,7 @@ describe('Multi User', async function() {
}); });
it('Subscription zip generator', async function() { it('Subscription zip generator', async function() {
const sub = await subscriptions_api.getSubscription(sub_to_test.id, user_to_test); const sub = await subscriptions_api.getSubscription(sub_to_test, user_to_test);
const sub_videos = await db_api.getRecords('files', {sub_id: sub.id}); const sub_videos = await db_api.getRecords('files', {sub_id: sub.id});
assert(sub); assert(sub);
const sub_files_to_download = []; const sub_files_to_download = [];
@@ -441,100 +435,35 @@ 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=hpigjnKl7nI'; const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
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 && info.length > 0); assert(!!info);
}); });
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);
assert(returned_download); console.log(returned_download);
const custom_download_method = async (url, args, options, callback) => { await utils.wait(20000);
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 success = await generateEmptyAudioFile('test/sample_mp3.mp3'); const audio_path = './test/sample.mp3';
const audio_path = './test/sample_mp3.mp3'; const sample_json = fs.readJSONSync('./test/sample.info.json');
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'],
@@ -542,13 +471,14 @@ 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(success && written_tags['raw']['TRCK'] === '27'); assert(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, null, null, null, null, true); const returned_download = await downloader_api.createDownload(url, 'video', options);
assert(returned_download); console.log(returned_download);
await utils.wait(20000);
}); });
it('Pause file', async function() { it('Pause file', async function() {
@@ -563,7 +493,7 @@ describe('Downloader', function() {
assert(args.length > 0); assert(args.length > 0);
}); });
it.skip('Generate args - subscription', async function() { it('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);
@@ -576,7 +506,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_mp4.info.json'); const sample_json = fs.readJSONSync('./test/sample.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);
@@ -603,19 +533,11 @@ describe('Downloader', function() {
}); });
describe('Twitch', async function () { describe('Twitch', async function () {
const twitch_api = require('../twitch'); const twitch_api = require('../twitch');
const example_vod = '1790315420'; const example_vod = '1710641401';
it('Download VOD chat', async function() { it('Download VOD', 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));
@@ -625,109 +547,10 @@ 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 = {
@@ -746,7 +569,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() {
@@ -756,7 +579,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); assert(!missing_file_db_record, true);
}); });
it('Check for duplicate files', async function() { it('Check for duplicate files', async function() {
@@ -776,29 +599,27 @@ 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); assert(duplicated_record_count == 1, true);
}); });
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', {path: 'test/missing_file.mp4'}); await db_api.removeAllRecords('files', {title: 'Sample File'});
if (fs.existsSync('video/sample_mp4.info.json')) fs.unlinkSync('video/sample_mp4.info.json'); if (fs.existsSync('video/sample.info.json')) fs.unlinkSync('video/sample.info.json');
if (fs.existsSync('video/sample_mp4.mp4')) fs.unlinkSync('video/sample_mp4.mp4'); if (fs.existsSync('video/sample.mp4')) fs.unlinkSync('video/sample.mp4');
// copies in files // copies in files
fs.copyFileSync('test/sample_mp4.info.json', 'video/sample_mp4.info.json'); fs.copyFileSync('test/sample.info.json', 'video/sample.info.json');
fs.copyFileSync('test/sample_mp4.mp4', 'video/sample_mp4.mp4'); fs.copyFileSync('test/sample.mp4', 'video/sample.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(success && !!imported_file); assert(!!imported_file === true);
// post-test cleanup // post-test cleanup
if (fs.existsSync('video/sample_mp4.info.json')) fs.unlinkSync('video/sample_mp4.info.json'); if (fs.existsSync('video/sample.info.json')) fs.unlinkSync('video/sample.info.json');
if (fs.existsSync('video/sample_mp4.mp4')) fs.unlinkSync('video/sample_mp4.mp4'); if (fs.existsSync('video/sample.mp4')) fs.unlinkSync('video/sample.mp4');
}); });
it('Schedule and cancel task', async function() { it('Schedule and cancel task', async function() {
@@ -838,12 +659,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'); await db_api.removeAllRecords('archives', {user_uid: 'test_user'});
}); });
afterEach(async function() { afterEach(async function() {
await db_api.removeAllRecords('archives'); await db_api.removeAllRecords('archives', {user_uid: 'test_user'});
}); });
it('Import archive', async function() { it('Import archive', async function() {
@@ -857,6 +678,7 @@ 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);
@@ -887,9 +709,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_title', 'test_user'); await archive_api.addToArchive('testextractor1', 'testing1', 'video', 'test_user');
await archive_api.addToArchive('testextractor2', 'testing1', 'video', 'test_title', 'test_user'); await archive_api.addToArchive('testextractor2', 'testing1', 'video', 'test_user');
await archive_api.addToArchive('testextractor2', 'testing2', 'video', 'test_title', 'test_user'); await archive_api.addToArchive('testextractor2', 'testing2', 'video', '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);
@@ -935,14 +757,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);
}); });
@@ -983,6 +805,7 @@ 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');
}); });
@@ -1035,74 +858,4 @@ 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);
}); });
}); });
describe('Config', async function() {
it('findChangedConfigItems', async function() {
const old_config = {
"YoutubeDLMaterial": {
"test_object1": {
"test_prop1": true,
"test_prop2": false
},
"test_object2": {
"test_prop3": {
"test_prop3_1": true,
"test_prop3_2": false
},
"test_prop4": false
},
"test_object3": {
"test_prop5": {
"test_prop5_1": true,
"test_prop5_2": false
},
"test_prop6": false
}
}
};
const new_config = {
"YoutubeDLMaterial": {
"test_object1": {
"test_prop1": false,
"test_prop2": false
},
"test_object2": {
"test_prop3": {
"test_prop3_1": false,
"test_prop3_2": false
},
"test_prop4": true
},
"test_object3": {
"test_prop5": {
"test_prop5_1": true,
"test_prop5_2": false
},
"test_prop6": true
}
}
};
const changes = config_api.findChangedConfigItems(old_config, new_config);
assert(changes[0]['key'] === 'test_prop1' && changes[0]['old_value'] === true && changes[0]['new_value'] === false);
assert(changes[1]['key'] === 'test_prop3' &&
changes[1]['old_value']['test_prop3_1'] === true &&
changes[1]['new_value']['test_prop3_1'] === false &&
changes[1]['old_value']['test_prop3_2'] === false &&
changes[1]['new_value']['test_prop3_2'] === false);
assert(changes[2]['key'] === 'test_prop4' && changes[2]['old_value'] === false && changes[2]['new_value'] === true);
assert(changes[3]['key'] === 'test_prop6' && changes[3]['old_value'] === false && changes[3]['new_value'] === true);
});
});
const generateEmptyVideoFile = async (file_path) => {
if (fs.existsSync(file_path)) fs.unlinkSync(file_path);
return await exec(`ffmpeg -t 1 -f lavfi -i color=c=black:s=640x480 -c:v libx264 -tune stillimage -pix_fmt yuv420p "${file_path}"`);
}
const generateEmptyAudioFile = async (file_path) => {
if (fs.existsSync(file_path)) fs.unlinkSync(file_path);
return await exec(`ffmpeg -f lavfi -i anullsrc=r=44100:cl=mono -t 1 -q:a 9 -acodec libmp3lame ${file_path}`);
}

View File

@@ -13,7 +13,7 @@ const CONSTS = require('./consts');
const is_windows = process.platform === 'win32'; const is_windows = process.platform === 'win32';
// replaces .webm with appropriate extension // replaces .webm with appropriate extension
exports.getTrueFileName = (unfixed_path, type, force_ext = null) => { exports.getTrueFileName = (unfixed_path, type) => {
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, force_ext = null) => {
if (old_ext !== new_ext) { if (old_ext !== new_ext) {
unfixed_parts[unfixed_parts.length-1] = force_ext || new_ext; unfixed_parts[unfixed_parts.length-1] = new_ext;
fixed_path = unfixed_parts.join('.'); fixed_path = unfixed_parts.join('.');
} }
return fixed_path; return fixed_path;
@@ -241,6 +241,11 @@ 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 || []
@@ -342,7 +347,7 @@ exports.checkExistsWithTimeout = async (filePath, timeout) => {
if (!err) { if (!err) {
clearTimeout(timer); clearTimeout(timer);
if (watcher) watcher.close(); if (watcher) watcher.close();
resolve(true); resolve();
} }
}); });
@@ -352,7 +357,7 @@ exports.checkExistsWithTimeout = async (filePath, timeout) => {
if (eventType === 'rename' && filename === basename) { if (eventType === 'rename' && filename === basename) {
clearTimeout(timer); clearTimeout(timer);
if (watcher) watcher.close(); if (watcher) watcher.close();
resolve(true); resolve();
} }
}); });
}); });
@@ -525,40 +530,6 @@ exports.getDirectoriesInDirectory = async (basePath) => {
} }
} }
exports.parseOutputJSON = (output, err) => {
let split_output = [];
// const output_jsons = [];
if (err && !output) {
if (!err.stderr.includes('This video is unavailable') && !err.stderr.includes('Private video')) {
return null;
}
logger.info('An error was encountered with at least one video, backup method will be used.')
try {
split_output = err.stdout.split(/\r\n|\r|\n/);
} catch (e) {
logger.error('Backup method failed. See error below:');
logger.error(e);
return null;
}
} else if (output.length === 0 || (output.length === 1 && output[0].length === 0)) {
// output is '' or ['']
return [];
} else {
for (const output_item of output) {
// we have to do this because sometimes there will be leading characters before the actual json
const start_idx = output_item.indexOf('{"');
const clean_output = output_item.slice(start_idx, output_item.length);
split_output.push(clean_output);
}
}
try {
return split_output.map(split_output_str => JSON.parse(split_output_str));
} catch(e) {
return null;
}
}
// objects // objects
function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date, description, view_count, height, abr) { function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date, description, view_count, height, abr) {

View File

@@ -1,159 +1,141 @@
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';
exports.youtubedl_forks = { const download_sources = {
'youtube-dl': { 'youtube-dl': {
'download_url': 'https://github.com/ytdl-org/youtube-dl/releases/latest/download/youtube-dl', 'tags_url': 'https://api.github.com/repos/ytdl-org/youtube-dl/tags',
'tags_url': 'https://api.github.com/repos/ytdl-org/youtube-dl/tags' 'func': downloadLatestYoutubeDLBinary
}, },
'youtube-dlc': { 'youtube-dlc': {
'download_url': 'https://github.com/blackjack4494/yt-dlc/releases/latest/download/youtube-dlc', 'tags_url': 'https://api.github.com/repos/blackjack4494/yt-dlc/tags',
'tags_url': 'https://api.github.com/repos/blackjack4494/yt-dlc/tags' 'func': downloadLatestYoutubeDLCBinary
}, },
'yt-dlp': { 'yt-dlp': {
'download_url': 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp', 'tags_url': 'https://api.github.com/repos/yt-dlp/yt-dlp/tags',
'tags_url': 'https://api.github.com/repos/yt-dlp/yt-dlp/tags' 'func': downloadLatestYoutubeDLPBinary
} }
} }
exports.runYoutubeDL = async (url, args, customDownloadHandler = null) => {
const output_file_path = getYoutubeDLPath();
if (!fs.existsSync(output_file_path)) await exports.checkForYoutubeDLUpdate();
let callback = null;
let child_process = null;
if (customDownloadHandler) {
callback = runYoutubeDLCustom(url, args, customDownloadHandler);
} else {
({callback, child_process} = await runYoutubeDLProcess(url, args));
}
return {child_process, callback};
}
// Run youtube-dl directly (not cancellable)
const runYoutubeDLCustom = async (url, args, customDownloadHandler) => {
const downloadHandler = customDownloadHandler;
return new Promise(resolve => {
downloadHandler(url, args, {maxBuffer: Infinity}, async function(err, output) {
const parsed_output = utils.parseOutputJSON(output, err);
resolve({parsed_output, err});
});
});
}
// Run youtube-dl in a subprocess (cancellable)
const runYoutubeDLProcess = async (url, args, youtubedl_fork = config_api.getConfigItem('ytdl_default_downloader')) => {
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}
}
function getYoutubeDLPath(youtubedl_fork = config_api.getConfigItem('ytdl_default_downloader')) {
const binary_file_name = youtubedl_fork + (is_windows ? '.exe' : '');
const binary_path = path.join('appdata', 'bin', binary_file_name);
return binary_path;
}
exports.killYoutubeDLProcess = async (child_process) => {
kill(child_process.pid, 'SIGKILL');
}
exports.checkForYoutubeDLUpdate = async () => { exports.checkForYoutubeDLUpdate = async () => {
const selected_fork = config_api.getConfigItem('ytdl_default_downloader'); return new Promise(async resolve => {
const output_file_path = getYoutubeDLPath(); const default_downloader = config_api.getConfigItem('ytdl_default_downloader');
// get current version const tags_url = download_sources[default_downloader]['tags_url'];
let current_app_details_exists = fs.existsSync(CONSTS.DETAILS_BIN_PATH); // get current version
if (!current_app_details_exists[selected_fork]) { let current_app_details_exists = fs.existsSync(CONSTS.DETAILS_BIN_PATH);
logger.warn(`Failed to get youtube-dl binary details at location '${CONSTS.DETAILS_BIN_PATH}'. Generating file...`); if (!current_app_details_exists) {
updateDetailsJSON(CONSTS.OUTDATED_YOUTUBEDL_VERSION, selected_fork, output_file_path); logger.warn(`Failed to get youtube-dl binary details at location '${CONSTS.DETAILS_BIN_PATH}'. Generating file...`);
} fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, {"version": OUTDATED_VERSION, "downloader": default_downloader});
const current_app_details = JSON.parse(fs.readFileSync(CONSTS.DETAILS_BIN_PATH)); }
const current_version = current_app_details[selected_fork]['version']; let current_app_details = JSON.parse(fs.readFileSync(CONSTS.DETAILS_BIN_PATH));
const current_fork = current_app_details[selected_fork]['downloader']; 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;
}
}
const latest_version = await exports.getLatestUpdateVersion(selected_fork); // got version, now let's check the latest version from the youtube-dl API
// 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);
}
}
exports.updateYoutubeDL = async (latest_update_version, custom_output_path = null) => {
await fs.ensureDir(path.join('appdata', 'bin'));
const default_downloader = config_api.getConfigItem('ytdl_default_downloader');
await downloadLatestYoutubeDLBinaryGeneric(default_downloader, latest_update_version, custom_output_path);
}
async function downloadLatestYoutubeDLBinaryGeneric(youtubedl_fork, new_version, custom_output_path = null) {
const file_ext = is_windows ? '.exe' : '';
// build the URL
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, `${youtubedl_fork} ${new_version}`);
fs.chmod(output_path, 0o777);
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'}) fetch(tags_url, {method: 'Get'})
.then(async res => res.json()) .then(async res => res.json())
.then(async (json) => { .then(async (json) => {
// check if the versions are different
if (!json || !json[0]) { if (!json || !json[0]) {
logger.error(`Failed to check ${youtubedl_fork} version for an update.`) logger.error(`Failed to check ${default_downloader} version for an update.`)
resolve(null); resolve(null);
return; return;
} }
const latest_update_version = json[0]['name']; const latest_update_version = json[0]['name'];
resolve(latest_update_version); 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 => { .catch(err => {
logger.error(`Failed to check ${youtubedl_fork} version for an update.`) logger.error(`Failed to check ${default_downloader} version for an update.`)
logger.error(err); logger.error(err);
resolve(null); resolve(null);
return;
}); });
}); });
} }
function updateDetailsJSON(new_version, fork, output_path) { exports.updateYoutubeDL = async (latest_update_version) => {
const default_downloader = config_api.getConfigItem('ytdl_default_downloader');
await download_sources[default_downloader]['func'](latest_update_version);
}
exports.verifyBinaryExistsLinux = () => {
const details_json = fs.readJSONSync(CONSTS.DETAILS_BIN_PATH);
if (!is_windows && details_json && (!details_json['path'] || details_json['path'].includes('.exe'))) {
details_json['path'] = 'node_modules/youtube-dl/bin/youtube-dl';
details_json['exec'] = 'youtube-dl';
details_json['version'] = OUTDATED_VERSION;
fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, details_json);
utils.restartServer();
}
}
async function downloadLatestYoutubeDLBinary(new_version) {
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) : {};
if (!details_json[fork]) details_json[fork] = {}; const download_url = `https://github.com/ytdl-org/youtube-dl/releases/latest/download/youtube-dl${file_ext}`;
const fork_json = details_json[fork]; const output_path = `node_modules/youtube-dl/bin/youtube-dl${file_ext}`;
fork_json['version'] = new_version;
fork_json['downloader'] = fork; await utils.fetchFile(download_url, output_path, `youtube-dl ${new_version}`);
fork_json['path'] = output_path; // unused
fork_json['exec'] = fork + file_ext; // unused updateDetailsJSON(new_version, 'youtube-dl');
}
async function downloadLatestYoutubeDLCBinary(new_version) {
const file_ext = is_windows ? '.exe' : '';
const download_url = `https://github.com/blackjack4494/yt-dlc/releases/latest/download/youtube-dlc${file_ext}`;
const output_path = `node_modules/youtube-dl/bin/youtube-dl${file_ext}`;
await utils.fetchFile(download_url, output_path, `youtube-dlc ${new_version}`);
updateDetailsJSON(new_version, 'youtube-dlc');
}
async function downloadLatestYoutubeDLPBinary(new_version) {
const file_ext = is_windows ? '.exe' : '';
const download_url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp${file_ext}`;
const output_path = `node_modules/youtube-dl/bin/youtube-dl${file_ext}`;
await utils.fetchFile(download_url, output_path, `yt-dlp ${new_version}`);
updateDetailsJSON(new_version, 'yt-dlp');
}
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);
} }

View File

@@ -1,6 +1,6 @@
apiVersion: v2 apiVersion: v2
name: youtubedl-material name: youtubedl-material
description: A Helm chart for https://github.com/Tzahi12345/YoutubeDL-Material description: A Helm chart for Kubernetes
# A chart can be either an 'application' or a 'library' chart. # A chart can be either an 'application' or a 'library' chart.
# #
@@ -15,10 +15,10 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes # This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version. # to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/) # Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.2.0 version: 0.1.0
# This is the version number of the application being deployed. This version number should be # This is the version number of the application being deployed. This version number should be
# 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.2" appVersion: "4.3.1"

View File

@@ -1,14 +1,7 @@
{{- if .Values.ingress.enabled -}} {{- if .Values.ingress.enabled -}}
{{- $fullName := include "youtubedl-material.fullname" . -}} {{- $fullName := include "youtubedl-material.fullname" . -}}
{{- $svcPort := .Values.service.port -}} {{- $svcPort := .Values.service.port -}}
{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} {{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
{{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
{{- end }}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1 apiVersion: networking.k8s.io/v1beta1
{{- else -}} {{- else -}}
apiVersion: extensions/v1beta1 apiVersion: extensions/v1beta1
@@ -23,9 +16,6 @@ metadata:
{{- toYaml . | nindent 4 }} {{- toYaml . | nindent 4 }}
{{- end }} {{- end }}
spec: spec:
{{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }} {{- if .Values.ingress.tls }}
tls: tls:
{{- range .Values.ingress.tls }} {{- range .Values.ingress.tls }}
@@ -43,19 +33,9 @@ spec:
paths: paths:
{{- range .paths }} {{- range .paths }}
- path: {{ .path }} - path: {{ .path }}
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
pathType: {{ .pathType }}
{{- end }}
backend: backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}
port:
number: {{ $svcPort }}
{{- else }}
serviceName: {{ $fullName }} serviceName: {{ $fullName }}
servicePort: {{ $svcPort }} servicePort: {{ $svcPort }}
{{- end }}
{{- end }} {{- end }}
{{- end }} {{- end }}
{{- end }} {{- end }}

15903
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,10 @@
{ {
"name": "youtube-dl-material", "name": "youtube-dl-material",
"version": "4.3.2", "version": "4.3.1",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",
"codespaces": "ng serve --configuration=codespaces",
"build": "ng build --configuration production", "build": "ng build --configuration production",
"prebuild": "node src/postbuild.mjs", "prebuild": "node src/postbuild.mjs",
"heroku-postbuild": "npm install --prefix backend", "heroku-postbuild": "npm install --prefix backend",
@@ -79,11 +78,5 @@
"protractor": "~7.0.0", "protractor": "~7.0.0",
"ts-node": "~3.0.4", "ts-node": "~3.0.4",
"tslint": "~6.1.0" "tslint": "~6.1.0"
}, }
"overrides": {
"ngx-avatars": {
"@angular/common": "15.0.1",
"@angular/core": "15.0.1"
}
}
} }

View File

@@ -14,7 +14,6 @@ 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';

View File

@@ -1,7 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type CheckSubscriptionRequest = {
sub_id: string;
};

View File

@@ -8,7 +8,6 @@ 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;

View File

@@ -11,12 +11,9 @@ 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;
downloading?: boolean;
paused?: boolean;
videos: Array<any>; videos: Array<any>;
}; };

View File

@@ -2,8 +2,10 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { SubscriptionRequestData } from './SubscriptionRequestData';
export type UnsubscribeRequest = { export type UnsubscribeRequest = {
sub_id: string; sub: SubscriptionRequestData;
/** /**
* Defaults to false * Defaults to false
*/ */

View File

@@ -17,11 +17,11 @@
</mat-menu> </mat-menu>
<button [matMenuTriggerFor]="menuSettings" mat-icon-button><mat-icon>more_vert</mat-icon></button> <button [matMenuTriggerFor]="menuSettings" mat-icon-button><mat-icon>more_vert</mat-icon></button>
<mat-menu #menuSettings="matMenu"> <mat-menu #menuSettings="matMenu">
<button class="top-menu-button" (click)="openProfileDialog()" mat-menu-item> <button class="top-menu-button" (click)="openProfileDialog()" *ngIf="postsService.isLoggedIn" 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 *ngIf="!postsService.config?.Advanced.multi_user_mode || postsService.isLoggedIn" class="top-menu-button" (click)="openArchivesDialog()" mat-menu-item> <button class="top-menu-button" (click)="openArchivesDialog()" mat-menu-item>
<mat-icon>topic</mat-icon> <mat-icon>topic</mat-icon>
<span i18n="Archives menu label">Archives</span> <span i18n="Archives menu label">Archives</span>
</button> </button>

View File

@@ -34,7 +34,6 @@ 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';
@@ -190,7 +189,6 @@ registerLocaleData(es, 'es');
DragDropModule, DragDropModule,
ClipboardModule, ClipboardModule,
TextFieldModule, TextFieldModule,
ScrollingModule,
NgxFileDropModule, NgxFileDropModule,
AvatarModule, AvatarModule,
ContentLoaderModule, ContentLoaderModule,

View File

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

View File

@@ -10,21 +10,13 @@ mat-header-cell, mat-cell {
.icon-button-spinner { .icon-button-spinner {
position: absolute; position: absolute;
top: -13px; top: 7px;
left: 10px; left: 6px;
}
.button-span {
position: relative;;
} }
.downloads-action-button-div { .downloads-action-button-div {
margin-left: 5px;
}
.downloads-action-button {
margin-top: 10px; margin-top: 10px;
margin-right: 10px; margin-left: 5px;
} }
.rounded-top { .rounded-top {

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, ViewChild, Input, EventEmitter, HostListener } from '@angular/core'; import { Component, OnInit, OnDestroy, ViewChild, Input, EventEmitter } 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,7 +13,31 @@ 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 {
@@ -38,72 +62,13 @@ export class DownloadsComponent implements OnInit, OnDestroy {
3: $localize`Complete` 3: $localize`Complete`
} }
actionsFlex = 2; displayedColumns: string[] = ['timestamp_start', 'title', 'step_index', 'sub_name', 'percent_complete', 'actions'];
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;
@@ -112,10 +77,6 @@ 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 {
@@ -203,8 +164,8 @@ export class DownloadsComponent implements OnInit, OnDestroy {
}); });
} }
pauseDownload(download: Download): void { pauseDownload(download_uid: string): 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.`);
} }
@@ -219,8 +180,8 @@ export class DownloadsComponent implements OnInit, OnDestroy {
}); });
} }
resumeDownload(download: Download): void { resumeDownload(download_uid: string): 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.`);
} }
@@ -235,8 +196,8 @@ export class DownloadsComponent implements OnInit, OnDestroy {
}); });
} }
restartDownload(download: Download): void { restartDownload(download_uid: string): 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 {
@@ -247,16 +208,16 @@ export class DownloadsComponent implements OnInit, OnDestroy {
}); });
} }
cancelDownload(download: Download): void { cancelDownload(download_uid: string): 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: Download): void { clearDownload(download_uid: string): 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.`);
} }
@@ -296,7 +257,6 @@ 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: {
@@ -316,22 +276,4 @@ export class DownloadsComponent implements OnInit, OnDestroy {
} }
}); });
} }
recalculateColumns() {
if (this.innerWidth < 650) this.displayedColumns = this.displayedColumnsSmall;
else this.displayedColumns = this.displayedColumnsBig;
this.actionsFlex = this.uids || this.innerWidth < 800 ? 1 : 2;
if (this.innerWidth < 800 && !this.uids || this.innerWidth < 1100 && this.uids) this.minimizeButtons = true;
else this.minimizeButtons = false;
}
} }
interface DownloadAction {
tooltip: string,
action: (download: Download) => void,
show: (download: Download) => boolean,
icon: string,
loading?: (download: Download) => boolean
}

View File

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

View File

@@ -13,21 +13,12 @@
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 {
@@ -39,8 +30,4 @@
position: absolute; position: absolute;
right: 8px; right: 8px;
top: 8px; top: 8px;
} }
.viewport {
height: 100%;
}

View File

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

View File

@@ -4,7 +4,7 @@
<mat-chip-listbox [value]="selectedFilters" [multiple]="true" (change)="selectedFiltersChanged($event)"> <mat-chip-listbox [value]="selectedFilters" [multiple]="true" (change)="selectedFiltersChanged($event)">
<mat-chip-option *ngFor="let filter of notificationFilters | keyvalue: originalOrder" [value]="filter.key" [selected]="selectedFilters.includes(filter.key)" color="accent">{{filter.value.label}}</mat-chip-option> <mat-chip-option *ngFor="let filter of notificationFilters | keyvalue: originalOrder" [value]="filter.key" [selected]="selectedFilters.includes(filter.key)" color="accent">{{filter.value.label}}</mat-chip-option>
</mat-chip-listbox> </mat-chip-listbox>
<app-notifications-list class="notifications-list" [style.height]="list_height" (notificationAction)="notificationAction($event)" (deleteNotification)="deleteNotification($event)" [notifications]="filtered_notifications"></app-notifications-list> <app-notifications-list (notificationAction)="notificationAction($event)" (deleteNotification)="deleteNotification($event)" [notifications]="filtered_notifications"></app-notifications-list>
</div> </div>
<button style="margin: 10px 0px 2px 10px;" *ngIf="notifications?.length > 0" color="warn" (click)="deleteAllNotifications()" mat-stroked-button>Remove all</button> <button style="margin: 10px 0px 2px 10px;" *ngIf="notifications?.length > 0" color="warn" (click)="deleteAllNotifications()" mat-stroked-button>Remove all</button>
</div> </div>

View File

@@ -14,7 +14,6 @@ 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>();
@@ -111,8 +110,6 @@ 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 {
@@ -120,12 +117,6 @@ export class NotificationsComponent implements OnInit {
this.filterNotifications(); this.filterNotifications();
} }
calculateListHeight() {
const avgHeight = 166;
const calcHeight = this.filtered_notifications.length * avgHeight;
this.list_height = calcHeight > window.innerHeight*0.65 ? '65vh' : `${calcHeight}px`;
}
originalOrder = (): number => { originalOrder = (): number => {
return 0; return 0;
} }

View File

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

View File

@@ -35,6 +35,36 @@
<p> <p>
<ng-container i18n="About bug prefix">Found a bug or have a suggestion?</ng-container>&nbsp;<a [href]="issuesLink" target="_blank"><ng-container i18n="About bug click here">Click here</ng-container></a>&nbsp;<ng-container i18n="About bug suffix">to create an issue!</ng-container> <ng-container i18n="About bug prefix">Found a bug or have a suggestion?</ng-container>&nbsp;<a [href]="issuesLink" target="_blank"><ng-container i18n="About bug click here">Click here</ng-container></a>&nbsp;<ng-container i18n="About bug suffix">to create an issue!</ng-container>
</p> </p>
<mat-divider></mat-divider>
<div style="margin-top: 10px;">
<h5>Personal settings:</h5>
<mat-form-field>
<mat-label i18n="Sidepanel mode">Sidepanel mode</mat-label>
<mat-select [(ngModel)]="sidepanel_mode" (selectionChange)="sidePanelModeChanged($event.value)">
<mat-option value="over">
Over
</mat-option>
<mat-option value="side">
Side
</mat-option>
</mat-select>
</mat-form-field>
<br/>
<mat-form-field>
<mat-label i18n="File card size">File card size</mat-label>
<mat-select [(ngModel)]="card_size" (selectionChange)="cardSizeOptionChanged($event.value)">
<mat-option value="large">
Large
</mat-option>
<mat-option value="medium">
Medium
</mat-option>
<mat-option value="small">
Small
</mat-option>
</mat-select>
</mat-form-field>
</div>
</div> </div>
</mat-dialog-content> </mat-dialog-content>

View File

@@ -16,6 +16,8 @@ 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) { }
@@ -29,4 +31,15 @@ export class AboutDialogComponent implements OnInit {
this.latestGithubRelease = res; this.latestGithubRelease = res;
}); });
} }
sidePanelModeChanged(new_mode) {
localStorage.setItem('sidepanel_mode', new_mode);
this.postsService.sidepanel_mode = new_mode;
}
cardSizeOptionChanged(new_size) {
localStorage.setItem('card_size', new_size);
this.postsService.card_size = new_size;
}
} }

View File

@@ -2,7 +2,6 @@ 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',
@@ -11,7 +10,7 @@ import { Subscription } from 'api-types';
}) })
export class SubscriptionInfoDialogComponent implements OnInit { export class SubscriptionInfoDialogComponent implements OnInit {
sub: Subscription = null; sub = null;
unsubbedEmitter = null; unsubbedEmitter = null;
constructor(public dialogRef: MatDialogRef<SubscriptionInfoDialogComponent>, constructor(public dialogRef: MatDialogRef<SubscriptionInfoDialogComponent>,
@@ -42,7 +41,7 @@ export class SubscriptionInfoDialogComponent implements OnInit {
} }
unsubscribe() { unsubscribe() {
this.postsService.unsubscribe(this.sub.id, true).subscribe(res => { this.postsService.unsubscribe(this.sub, true).subscribe(res => {
this.unsubbedEmitter.emit(true); this.unsubbedEmitter.emit(true);
this.dialogRef.close(); this.dialogRef.close();
}); });

View File

@@ -13,52 +13,19 @@
</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">
<mat-label><ng-container i18n="Language select label">Language</ng-container></mat-label> <div *ngIf="!postsService.isLoggedIn || !postsService.user">
<mat-select (selectionChange)="localeSelectChanged($event.value)" [(value)]="initialLocale"> <h5><mat-icon>warn</mat-icon><ng-container i18n="Not logged in notification">You are not logged in.</ng-container></h5>
<mat-option *ngFor="let locale of supported_locales" [value]="locale"> <button (click)="loginClicked()" mat-raised-button color="primary"><ng-container i18n="Login">Login</ng-container></button>
<ng-container *ngIf="all_locales[locale]"> </div>
{{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 *ngIf="postsService.isLoggedIn" style="position: absolute; right: 0px;" (click)="logoutClicked()" mat-stroked-button color="warn"><ng-container i18n="Logout">Logout</ng-container></button> <button style="position: absolute; right: 0px;" (click)="logoutClicked()" mat-stroked-button color="warn"><ng-container i18n="Logout">Logout</ng-container></button>
</div> </div>
</div> </div>
</mat-dialog-actions> </mat-dialog-actions>

View File

@@ -2,7 +2,6 @@ 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',
@@ -11,24 +10,9 @@ import { isoLangs } from './locales_list';
}) })
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() {
@@ -41,19 +25,4 @@ export class UserProfileDialogComponent implements OnInit {
this.dialogRef.close(); this.dialogRef.close();
} }
localeSelectChanged(new_val: string): void {
localStorage.setItem('locale', new_val);
this.postsService.openSnackBar($localize`Language successfully changed! Reload to update the page.`)
}
sidePanelModeChanged(new_mode) {
localStorage.setItem('sidepanel_mode', new_mode);
this.postsService.sidepanel_mode = new_mode;
}
cardSizeOptionChanged(new_size) {
localStorage.setItem('card_size', new_size);
this.postsService.card_size = new_size;
}
} }

View File

@@ -205,7 +205,7 @@
</div> </div>
<div style="display: flex; justify-content: center;" *ngIf="downloads && downloads.length > 0 && !autoplay"> <div style="display: flex; justify-content: center;" *ngIf="downloads && downloads.length > 0 && !autoplay">
<app-downloads style="width: 80%; min-width: 350px; margin-bottom: 10px" [uids]="download_uids"></app-downloads> <app-downloads style="width: 80%; margin-bottom: 10px" [uids]="download_uids"></app-downloads>
</div> </div>
<ng-container *ngIf="cachedFileManagerEnabled || fileManagerEnabled"> <ng-container *ngIf="cachedFileManagerEnabled || fileManagerEnabled">

View File

@@ -169,8 +169,6 @@ 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;
@@ -234,12 +232,11 @@ export class MainComponent implements OnInit {
} }
// get downloads routine // get downloads routine
if (this.interval_id) { clearInterval(this.interval_id) } setInterval(() => {
this.interval_id = setInterval(() => {
if (this.current_download) { if (this.current_download) {
this.getCurrentDownload(); this.getCurrentDownload();
} }
}, 1000); }, 500);
return true; return true;
} }
@@ -297,10 +294,6 @@ 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;

View File

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

View File

@@ -22,22 +22,20 @@
</p> </p>
</ng-container> </ng-container>
<ng-container *ngIf="!db_file || !db_file['description']"> <ng-container *ngIf="!db_file || !db_file['description']">
<p i18n="No description" style="text-align: center;"> <p 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">
<span class="buttons" *ngIf="db_playlist"> <ng-container *ngIf="db_playlist">
<button (click)="downloadContent()" [disabled]="downloading" mat-icon-button><mat-icon>save</mat-icon></button> <button (click)="downloadContent()" [disabled]="downloading" mat-icon-button><mat-icon>save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="35"></mat-spinner></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>
</span> </ng-container>
<span class="buttons" *ngIf="db_file"> <ng-container *ngIf="db_file">
<button (click)="downloadFile()" [disabled]="downloading" mat-icon-button><mat-icon>cloud_download</mat-icon></button> <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>
<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>
</span> </ng-container>
<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>
@@ -58,6 +56,14 @@
<app-twitch-chat #twitchchat [db_file]="db_file" [current_timestamp]="api.currentTime" [sub]="subscription"></app-twitch-chat> <app-twitch-chat #twitchchat [db_file]="db_file" [current_timestamp]="api.currentTime" [sub]="subscription"></app-twitch-chat>
</ng-container> </ng-container>
</mat-drawer> </mat-drawer>
<!-- <div class="update-playlist-button-div" *ngIf="id && playlistChanged()">
<div class="spinner-div">
<mat-spinner *ngIf="playlist_updating" [diameter]="25"></mat-spinner>
</div>
<button color="primary" [disabled]="playlist_updating" (click)="updatePlaylist()" mat-raised-button><ng-container i18n="Playlist save changes button">Save changes</ng-container>&nbsp;<mat-icon>update</mat-icon></button>
</div> -->
</mat-drawer-container> </mat-drawer-container>
</div> </div>
</div> </div>

View File

@@ -61,6 +61,8 @@ 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;
@@ -68,7 +70,14 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('twitchchat') twitchChat: TwitchChatComponent; @ViewChild('twitchchat') twitchChat: TwitchChatComponent;
@HostListener('window:resize', ['$event'])
onResize(): void {
this.innerWidth = window.innerWidth;
}
ngOnInit(): void { ngOnInit(): void {
this.innerWidth = window.innerWidth;
this.playlist_id = this.route.snapshot.paramMap.get('playlist_id'); this.playlist_id = this.route.snapshot.paramMap.get('playlist_id');
this.uid = this.route.snapshot.paramMap.get('uid'); this.uid = this.route.snapshot.paramMap.get('uid');
this.sub_id = this.route.snapshot.paramMap.get('sub_id'); this.sub_id = this.route.snapshot.paramMap.get('sub_id');

View File

@@ -113,13 +113,11 @@ import {
Archive, Archive,
Subscription, Subscription,
RestartDownloadResponse, RestartDownloadResponse,
TaskType, TaskType
CheckSubscriptionRequest
} from '../api-types'; } from '../api-types';
import { isoLangs } from './dialogs/user-profile-dialog/locales_list'; import { isoLangs } from './settings/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';
import { environment } from '../environments/environment';
@Injectable() @Injectable()
export class PostsService implements CanActivate { export class PostsService implements CanActivate {
@@ -177,7 +175,7 @@ export class PostsService implements CanActivate {
if (isDevMode()) { if (isDevMode()) {
this.debugMode = true; this.debugMode = true;
this.path = !environment.codespaces ? 'http://localhost:17442/api/' : `${window.location.origin.replace('4200', '17442')}/api/`; this.path = 'http://localhost:17442/api/';
} }
this.http_params = `apiKey=${this.auth_token}` this.http_params = `apiKey=${this.auth_token}`
@@ -568,18 +566,8 @@ 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);
} }
checkSubscription(sub_id: string) { unsubscribe(sub: SubscriptionRequestData, deleteMode = false) {
const body: CheckSubscriptionRequest = {sub_id: sub_id}; const body: UnsubscribeRequest = {sub: sub, deleteMode: deleteMode};
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)
} }

View File

@@ -78,6 +78,23 @@
</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 -->
@@ -426,13 +443,6 @@
<mat-hint><a target="_blank" href="https://stackoverflow.com/a/37396871/8088021"><ng-container i18n="Telegram chat ID help">How do I get the chat ID?</ng-container></a></mat-hint> <mat-hint><a target="_blank" href="https://stackoverflow.com/a/37396871/8088021"><ng-container i18n="Telegram chat ID help">How do I get the chat ID?</ng-container></a></mat-hint>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="col-12 mb-2">
<mat-form-field class="text-field" color="accent">
<mat-label i18n="Telegram webhook proxy">Telegram webhook proxy</mat-label>
<input placeholder="https://smee.io/XXXXX" [disabled]="!new_config['Extra']['enable_notifications'] || !new_config['API']['use_telegram_API']" [(ngModel)]="new_config['API']['telegram_webhook_proxy']" matInput>
<mat-hint><a target="_blank" href="https://smee.io/"><ng-container i18n="Telegram webhook proxy help">Example service</ng-container></a></mat-hint>
</mat-form-field>
</div>
</div> </div>
</div> </div>
</ng-template> </ng-template>

View File

@@ -1,5 +1,6 @@
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';
@@ -21,6 +22,10 @@ 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;
@@ -78,6 +83,16 @@ 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 {
@@ -192,6 +207,11 @@ 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);
} }

View File

@@ -3,7 +3,6 @@
<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}}&nbsp;<ng-container *ngIf="subscription.paused" i18n="Paused suffix">(Paused)</ng-container> {{subscription.name}}&nbsp;<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>
@@ -14,14 +13,7 @@
<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>
<div class="check-button"> <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>
<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>

View File

@@ -58,19 +58,13 @@
bottom: 25px; bottom: 25px;
} }
.check-button { .edit-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;

View File

@@ -3,7 +3,6 @@ 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',
@@ -13,13 +12,11 @@ import { Subscription } from 'api-types';
export class SubscriptionComponent implements OnInit, OnDestroy { export class SubscriptionComponent implements OnInit, OnDestroy {
id = null; id = null;
subscription: Subscription = null; 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) { }
@@ -93,34 +90,4 @@ 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!');
});
}
} }

View File

@@ -4790,157 +4790,6 @@
</context-group> </context-group>
<note priority="1" from="description">Download error</note> <note priority="1" from="description">Download error</note>
</trans-unit> </trans-unit>
<trans-unit id="28da11220a3377ddce3c7948825d33101f142782" datatype="html">
<source>Extractor</source>
<target state="translated">Extraktor</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="2e076ff9866213d0815961c494aa48b177046b9d" datatype="html">
<source>Telegram bot token</source>
<target state="translated">Telegram-Bot-Token</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">417</context>
</context-group>
<note priority="1" from="description">Telegram bot token</note>
</trans-unit>
<trans-unit id="6080b77234e92ad41bb52653b239c4c4f851317d" datatype="html">
<source>Error</source>
<target state="translated">Fehler</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.html</context>
<context context-type="linenumber">39</context>
</context-group>
<note priority="1" from="description">Error</note>
</trans-unit>
<trans-unit id="3640026747176198246" datatype="html">
<source>Watch content</source>
<target state="translated">Inhalt ansehen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">50</context>
</context-group>
</trans-unit>
<trans-unit id="8456659390937171831" datatype="html">
<source>Show error</source>
<target state="translated">Fehler anzeigen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">56</context>
</context-group>
</trans-unit>
<trans-unit id="1236604279860679031" datatype="html">
<source>Restart</source>
<target state="translated">Neu starten</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">62</context>
</context-group>
</trans-unit>
<trans-unit id="9042260521669277115" datatype="html">
<source>Pause</source>
<target state="translated">Pause</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">68</context>
</context-group>
</trans-unit>
<trans-unit id="7182974689040833178" datatype="html">
<source>Resume</source>
<target state="translated">Fortsetzen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">74</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">80</context>
</context-group>
</trans-unit>
<trans-unit id="1d4fa01d25990f60abf21c3a451fa8ba262b7912" datatype="html">
<source>Unfavorite</source>
<target state="translated">Entfavorisieren</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/unified-file-card/unified-file-card.component.html</context>
<context context-type="linenumber">27</context>
</context-group>
<note priority="1" from="description">Unfavorite button</note>
</trans-unit>
<trans-unit id="b78a98bc54259a29cf6250dbaeab5fe11fae91cf" datatype="html">
<source>Favorited</source>
<target state="translated">Favorisiert</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">51</context>
</context-group>
<note priority="1" from="description">Favorited</note>
</trans-unit>
<trans-unit id="1698114086921246480" datatype="html">
<source>Unsubscribe</source>
<target state="translated">Deabonnieren</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/subscription-info-dialog/subscription-info-dialog.component.ts</context>
<context context-type="linenumber">32</context>
</context-group>
</trans-unit>
<trans-unit id="b6399391e706e2d7b7b7880eb5630e4e6f49728c" datatype="html">
<source>Side</source>
<target state="translated">Seite</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html</context>
<context context-type="linenumber">35,37</context>
</context-group>
<note priority="1" from="description">Side</note>
</trans-unit>
<trans-unit id="338b44701a53ce3ef2f36abfb56f89c3edfa9eab" datatype="html">
<source>Over</source>
<target state="translated">Über</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html</context>
<context context-type="linenumber">32,34</context>
</context-group>
<note priority="1" from="description">Over</note>
</trans-unit>
<trans-unit id="e0a11fbea353b1ce1131161774e4a3e10bcb99b1" datatype="html">
<source>Large</source>
<target state="translated">Groß</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html</context>
<context context-type="linenumber">44,46</context>
</context-group>
<note priority="1" from="description">Large</note>
</trans-unit>
<trans-unit id="378c072ce05889c9771718d05106e7685fcd3507" datatype="html">
<source>Medium</source>
<target state="translated">Mittel</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html</context>
<context context-type="linenumber">47,49</context>
</context-group>
<note priority="1" from="description">Medium</note>
</trans-unit>
<trans-unit id="9a865c2922f5c01899d06c472dba2e5bd63bcff9" datatype="html">
<source>Small</source>
<target state="translated">Klein</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html</context>
<context context-type="linenumber">50,52</context>
</context-group>
<note priority="1" from="description">Small</note>
</trans-unit>
<trans-unit id="af30e51aa8b67e1133a341ec28359be05150e65c" datatype="html">
<source>No description available.</source>
<target state="translated">Keine Beschreibung verfügbar.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/player/player.component.html</context>
<context context-type="linenumber">25,27</context>
</context-group>
<note priority="1" from="description">No description</note>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

File diff suppressed because it is too large Load Diff

View File

@@ -3934,113 +3934,6 @@
</context-group> </context-group>
<note priority="1" from="description">Discord Webhook URL</note> <note priority="1" from="description">Discord Webhook URL</note>
</trans-unit> </trans-unit>
<trans-unit id="338b44701a53ce3ef2f36abfb56f89c3edfa9eab" datatype="html">
<source>Over</source>
<target state="translated">Sobre</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html</context>
<context context-type="linenumber">32,34</context>
</context-group>
<note priority="1" from="description">Over</note>
</trans-unit>
<trans-unit id="6080b77234e92ad41bb52653b239c4c4f851317d" datatype="html">
<source>Error</source>
<target state="translated">Error</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.html</context>
<context context-type="linenumber">39</context>
</context-group>
<note priority="1" from="description">Error</note>
</trans-unit>
<trans-unit id="3640026747176198246" datatype="html">
<source>Watch content</source>
<target state="translated">Ver el contenido</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">50</context>
</context-group>
</trans-unit>
<trans-unit id="8456659390937171831" datatype="html">
<source>Show error</source>
<target state="translated">Mostrar el error</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">56</context>
</context-group>
</trans-unit>
<trans-unit id="1236604279860679031" datatype="html">
<source>Restart</source>
<target state="translated">Reiniciar</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">62</context>
</context-group>
</trans-unit>
<trans-unit id="9042260521669277115" datatype="html">
<source>Pause</source>
<target state="translated">Pausar</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">68</context>
</context-group>
</trans-unit>
<trans-unit id="7182974689040833178" datatype="html">
<source>Resume</source>
<target state="translated">Resumen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">74</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">80</context>
</context-group>
</trans-unit>
<trans-unit id="b6399391e706e2d7b7b7880eb5630e4e6f49728c" datatype="html">
<source>Side</source>
<target state="translated">Lado</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html</context>
<context context-type="linenumber">35,37</context>
</context-group>
<note priority="1" from="description">Side</note>
</trans-unit>
<trans-unit id="e0a11fbea353b1ce1131161774e4a3e10bcb99b1" datatype="html">
<source>Large</source>
<target state="translated">Largo</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html</context>
<context context-type="linenumber">44,46</context>
</context-group>
<note priority="1" from="description">Large</note>
</trans-unit>
<trans-unit id="378c072ce05889c9771718d05106e7685fcd3507" datatype="html">
<source>Medium</source>
<target state="translated">Medio</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html</context>
<context context-type="linenumber">47,49</context>
</context-group>
<note priority="1" from="description">Medium</note>
</trans-unit>
<trans-unit id="9a865c2922f5c01899d06c472dba2e5bd63bcff9" datatype="html">
<source>Small</source>
<target state="translated">Pequeño</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html</context>
<context context-type="linenumber">50,52</context>
</context-group>
<note priority="1" from="description">Small</note>
</trans-unit>
<trans-unit id="af30e51aa8b67e1133a341ec28359be05150e65c" datatype="html">
<source>No description available.</source>
<target state="translated">Sin una descripción disponible.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/player/player.component.html</context>
<context context-type="linenumber">25,27</context>
</context-group>
<note priority="1" from="description">No description</note>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -4133,899 +4133,6 @@
<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="translated">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>
<trans-unit id="347407180135731058" datatype="html">
<source>Audio</source>
<target state="translated">Audio</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">44</context>
</context-group>
</trans-unit>
<trans-unit id="8953483585652369683" datatype="html">
<source>Archive successfully imported!</source>
<target state="translated">Arsip berhasil diimpor!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">130</context>
</context-group>
</trans-unit>
<trans-unit id="6549265851868599441" datatype="html">
<source>Video</source>
<target state="translated">Video</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">40</context>
</context-group>
</trans-unit>
<trans-unit id="3159807825117518005" datatype="html">
<source>Delete archives</source>
<target state="translated">Hapus arsip</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">152</context>
</context-group>
</trans-unit>
<trans-unit id="8425787787095143143" datatype="html">
<source>Would you like to delete <x id="selected archives amount" equiv-text="this.selection.selected.length"/> archive(s)?</source>
<target state="translated">Anda ingin menghapus arsip <x id="selected archives amount" equiv-text="this.selection.selected.length"/> ?</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">153</context>
</context-group>
</trans-unit>
<trans-unit id="7022070615528435141" datatype="html">
<source>Delete</source>
<target state="translated">Hapus</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">154</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">160</context>
</context-group>
</trans-unit>
<trans-unit id="2525880134753073592" datatype="html">
<source>Successfully deleted archive items!</source>
<target state="translated">Berhasil menghapus arsip!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">172</context>
</context-group>
</trans-unit>
<trans-unit id="8224301330941792118" datatype="html">
<source>Failed to delete archive items!</source>
<target state="translated">Gagal menghapus arsip!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">174</context>
</context-group>
</trans-unit>
<trans-unit id="a2f14a73f7a6e94479f67423cc51102da8d6f524" datatype="html">
<source>None</source>
<target state="translated">Tidak ada</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">84</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">126</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">27</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">36</context>
</context-group>
<note priority="1" from="description">None</note>
</trans-unit>
<trans-unit id="56b1a3c93fb95fed1805005c561a5e431d57ffae" datatype="html">
<source>Blacklist all files</source>
<target state="translated">Blacklist semua file</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/task-settings/task-settings.component.html</context>
<context context-type="linenumber">11</context>
</context-group>
<note priority="1" from="description">Blacklist deleted files</note>
</trans-unit>
<trans-unit id="9aa1b4779a515170b297d2c0507e6ff9d2e3e0e0" datatype="html">
<source>Blacklist deleted subscription files</source>
<target state="translated">Blacklist file langganan yang sudah dihapus</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/task-settings/task-settings.component.html</context>
<context context-type="linenumber">14</context>
</context-group>
<note priority="1" from="description">Blacklist deleted subscription files</note>
</trans-unit>
<trans-unit id="d618f383a0ea2458eeb945a85190d4a002ea394b" datatype="html">
<source>Arg</source>
<target state="translated">Arg</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/arg-modifier-dialog/arg-modifier-dialog.component.html</context>
<context context-type="linenumber">41</context>
</context-group>
<note priority="1" from="description">Arg</note>
</trans-unit>
<trans-unit id="37469c9f3e31d95087fa22b6c9c3bc64adf1692d" datatype="html">
<source>Enable RSS Feed</source>
<target state="translated">Aktifkan RSS Feed</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">271</context>
</context-group>
<note priority="1" from="description">Enable RSS Feed setting</note>
</trans-unit>
<trans-unit id="61d6b5fa4311b1c617b66dad72496f9dd43b07b4" datatype="html">
<source>Be careful enabling this with multi-user mode! User data may be exposed.</source>
<target state="translated">Berhati-hatilah dalam mengaktifkan ini dengan mode multi-pengguna! Data pengguna dapat terekspos.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">272</context>
</context-group>
<note priority="1" from="description">RSS Feed prefix</note>
</trans-unit>
<trans-unit id="33a7c6d5ff3515fa237f1fd4e43df8b65373954d" datatype="html">
<source>Enable all notifications</source>
<target state="translated">Aktifkan semua notifikasi</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">352</context>
</context-group>
<note priority="1" from="description">Enable all notifications setting</note>
</trans-unit>
<trans-unit id="5827fde8fcafdd55ae80921ad3ad4aa01012e203" datatype="html">
<source>Use gotify API</source>
<target state="translated">Gunakan API gotify</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">396</context>
</context-group>
<note priority="1" from="description">Use gotify API setting</note>
</trans-unit>
<trans-unit id="8c6e24eab969d9f63a8a0e9d617aee3b99e28ae6" datatype="html">
<source>Play all</source>
<target state="translated">Mainkan semua</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/subscription/subscription/subscription.component.html</context>
<context context-type="linenumber">17</context>
</context-group>
<note priority="1" from="description">Play all</note>
</trans-unit>
<trans-unit id="674a999dd48d7da565ffdd105602261b8a4761ea" datatype="html">
<source>Download zip</source>
<target state="translated">Unduh zip</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/subscription/subscription/subscription.component.html</context>
<context context-type="linenumber">18</context>
</context-group>
<note priority="1" from="description">Download zip</note>
</trans-unit>
<trans-unit id="0ed98b4c6ec1db6708a963e8a2699478ac97f55c" datatype="html">
<source>Add subscription</source>
<target state="translated">Tambahkan langganan</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/subscriptions/subscriptions.component.html</context>
<context context-type="linenumber">60</context>
</context-group>
<note priority="1" from="description">Add subscription</note>
</trans-unit>
<trans-unit id="019d4bd6a5690f0cfa0ecf346a4e6bf7f0d8debb" datatype="html">
<source>Remove</source>
<target state="translated">Buang</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.html</context>
<context context-type="linenumber">23</context>
</context-group>
<note priority="1" from="description">Remove</note>
</trans-unit>
<trans-unit id="8564202903947049539" datatype="html">
<source>Play</source>
<target state="translated">Putar</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.ts</context>
<context context-type="linenumber">30</context>
</context-group>
</trans-unit>
<trans-unit id="8643601595923420698" datatype="html">
<source>Retry download</source>
<target state="translated">Unduh lagi</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.ts</context>
<context context-type="linenumber">31</context>
</context-group>
</trans-unit>
<trans-unit id="8571838164752006148" datatype="html">
<source>View error</source>
<target state="translated">Lihat kesalahan</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.ts</context>
<context context-type="linenumber">32</context>
</context-group>
</trans-unit>
<trans-unit id="5709555629190115111" datatype="html">
<source>View task</source>
<target state="translated">Lihat tugas</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.ts</context>
<context context-type="linenumber">33</context>
</context-group>
</trans-unit>
<trans-unit id="1879058637439215882" datatype="html">
<source>Download error</source>
<target state="translated">Kesalahan pengunduhan</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications/notifications.component.ts</context>
<context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="cdf5297d8d080a78e8b10debc5c38b7845a3cbe7" datatype="html">
<source>Do not ask for confirmation</source>
<target state="translated">Jangan konfirmasi</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/task-settings/task-settings.component.html</context>
<context context-type="linenumber">19</context>
</context-group>
<note priority="1" from="description">Do not ask for confirmation</note>
</trans-unit>
<trans-unit id="9176960997786930103" datatype="html">
<source>Error for: <x id="PH" equiv-text="task['title']"/></source>
<target state="translated">Kesalahan untuk: <x id="PH" equiv-text="task['title']"/></target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/tasks/tasks.component.ts</context>
<context context-type="linenumber">174</context>
</context-group>
</trans-unit>
<trans-unit id="1d4fa01d25990f60abf21c3a451fa8ba262b7912" datatype="html">
<source>Unfavorite</source>
<target state="translated">Hapus dari favorit</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/unified-file-card/unified-file-card.component.html</context>
<context context-type="linenumber">27</context>
</context-group>
<note priority="1" from="description">Unfavorite button</note>
</trans-unit>
<trans-unit id="11a0771f88158a540a54e0e4ec5d25733d65fc0e" datatype="html">
<source>Favorite</source>
<target state="translated">Favoritkan</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/unified-file-card/unified-file-card.component.html</context>
<context context-type="linenumber">26</context>
</context-group>
<note priority="1" from="description">Favorite button</note>
</trans-unit>
<trans-unit id="c35ef0f03a863d33b04aae6807f140397a50f491" datatype="html">
<source>Generate RSS URL</source>
<target state="translated">Hasilkan URL RSS</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">1</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">273</context>
</context-group>
<note priority="1" from="description">Generate RSS URL</note>
</trans-unit>
<trans-unit id="e08a77594f3d89311cdf6da5090044270909c194" datatype="html">
<source>User</source>
<target state="translated">Pengguna</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">25</context>
</context-group>
<note priority="1" from="description">User</note>
</trans-unit>
<trans-unit id="8336047719608684263" datatype="html">
<source>Unsubscribe from <x id="subscription name" equiv-text="this.sub['name']"/></source>
<target state="translated">Berhenti berlangganan from <x id="subscription name" equiv-text="this.sub['name']"/></target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/subscription-info-dialog/subscription-info-dialog.component.ts</context>
<context context-type="linenumber">30</context>
</context-group>
</trans-unit>
<trans-unit id="1698114086921246480" datatype="html">
<source>Unsubscribe</source>
<target state="translated">Berhenti berlangganan</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/subscription-info-dialog/subscription-info-dialog.component.ts</context>
<context context-type="linenumber">32</context>
</context-group>
</trans-unit>
<trans-unit id="1091872159779006651" datatype="html">
<source>You must input a time!</source>
<target state="translated">Anda harus memasukkan waktu!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component.ts</context>
<context context-type="linenumber">70</context>
</context-group>
</trans-unit>
<trans-unit id="378c072ce05889c9771718d05106e7685fcd3507" datatype="html">
<source>Medium</source>
<target state="translated">Sedang</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html</context>
<context context-type="linenumber">47,49</context>
</context-group>
<note priority="1" from="description">Medium</note>
</trans-unit>
<trans-unit id="9a865c2922f5c01899d06c472dba2e5bd63bcff9" datatype="html">
<source>Small</source>
<target state="translated">Kecil</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html</context>
<context context-type="linenumber">50,52</context>
</context-group>
<note priority="1" from="description">Small</note>
</trans-unit>
<trans-unit id="8c1bf02206fbc371ff69ab1b7e35a17ba29d169d" datatype="html">
<source>Use ntfy API</source>
<target state="translated">Gunakan API ntfy</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">386</context>
</context-group>
<note priority="1" from="description">Use ntfy API setting</note>
</trans-unit>
<trans-unit id="06f503e492d6dbcf59e7b9c412ca86913d718689" datatype="html">
<source>ntfy topic URL</source>
<target state="translated">URL topik ntfy</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">ntfy topic URL</note>
</trans-unit>
<trans-unit id="55f559d6f666b945479f534b0c182f70cd0a8a69" datatype="html">
<source>Gotify server URL</source>
<target state="translated">URL server Gotify</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">400</context>
</context-group>
<note priority="1" from="description">Gotify server URL</note>
</trans-unit>
<trans-unit id="a4ed8eba1e057e67d5c2d87b52230f182b3dae4e" datatype="html">
<source>Restart required.</source>
<target state="translated">Restart diperlukan.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">446</context>
</context-group>
<note priority="1" from="description">Restart required hint</note>
</trans-unit>
<trans-unit id="4b3972c3e9485218508a95f7a4ce7758e3f09ced" datatype="html">
<source>Upload</source>
<target state="translated">Unggah</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">137</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/cookies-uploader-dialog/cookies-uploader-dialog.component.html</context>
<context context-type="linenumber">30</context>
</context-group>
<note priority="1" from="description">Upload</note>
</trans-unit>
<trans-unit id="5947241266456580665" datatype="html">
<source>Download failed</source>
<target state="translated">Gagal mengunduh</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.ts</context>
<context context-type="linenumber">18</context>
</context-group>
</trans-unit>
<trans-unit id="8443034725057696949" datatype="html">
<source>Task finished</source>
<target state="translated">Tugas selesai</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.ts</context>
<context context-type="linenumber">19</context>
</context-group>
</trans-unit>
<trans-unit id="3533826530554274875" datatype="html">
<source>Upload Date</source>
<target state="translated">Tanggal pengunggahan</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/sort-property/sort-property.component.ts</context>
<context context-type="linenumber">17</context>
</context-group>
</trans-unit>
<trans-unit id="c41475a25c9f9d9639db9efa56637603a77528b4" datatype="html">
<source>Download archive</source>
<target state="translated">Unduh arsip</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">80</context>
</context-group>
<note priority="1" from="description">Download archive</note>
</trans-unit>
<trans-unit id="4578192247039196794" datatype="html">
<source>Task</source>
<target state="translated">Tugas</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications/notifications.component.ts</context>
<context context-type="linenumber">31</context>
</context-group>
</trans-unit>
<trans-unit id="6219551536751479443" datatype="html">
<source>Finished downloading</source>
<target state="translated">Selesai mengunduh</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.ts</context>
<context context-type="linenumber">17</context>
</context-group>
</trans-unit>
<trans-unit id="5a105e7bd7e7db6ea211fe950fc9f317379acceb" datatype="html">
<source>No notifications available</source>
<target state="translated">Tidak ada notifikasi</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications/notifications.component.html</context>
<context context-type="linenumber">1</context>
</context-group>
<note priority="1" from="description">No notifications available</note>
</trans-unit>
<trans-unit id="6876310993601590130" datatype="html">
<source>Download completed</source>
<target state="translated">Unduhan selesai</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications/notifications.component.ts</context>
<context context-type="linenumber">23</context>
</context-group>
</trans-unit>
<trans-unit id="5000203534763292992" datatype="html">
<source>Download restarted!</source>
<target state="translated">Unduh dimulai ulang!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications/notifications.component.ts</context>
<context context-type="linenumber">72</context>
</context-group>
</trans-unit>
<trans-unit id="7911845622864460134" datatype="html">
<source>Video only</source>
<target state="translated">Hanya video</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/recent-videos/recent-videos.component.ts</context>
<context context-type="linenumber">55</context>
</context-group>
</trans-unit>
<trans-unit id="4665451070906079743" datatype="html">
<source>Favorited</source>
<target state="translated">Favorit</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/recent-videos/recent-videos.component.ts</context>
<context context-type="linenumber">65</context>
</context-group>
</trans-unit>
<trans-unit id="c65dd978b3c7566551c0ebefb234c2d41942b847" datatype="html">
<source>Delete files older than</source>
<target state="translated">Hapus file yang berusia diatas</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/task-settings/task-settings.component.html</context>
<context context-type="linenumber">6</context>
</context-group>
<note priority="1" from="description">Delete files older than</note>
</trans-unit>
<trans-unit id="7b4585a9072f3c1292972c14a3d0e14978fbfc9c" datatype="html">
<source>Delete old files:</source>
<target state="translated">Hapus file lama:</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/tasks/tasks.component.html</context>
<context context-type="linenumber">66</context>
</context-group>
<note priority="1" from="description">Delete old files</note>
</trans-unit>
<trans-unit id="6437411876967154040" datatype="html">
<source>Audio only</source>
<target state="translated">Hanya audio</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/recent-videos/recent-videos.component.ts</context>
<context context-type="linenumber">60</context>
</context-group>
</trans-unit>
<trans-unit id="ea2b65121b93921fe54692025da9b9e3ce779ad5" datatype="html">
<source>Task settings - <x id="INTERPOLATION" equiv-text="{{task.title}}"/></source>
<target state="translated">Pengaturan tugas - <x id="INTERPOLATION" equiv-text="{{task.title}}"/></target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/task-settings/task-settings.component.html</context>
<context context-type="linenumber">1</context>
</context-group>
<note priority="1" from="description">Task settings</note>
</trans-unit>
<trans-unit id="1f2809e6a99d511fdb6eaf041d785fe54d0680cc" datatype="html">
<source>File card size</source>
<target state="translated">Ukuran kartu file</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html</context>
<context context-type="linenumber">42</context>
</context-group>
<note priority="1" from="description">File card size</note>
</trans-unit>
<trans-unit id="6268070779441507380" datatype="html">
<source>Download Date</source>
<target state="translated">Tanggal pengunduhan</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/sort-property/sort-property.component.ts</context>
<context context-type="linenumber">13</context>
</context-group>
</trans-unit>
<trans-unit id="2492098975665776610" datatype="html">
<source>File Size</source>
<target state="translated">Ukuran file</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/sort-property/sort-property.component.ts</context>
<context context-type="linenumber">25</context>
</context-group>
</trans-unit>
<trans-unit id="8953033926734869941" datatype="html">
<source>Name</source>
<target state="translated">Nama</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/sort-property/sort-property.component.ts</context>
<context context-type="linenumber">21</context>
</context-group>
</trans-unit>
<trans-unit id="7410432243549869948" datatype="html">
<source>Duration</source>
<target state="translated">Durasi</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/sort-property/sort-property.component.ts</context>
<context context-type="linenumber">29</context>
</context-group>
</trans-unit>
<trans-unit id="5ac5a0e5ffe8e5623b40696f4c2403c17349271f" datatype="html">
<source>Sidepanel mode</source>
<target state="translated">Model sidepanel</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html</context>
<context context-type="linenumber">30</context>
</context-group>
<note priority="1" from="description">Sidepanel mode</note>
</trans-unit>
<trans-unit id="1a9b415816364f554ee411020e65219092655271" datatype="html">
<source>Title filter</source>
<target state="translated">Filter judul</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">8</context>
</context-group>
<note priority="1" from="description">Title filter</note>
</trans-unit>
<trans-unit id="9aa62bf1a535a97a4d752bbc5cf1c31af0f0c1f7" datatype="html">
<source>Supports regex</source>
<target state="translated">Dukungan regex</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">10</context>
</context-group>
<note priority="1" from="description">Supports regex</note>
</trans-unit>
<trans-unit id="c2faa86201eab08b5b39b5437f96ab9432e125e7" datatype="html">
<source>Item limit</source>
<target state="translated">Batasan item</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">46</context>
</context-group>
<note priority="1" from="description">Item limit</note>
</trans-unit>
<trans-unit id="b78a98bc54259a29cf6250dbaeab5fe11fae91cf" datatype="html">
<source>Favorited</source>
<target state="translated">Yang di favoritkan</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">51</context>
</context-group>
<note priority="1" from="description">Favorited</note>
</trans-unit>
<trans-unit id="784837056777689544" datatype="html">
<source>Would you like to unsubscribe from <x id="subscription name" equiv-text="this.sub['name']"/>?</source>
<target state="translated">Apakah anda ingin berhenti berlangganan dari <x id="subscription name" equiv-text="this.sub['name']"/>?</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/subscription-info-dialog/subscription-info-dialog.component.ts</context>
<context context-type="linenumber">31</context>
</context-group>
</trans-unit>
<trans-unit id="7cedb649779673568447b994463b2882c4e0436a" datatype="html">
<source>Slack Webhook URL</source>
<target state="translated">URL Slack Webhook</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">380</context>
</context-group>
<note priority="1" from="description">Slack Webhook URL</note>
</trans-unit>
<trans-unit id="9c562d26e041390ecc3f49dabc51cc50ebba7469" datatype="html">
<source>Allowed notification types</source>
<target state="translated">Jenis notifikasi yang diizinkan</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">356</context>
</context-group>
<note priority="1" from="description">Allowed notification types</note>
</trans-unit>
<trans-unit id="2481374649045841364" datatype="html">
<source>Would you like to delete <x id="category name" equiv-text="category['name']"/>?</source>
<target state="needs-translation">Apakah Anda ingin menghapus <x id="category name" equiv-text="category['name']"/>?</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">159</context>
</context-group>
</trans-unit>
<trans-unit id="fd467148a18f0921c10d116d4e0174fe29452be4" datatype="html">
<source>See documentation here.</source>
<target state="translated">Lihat dokumentasi di sini.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">274</context>
</context-group>
<note priority="1" from="description">RSS feed documentation</note>
</trans-unit>
<trans-unit id="d57c023a4cf63b2f12c10328c15b636ff18929aa" datatype="html">
<source>Best</source>
<target state="translated">Terbaik</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/main/main.component.html</context>
<context context-type="linenumber">24,25</context>
</context-group>
<note priority="1" from="description">Best</note>
</trans-unit>
<trans-unit id="8bcabdf6b16cad0313a86c7e940c5e3ad7f9f8ab" datatype="html">
<source>Notifications</source>
<target state="translated">Notifikasi</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">343</context>
</context-group>
<note priority="1" from="description">Notifications settings label</note>
</trans-unit>
<trans-unit id="3ffd9490f3a4c0b24021d25e1dc71fcfe5d39cd6" datatype="html">
<source>Download error</source>
<target state="translated">Kesalahan pengunduhan</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">359</context>
</context-group>
<note priority="1" from="description">Download error</note>
</trans-unit>
<trans-unit id="38992954440d6afb54aeb58af12ca0123ee5e26e" datatype="html">
<source>Use Telegram API</source>
<target state="translated">Gunakan API Telegram</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">413</context>
</context-group>
<note priority="1" from="description">Use Telegram API setting</note>
</trans-unit>
<trans-unit id="6080b77234e92ad41bb52653b239c4c4f851317d" datatype="html">
<source>Error</source>
<target state="translated">Kesalahan</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.html</context>
<context context-type="linenumber">39</context>
</context-group>
<note priority="1" from="description">Error</note>
</trans-unit>
<trans-unit id="3640026747176198246" datatype="html">
<source>Watch content</source>
<target state="translated">Tonton konten</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">50</context>
</context-group>
</trans-unit>
<trans-unit id="35cf4cdcedc8ef3f94b6100e0d86836e31dbb908" datatype="html">
<source>Force autoplay</source>
<target state="translated">Memaksa pemutaran otomatis</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">218</context>
</context-group>
<note priority="1" from="description">Force autoplay setting</note>
</trans-unit>
<trans-unit id="2361a4f76caaa4574803fbcdca8b0a47c91cc7ed" datatype="html">
<source>Task finished</source>
<target state="translated">Tugas selesai</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">360</context>
</context-group>
<note priority="1" from="description">Task finished</note>
</trans-unit>
<trans-unit id="9e766e11a9de375907aaf566897ecc6dac393ebc" datatype="html">
<source>Webhook URL</source>
<target state="translated">URL webhook</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">366</context>
</context-group>
<note priority="1" from="description">webhook URL</note>
</trans-unit>
<trans-unit id="3264d82792954815be755b3da01e2625458711dc" datatype="html">
<source>Discord Webhook URL</source>
<target state="translated">URL Webhook Discord</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">373</context>
</context-group>
<note priority="1" from="description">Discord Webhook URL</note>
</trans-unit>
<trans-unit id="0cfc9cfe7cd8ea14bc053693b28872da739af02c" datatype="html">
<source>See docs here.</source>
<target state="translated">Lihat dokumen di sini.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">375</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">382</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">392</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">402</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">409</context>
</context-group>
<note priority="1" from="description">Discord API setting hint</note>
</trans-unit>
<trans-unit id="8456659390937171831" datatype="html">
<source>Show error</source>
<target state="translated">Tampilkan kesalahan</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">56</context>
</context-group>
</trans-unit>
<trans-unit id="c40370dc182b5e4333828b70f7478bde58bb5cfe" datatype="html">
<source>Enable notifications</source>
<target state="translated">Aktifkan notifikasi</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">349</context>
</context-group>
<note priority="1" from="description">Enable notifications setting</note>
</trans-unit>
<trans-unit id="c5dc5fbcce45e9b1530e2a5c2baa8ebe722aef4c" datatype="html">
<source>Download complete</source>
<target state="translated">Unduh selesai</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">358</context>
</context-group>
<note priority="1" from="description">Download complete</note>
</trans-unit>
<trans-unit id="b770c48628d98cb4633d6a17e3f0ba0265376af5" datatype="html">
<source>Gotify app token</source>
<target state="translated">Token aplikasi Gotify</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">407</context>
</context-group>
<note priority="1" from="description">Gotify app token</note>
</trans-unit>
<trans-unit id="eeb0ba2e4743901d8f5eebd9a3529aa1f236c608" datatype="html">
<source>Create bot here.</source>
<target state="translated">Buat bot di sini.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">419</context>
</context-group>
<note priority="1" from="description">Telegram bot create link</note>
</trans-unit>
<trans-unit id="3e420c675b8f3f3702576d52e8bb6e8e1d3feda0" datatype="html">
<source>How do I get the chat ID?</source>
<target state="translated">Bagaimana cara mendapatkan ID obrolan?</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">426</context>
</context-group>
<note priority="1" from="description">Telegram chat ID help</note>
</trans-unit>
<trans-unit id="6785427850041119037" datatype="html">
<source>Delete category</source>
<target state="translated">Hapus kategori</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">158</context>
</context-group>
</trans-unit>
<trans-unit id="7332320960988475089" datatype="html">
<source>Successfully deleted <x id="category name" equiv-text="category['name']"/>!</source>
<target state="translated">Berhasil menghapus <x id="category name" equiv-text="category['name']"/>!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">168</context>
</context-group>
</trans-unit>
<trans-unit id="3371159074051387771" datatype="html">
<source>Failed to delete <x id="category name" equiv-text="category['name']"/>!</source>
<target state="translated">Gagal menghapus <x id="category name" equiv-text="category['name']"/>!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">172</context>
</context-group>
</trans-unit>
<trans-unit id="2e076ff9866213d0815961c494aa48b177046b9d" datatype="html">
<source>Telegram bot token</source>
<target state="translated">Token bot Telegram</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">417</context>
</context-group>
<note priority="1" from="description">Telegram bot token</note>
</trans-unit>
<trans-unit id="144e1a21ebe8fa238f88d2ac27515ed711cfc9a0" datatype="html">
<source>Telegram chat ID</source>
<target state="translated">ID obrolan Telegram</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">424</context>
</context-group>
<note priority="1" from="description">Telegram chat ID</note>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -1432,7 +1432,7 @@
</trans-unit> </trans-unit>
<trans-unit id="994363f08f9fbfa3b3994ff7b35c6904fdff18d8" datatype="html"> <trans-unit id="994363f08f9fbfa3b3994ff7b35c6904fdff18d8" datatype="html">
<source>Profile</source> <source>Profile</source>
<target state="translated">个人资料</target> <target state="translated">资料</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">app/app.component.html</context> <context context-type="sourcefile">app/app.component.html</context>
<context context-type="linenumber">19</context> <context context-type="linenumber">19</context>
@@ -5015,131 +5015,6 @@
</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>
<trans-unit id="9042260521669277115" datatype="html">
<source>Pause</source>
<target state="translated">暂停</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">68</context>
</context-group>
</trans-unit>
<trans-unit id="b6399391e706e2d7b7b7880eb5630e4e6f49728c" datatype="html">
<source>Side</source>
<target state="translated">侧边栏</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html</context>
<context context-type="linenumber">35,37</context>
</context-group>
<note priority="1" from="description">Side</note>
</trans-unit>
<trans-unit id="9a865c2922f5c01899d06c472dba2e5bd63bcff9" datatype="html">
<source>Small</source>
<target state="translated">小</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html</context>
<context context-type="linenumber">50,52</context>
</context-group>
<note priority="1" from="description">Small</note>
</trans-unit>
<trans-unit id="af30e51aa8b67e1133a341ec28359be05150e65c" datatype="html">
<source>No description available.</source>
<target state="translated">没有说明。</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/player/player.component.html</context>
<context context-type="linenumber">25,27</context>
</context-group>
<note priority="1" from="description">No description</note>
</trans-unit>
<trans-unit id="338b44701a53ce3ef2f36abfb56f89c3edfa9eab" datatype="html">
<source>Over</source>
<target state="translated">结束</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html</context>
<context context-type="linenumber">32,34</context>
</context-group>
<note priority="1" from="description">Over</note>
</trans-unit>
<trans-unit id="6080b77234e92ad41bb52653b239c4c4f851317d" datatype="html">
<source>Error</source>
<target state="translated">错误</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.html</context>
<context context-type="linenumber">39</context>
</context-group>
<note priority="1" from="description">Error</note>
</trans-unit>
<trans-unit id="3640026747176198246" datatype="html">
<source>Watch content</source>
<target state="translated">观看内容</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">50</context>
</context-group>
</trans-unit>
<trans-unit id="8456659390937171831" datatype="html">
<source>Show error</source>
<target state="translated">查看错误</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">56</context>
</context-group>
</trans-unit>
<trans-unit id="1236604279860679031" datatype="html">
<source>Restart</source>
<target state="translated">重新开始</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">62</context>
</context-group>
</trans-unit>
<trans-unit id="e0a11fbea353b1ce1131161774e4a3e10bcb99b1" datatype="html">
<source>Large</source>
<target state="translated">大</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html</context>
<context context-type="linenumber">44,46</context>
</context-group>
<note priority="1" from="description">Large</note>
</trans-unit>
<trans-unit id="378c072ce05889c9771718d05106e7685fcd3507" datatype="html">
<source>Medium</source>
<target state="translated">中</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/user-profile-dialog/user-profile-dialog.component.html</context>
<context context-type="linenumber">47,49</context>
</context-group>
<note priority="1" from="description">Medium</note>
</trans-unit>
<trans-unit id="7182974689040833178" datatype="html">
<source>Resume</source>
<target state="translated">恢复</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">74</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">80</context>
</context-group>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -1,9 +0,0 @@
// The file contents for the current environment will overwrite these during build.
// The build system defaults to the dev environment which uses `environment.ts`, but if you do
// `ng build --env=prod` then `environment.prod.ts` will be used instead.
// The list of which env maps to which file can be found in `.angular-cli.json`.
export const environment = {
production: false,
codespaces: true
};

View File

@@ -1,4 +1,3 @@
export const environment = { export const environment = {
production: true, production: true
codespaces: false
}; };

View File

@@ -4,6 +4,5 @@
// The list of which env maps to which file can be found in `.angular-cli.json`. // The list of which env maps to which file can be found in `.angular-cli.json`.
export const environment = { export const environment = {
production: false, production: true
codespaces: false
}; };