Compare commits

..

14 Commits

Author SHA1 Message Date
Tzahi12345
c7b017498f Prevent null userid/username registration 2023-05-25 22:01:24 -04:00
Tzahi12345
eb6584a4d6 Upgraded version to 4.3.2 2023-05-25 21:57:26 -04:00
Tzahi12345
2c61260e0f UI updates (#922)
* Fixed download spinner in player component

* Downloads UI is more mobile friendly (#905)

* Code cleanup

* Fixed size of actions in home screen downloads

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

* Moved personal settings from about dialog to profile dialog

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

* Fixed issue where archive dialog could be accessed from anywhere

* Misc internationalization improvements

* Combined download stage and download progress columns

* Added back loading spinner to download actions

* Adjusted thresholds for consolidating download action buttons

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

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

add && make it err and skipping perm check

* Restore armv7 nightly

* Update entrypoint.sh

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

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

Co-authored-by: yangyangdaji <1504305527@qq.com>
2023-05-25 21:54:04 -04:00
Tzahi12345
c207e56855 Rebuild database task (#900)
* Improved tests for multi-user mode
* Adds task to rebuild database
* Updated subscriptions.js export syntax
* Subscription metadata is now backed up
* Added typing to task key
* Updated api models
* Tasks actions styling update
2023-05-23 22:37:25 -04:00
Tzahi12345
441131e930 Merge pull request #917 from Tzahi12345/archive-count-fix
Subscription temp archive fix
2023-05-23 22:35:49 -04:00
Tzahi12345
84c2b2769b Merge pull request #890 from martadinata666/js-generate
docker : reduce build time and size
2023-05-21 00:35:28 -04:00
Isaac Abadi
e145c9c992 Updated fetch-twitchdownloader.sh to get the latest release for the specific arch 2023-05-20 19:32:00 -06:00
Dedy Martadinata S
2adbc0a02c Update fetch-twitchdownloader.sh 2023-05-20 11:35:12 +07:00
Dedy Martadinata S
fe95f04c18 Update Dockerfile 2023-05-20 11:30:24 +07:00
Dedy Martadinata S
9b3816afce Update Dockerfile 2023-05-20 11:25:16 +07:00
Dedy Martadinata S
07874d9241 Revert 142d708ee3
It become fail to set anything
2023-05-20 11:13:09 +07:00
Dedy Martadinata S
9fa1aab1e5 Update Dockerfile, Update CI PR, use scripts to download twitchdownloader 2023-05-12 14:46:27 +07:00
57 changed files with 1068 additions and 725 deletions

View File

@@ -32,7 +32,7 @@ jobs:
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64/v8
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
#platforms: linux/amd64
push: false
tags: tzahi12345/youtubedl-material:nightly-pr
tags: tzahi12345/youtubedl-material:nightly-pr

View File

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

View File

@@ -1,15 +1,17 @@
# Fetching our ffmpeg
FROM ubuntu:22.04 AS ffmpeg
# Fetching our utils
FROM ubuntu:22.04 AS utils
ENV DEBIAN_FRONTEND=noninteractive
# Use script due local build compability
COPY docker-utils/ffmpeg-fetch.sh .
RUN chmod +x ffmpeg-fetch.sh
COPY docker-utils/*.sh .
RUN chmod +x *.sh
RUN sh ./ffmpeg-fetch.sh
RUN sh ./fetch-twitchdownloader.sh
# Create our Ubuntu 22.04 with node 16.14.2 (that specific version is required as per: https://stackoverflow.com/a/72855258/8088021)
# Go to 20.04
FROM ubuntu:20.04 AS base
FROM ubuntu:22.04 AS base
ARG TARGETPLATFORM
ARG DEBIAN_FRONTEND=noninteractive
ENV UID=1000
ENV GID=1000
@@ -17,19 +19,30 @@ ENV USER=youtube
ENV NO_UPDATE_NOTIFIER=true
ENV PM2_HOME=/app/pm2
ENV ALLOW_CONFIG_MUTATIONS=true
# Directy fetch specific version
## https://deb.nodesource.com/node_16.x/pool/main/n/nodejs/nodejs_16.14.2-deb-1nodesource1_amd64.deb
RUN groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
apt update && \
apt install -y --no-install-recommends curl ca-certificates tzdata && \
curl -fsSL https://deb.nodesource.com/setup_16.x | bash - && \
apt install -y --no-install-recommends nodejs && \
npm -g install npm n && \
n 16.14.2 && \
apt install -y --no-install-recommends curl ca-certificates tzdata libicu70 && \
apt clean && \
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;
# Build frontend
FROM base as frontend
ARG BUILDPLATFORM
FROM --platform=${BUILDPLATFORM} node:16 as frontend
RUN npm install -g @angular/cli
WORKDIR /build
COPY [ "package.json", "package-lock.json", "angular.json", "tsconfig.json", "/build/" ]
@@ -49,32 +62,35 @@ RUN npm config set strict-ssl false && \
npm install --prod && \
ls -al
FROM base as python
WORKDIR /app
COPY docker-utils/GetTwitchDownloader.py .
RUN apt update && \
apt install -y --no-install-recommends python3-minimal python-is-python3 python3-pip && \
apt clean && \
rm -rf /var/lib/apt/lists/*
RUN pip install PyGithub requests
RUN python GetTwitchDownloader.py
#FROM base as python
# armv7 need build from source
#WORKDIR /app
#COPY docker-utils/GetTwitchDownloader.py .
#RUN apt update && \
# apt install -y --no-install-recommends python3-minimal python-is-python3 python3-pip python3-dev build-essential libffi-dev && \
# apt clean && \
# rm -rf /var/lib/apt/lists/*
#RUN pip install PyGithub requests
#RUN python GetTwitchDownloader.py
# Final image
FROM base
RUN npm install -g pm2 && \
apt update && \
apt install -y --no-install-recommends gosu python3-minimal python-is-python3 python3-pip atomicparsley build-essential && \
pip install pycryptodomex && \
apt remove -y --purge build-essential && \
apt autoremove -y --purge && \
apt clean && \
rm -rf /var/lib/apt/lists/*
RUN pip install pycryptodomex
WORKDIR /app
# User 1000 already exist from base image
COPY --chown=$UID:$GID --from=ffmpeg [ "/usr/local/bin/ffmpeg", "/usr/local/bin/ffmpeg" ]
COPY --chown=$UID:$GID --from=ffmpeg [ "/usr/local/bin/ffprobe", "/usr/local/bin/ffprobe" ]
COPY --chown=$UID:$GID --from=utils [ "/usr/local/bin/ffmpeg", "/usr/local/bin/ffmpeg" ]
COPY --chown=$UID:$GID --from=utils [ "/usr/local/bin/ffprobe", "/usr/local/bin/ffprobe" ]
COPY --chown=$UID:$GID --from=utils [ "/usr/local/bin/TwitchDownloaderCLI", "/usr/local/bin/TwitchDownloaderCLI"]
COPY --chown=$UID:$GID --from=backend ["/app/","/app/"]
COPY --chown=$UID:$GID --from=frontend [ "/build/backend/public/", "/app/public/" ]
COPY --chown=$UID:$GID --from=python ["/app/TwitchDownloaderCLI","/usr/local/bin/TwitchDownloaderCLI"]
RUN chown $UID:$GID .
#COPY --chown=$UID:$GID --from=python ["/app/TwitchDownloaderCLI","/usr/local/bin/TwitchDownloaderCLI"]
RUN chmod +x /app/fix-scripts/*.sh
# Add some persistence data
#VOLUME ["/app/appdata"]

View File

@@ -1758,14 +1758,14 @@ components:
type: object
properties:
task_key:
type: string
$ref: '#/components/schemas/TaskType'
required:
- task_key
UpdateTaskScheduleRequest:
type: object
properties:
task_key:
type: string
$ref: '#/components/schemas/TaskType'
new_schedule:
$ref: '#/components/schemas/Schedule'
required:
@@ -1775,7 +1775,7 @@ components:
type: object
properties:
task_key:
type: string
$ref: '#/components/schemas/TaskType'
new_data:
type: object
required:
@@ -1785,7 +1785,7 @@ components:
type: object
properties:
task_key:
type: string
$ref: '#/components/schemas/TaskType'
new_options:
type: object
required:
@@ -2726,7 +2726,7 @@ components:
type: object
properties:
key:
type: string
$ref: '#/components/schemas/TaskType'
title:
type: string
last_ran:
@@ -2745,6 +2745,17 @@ components:
$ref: '#/components/schemas/Schedule'
options:
type: object
TaskType:
type: string
enum:
- backup_local_db
- missing_files_check
- missing_db_records
- duplicate_files_check
- youtubedl_update_check
- delete_old_files
- import_legacy_archives
- rebuild_database
Schedule:
required:
- type

View File

@@ -535,6 +535,7 @@ async function loadConfig() {
if (allowSubscriptions) {
// set downloading to false
let subscriptions = await subscriptions_api.getAllSubscriptions();
subscriptions.forEach(async sub => subscriptions_api.writeSubscriptionMetadata(sub));
subscriptions_api.updateSubscriptionPropertyMultiple(subscriptions, {downloading: false});
// runs initially, then runs every ${subscriptionCheckInterval} seconds
const watchSubscriptionsInterval = function() {
@@ -1928,9 +1929,38 @@ app.post('/api/clearAllLogs', optionalJwt, async function(req, res) {
// user authentication
app.post('/api/auth/register'
, optionalJwt
, auth_api.registerUser);
app.post('/api/auth/register', optionalJwt, async (req, res) => {
const userid = req.body.userid;
const username = req.body.username;
const plaintextPassword = req.body.password;
if (userid !== 'admin' && !config_api.getConfigItem('ytdl_allow_registration') && !req.isAuthenticated() && (!req.user || !exports.userHasPermission(req.user.uid, 'settings'))) {
logger.error(`Registration failed for user ${userid}. Registration is disabled.`);
res.sendStatus(409);
return;
}
if (plaintextPassword === "") {
logger.error(`Registration failed for user ${userid}. A password must be provided.`);
res.sendStatus(409);
return;
}
if (!userid || !username) {
logger.error(`Registration failed for user ${userid}. Username or userid is invalid.`);
}
const new_user = await auth_api.registerUser(userid, username, plaintextPassword);
if (!new_user) {
res.sendStatus(409);
return;
}
res.send({
user: new_user
});
});
app.post('/api/auth/login'
, auth_api.passport.authenticate(['local', 'ldapauth'], {})
, auth_api.generateJWT
@@ -1982,18 +2012,7 @@ app.post('/api/updateUser', optionalJwt, async (req, res) => {
app.post('/api/deleteUser', optionalJwt, async (req, res) => {
let uid = req.body.uid;
try {
let success = false;
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const user_folder = path.join(__dirname, usersFileFolder, uid);
const user_db_obj = await db_api.getRecord('users', {uid: uid});
if (user_db_obj) {
// user exists, let's delete
await fs.remove(user_folder);
await db_api.removeRecord('users', {uid: uid});
success = true;
} else {
logger.error(`Could not find user with uid ${uid}`);
}
const success = await auth_api.deleteUser(uid);
res.send({success: success});
} catch (err) {
logger.error(err);

View File

@@ -1,11 +1,13 @@
const config_api = require('../config');
const consts = require('../consts');
const CONSTS = require('../consts');
const logger = require('../logger');
const db_api = require('../db');
const jwt = require('jsonwebtoken');
const { uuid } = require('uuidv4');
const bcrypt = require('bcryptjs');
const fs = require('fs-extra');
const path = require('path');
var LocalStrategy = require('passport-local').Strategy;
var LdapStrategy = require('passport-ldapauth');
@@ -16,7 +18,7 @@ var JwtStrategy = require('passport-jwt').Strategy,
let SERVER_SECRET = null;
let JWT_EXPIRATION = null;
let opts = null;
let saltRounds = null;
let saltRounds = 10;
exports.initialize = function () {
/*************************
@@ -31,8 +33,6 @@ exports.initialize = function () {
});
}
saltRounds = 10;
// Sometimes this value is not properly typed: https://github.com/Tzahi12345/YoutubeDL-Material/issues/813
JWT_EXPIRATION = config_api.getConfigItem('ytdl_jwt_expiration');
if (!(+JWT_EXPIRATION)) {
@@ -68,7 +68,7 @@ exports.initialize = function () {
const setupRoles = async () => {
const required_roles = {
admin: {
permissions: consts.AVAILABLE_PERMISSIONS
permissions: CONSTS.AVAILABLE_PERMISSIONS
},
user: {
permissions: [
@@ -106,55 +106,41 @@ exports.passport.deserializeUser(function(user, done) {
/***************************************
* Register user with hashed password
**************************************/
exports.registerUser = async function(req, res) {
var userid = req.body.userid;
var username = req.body.username;
var plaintextPassword = req.body.password;
if (userid !== 'admin' && !config_api.getConfigItem('ytdl_allow_registration') && !req.isAuthenticated() && (!req.user || !exports.userHasPermission(req.user.uid, 'settings'))) {
res.sendStatus(409);
logger.error(`Registration failed for user ${userid}. Registration is disabled.`);
return;
exports.registerUser = async (userid, username, plaintextPassword) => {
const hash = await bcrypt.hash(plaintextPassword, saltRounds);
const new_user = generateUserObject(userid, username, hash);
// check if user exists
if (await db_api.getRecord('users', {uid: userid})) {
// user id is taken!
logger.error('Registration failed: UID is already taken!');
return null;
} else if (await db_api.getRecord('users', {name: username})) {
// user name is taken!
logger.error('Registration failed: User name is already taken!');
return null;
} else {
// add to db
await db_api.insertRecordIntoTable('users', new_user);
logger.verbose(`New user created: ${new_user.name}`);
return new_user;
}
}
if (plaintextPassword === "") {
res.sendStatus(400);
logger.error(`Registration failed for user ${userid}. A password must be provided.`);
return;
exports.deleteUser = async (uid) => {
let success = false;
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const user_folder = path.join(__dirname, usersFileFolder, uid);
const user_db_obj = await db_api.getRecord('users', {uid: uid});
if (user_db_obj) {
// user exists, let's delete
await fs.remove(user_folder);
await db_api.removeRecord('users', {uid: uid});
success = true;
} else {
logger.error(`Could not find user with uid ${uid}`);
}
bcrypt.hash(plaintextPassword, saltRounds)
.then(async function(hash) {
let new_user = generateUserObject(userid, username, hash);
// check if user exists
if (await db_api.getRecord('users', {uid: userid})) {
// user id is taken!
logger.error('Registration failed: UID is already taken!');
res.status(409).send('UID is already taken!');
} else if (await db_api.getRecord('users', {name: username})) {
// user name is taken!
logger.error('Registration failed: User name is already taken!');
res.status(409).send('User name is already taken!');
} else {
// add to db
await db_api.insertRecordIntoTable('users', new_user);
logger.verbose(`New user created: ${new_user.name}`);
res.send({
user: new_user
});
}
})
.then(function(result) {
})
.catch(function(err) {
logger.error(err);
if( err.code == 'ER_DUP_ENTRY' ) {
res.status(409).send('UserId already taken');
} else {
res.sendStatus(409);
}
});
return success;
}
/***************************************
@@ -235,7 +221,7 @@ exports.returnAuthResponse = async function(req, res) {
user: req.user,
token: req.token,
permissions: await exports.userPermissions(req.user.uid),
available_permissions: consts['AVAILABLE_PERMISSIONS']
available_permissions: CONSTS.AVAILABLE_PERMISSIONS
});
}
@@ -319,7 +305,7 @@ exports.getUserVideos = async function(user_uid, type) {
}
exports.getUserVideo = async function(user_uid, file_uid, requireSharing = false) {
let file = await db_api.getRecord('files', {file_uid: file_uid});
let file = await db_api.getRecord('files', {uid: file_uid});
// prevent unauthorized users from accessing the file info
if (file && !file['sharingEnabled'] && requireSharing) file = null;
@@ -406,8 +392,8 @@ exports.userPermissions = async function(user_uid) {
const role_obj = await db_api.getRecord('roles', {key: role});
const role_permissions = role_obj['permissions'];
for (let i = 0; i < consts['AVAILABLE_PERMISSIONS'].length; i++) {
let permission = consts['AVAILABLE_PERMISSIONS'][i];
for (let i = 0; i < CONSTS.AVAILABLE_PERMISSIONS.length; i++) {
let permission = CONSTS.AVAILABLE_PERMISSIONS[i];
const user_has_explicit_permission = user_obj['permissions'].includes(permission);
const permission_in_overrides = user_obj['permission_overrides'].includes(permission);

View File

@@ -347,6 +347,8 @@ const YTDL_ARGS_WITH_VALUES = [
'--convert-subs'
];
exports.SUBSCRIPTION_BACKUP_PATH = 'subscription_backup.json'
// we're using a Set here for performance
exports.YTDL_ARGS_WITH_VALUES = new Set(YTDL_ARGS_WITH_VALUES);

View File

@@ -1,17 +1,7 @@
#!/bin/sh
#!/bin/bash
set -eu
CMD="npm start && pm2 start"
# if the first arg starts with "-" pass it to program
if [ "${1#-}" != "$1" ]; then
set -- "$CMD" "$@"
fi
# chown current working directory to current user
if [ "$*" = "$CMD" ] && [ "$(id -u)" = "0" ]; then
find . \! -user "$UID" -exec chown "$UID:$GID" '{}' + || echo "WARNING! Could not change directory ownership. If you manage permissions externally this is fine, otherwise you may experience issues when downloading or deleting videos."
exec gosu "$UID:$GID" "$0" "$@"
fi
exec "$@"
echo "[entrypoint] setup permission, this may take a while"
find . \! -user "$UID" -exec chown "$UID:$GID" '{}' + || echo "WARNING! Could not change directory ownership. If you manage permissions externally this is fine, otherwise you may experience issues when downloading or deleting videos."
exec gosu "$UID:$GID" "$@"

View File

@@ -6,20 +6,21 @@ const config_api = require('./config');
const archive_api = require('./archive');
const utils = require('./utils');
const logger = require('./logger');
const CONSTS = require('./consts');
const debugMode = process.env.YTDL_MODE === 'debug';
const db_api = require('./db');
const downloader_api = require('./downloader');
async function subscribe(sub, user_uid = null) {
exports.subscribe = async (sub, user_uid = null, skip_get_info = false) => {
const result_obj = {
success: false,
error: ''
};
return new Promise(async resolve => {
// sub should just have url and name. here we will get isPlaylist and path
sub.isPlaylist = sub.url.includes('playlist');
sub.isPlaylist = sub.isPlaylist || sub.url.includes('playlist');
sub.videos = [];
let url_exists = !!(await db_api.getRecord('subscriptions', {url: sub.url, user_uid: user_uid}));
@@ -34,10 +35,11 @@ async function subscribe(sub, user_uid = null) {
sub['user_uid'] = user_uid ? user_uid : undefined;
await db_api.insertRecordIntoTable('subscriptions', sub);
let success = await getSubscriptionInfo(sub);
let success = skip_get_info ? true : await getSubscriptionInfo(sub);
exports.writeSubscriptionMetadata(sub);
if (success) {
getVideosForSub(sub, user_uid);
if (!sub.paused) exports.getVideosForSub(sub, user_uid);
} else {
logger.error('Subscribe: Failed to get subscription info. Subscribe failed.')
}
@@ -109,7 +111,7 @@ async function getSubscriptionInfo(sub) {
});
}
async function unsubscribe(sub, deleteMode, user_uid = null) {
exports.unsubscribe = async (sub, deleteMode, user_uid = null) => {
let basePath = null;
if (user_uid)
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
@@ -148,7 +150,7 @@ async function unsubscribe(sub, deleteMode, user_uid = null) {
await db_api.removeAllRecords('archives', {sub_id: sub.id});
}
async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null, user_uid = null) {
exports.deleteSubscriptionFile = async (sub, file, deleteForever, file_uid = null, user_uid = null) => {
if (typeof sub === 'string') {
// TODO: fix bad workaround where sub is a sub_id
sub = await db_api.getRecord('subscriptions', {sub_id: sub});
@@ -216,8 +218,8 @@ async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null,
}
}
async function getVideosForSub(sub, user_uid = null) {
const latest_sub_obj = await getSubscription(sub.id);
exports.getVideosForSub = async (sub, user_uid = null) => {
const latest_sub_obj = await exports.getSubscription(sub.id);
if (!latest_sub_obj || latest_sub_obj['downloading']) {
return false;
}
@@ -305,7 +307,7 @@ async function handleOutputJSON(output, sub, user_uid) {
}
const files_to_download = await getFilesToDownload(sub, output_jsons);
const base_download_options = generateOptionsForSubscriptionDownload(sub, user_uid);
const base_download_options = exports.generateOptionsForSubscriptionDownload(sub, user_uid);
for (let j = 0; j < files_to_download.length; j++) {
const file_to_download = files_to_download[j];
@@ -316,7 +318,7 @@ async function handleOutputJSON(output, sub, user_uid) {
return files_to_download;
}
function generateOptionsForSubscriptionDownload(sub, user_uid) {
exports.generateOptionsForSubscriptionDownload = (sub, user_uid) => {
let basePath = null;
if (user_uid)
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
@@ -443,35 +445,36 @@ async function getFilesToDownload(sub, output_jsons) {
}
async function getSubscriptions(user_uid = null) {
exports.getSubscriptions = async (user_uid = null) => {
return await db_api.getRecords('subscriptions', {user_uid: user_uid});
}
async function getAllSubscriptions() {
exports.getAllSubscriptions = async () => {
const all_subs = await db_api.getRecords('subscriptions');
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
return all_subs.filter(sub => !!(sub.user_uid) === !!multiUserMode);
}
async function getSubscription(subID) {
exports.getSubscription = async (subID) => {
// stringify and parse because we may override the 'downloading' property
const sub = JSON.parse(JSON.stringify(await db_api.getRecord('subscriptions', {id: subID})));
// now with the download_queue, we may need to override 'downloading'
const current_downloads = await db_api.getRecords('download_queue', {running: true, sub_id: sub.id}, true);
const current_downloads = await db_api.getRecords('download_queue', {running: true, sub_id: subID}, true);
if (!sub['downloading']) sub['downloading'] = current_downloads > 0;
return sub;
}
async function getSubscriptionByName(subName, user_uid = null) {
exports.getSubscriptionByName = async (subName, user_uid = null) => {
return await db_api.getRecord('subscriptions', {name: subName, user_uid: user_uid});
}
async function updateSubscription(sub) {
exports.updateSubscription = async (sub) => {
await db_api.updateRecord('subscriptions', {id: sub.id}, sub);
exports.writeSubscriptionMetadata(sub);
return true;
}
async function updateSubscriptionPropertyMultiple(subs, assignment_obj) {
exports.updateSubscriptionPropertyMultiple = async (subs, assignment_obj) => {
subs.forEach(async sub => {
await updateSubscriptionProperty(sub, assignment_obj);
});
@@ -483,6 +486,14 @@ async function updateSubscriptionProperty(sub, assignment_obj) {
return true;
}
exports.writeSubscriptionMetadata = (sub) => {
let basePath = sub.user_uid ? path.join(config_api.getConfigItem('ytdl_users_base_path'), sub.user_uid, 'subscriptions')
: config_api.getConfigItem('ytdl_subscriptions_base_path');
const appendedBasePath = getAppendedBasePath(sub, basePath);
const metadata_path = path.join(appendedBasePath, CONSTS.SUBSCRIPTION_BACKUP_PATH);
fs.writeJSONSync(metadata_path, sub);
}
async function setFreshUploads(sub) {
const sub_files = await db_api.getRecords('files', {sub_id: sub.id});
if (!sub_files) return;
@@ -537,17 +548,3 @@ async function checkVideoIfBetterExists(file_obj, sub, user_uid) {
function getAppendedBasePath(sub, base_path) {
return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name);
}
module.exports = {
getSubscription : getSubscription,
getSubscriptionByName : getSubscriptionByName,
getSubscriptions : getSubscriptions,
getAllSubscriptions : getAllSubscriptions,
updateSubscription : updateSubscription,
subscribe : subscribe,
unsubscribe : unsubscribe,
deleteSubscriptionFile : deleteSubscriptionFile,
getVideosForSub : getVideosForSub,
updateSubscriptionPropertyMultiple : updateSubscriptionPropertyMultiple,
generateOptionsForSubscriptionDownload: generateOptionsForSubscriptionDownload
}

View File

@@ -3,10 +3,17 @@ const notifications_api = require('./notifications');
const youtubedl_api = require('./youtube-dl');
const archive_api = require('./archive');
const files_api = require('./files');
const subscriptions_api = require('./subscriptions');
const config_api = require('./config');
const auth_api = require('./authentication/auth');
const utils = require('./utils');
const logger = require('./logger');
const CONSTS = require('./consts');
const fs = require('fs-extra');
const logger = require('./logger');
const path = require('path');
const scheduler = require('node-schedule');
const { uuid } = require('uuidv4');
const TASKS = {
backup_local_db: {
@@ -47,6 +54,11 @@ const TASKS = {
run: archive_api.importArchives,
title: 'Import legacy archives',
job: null
},
rebuild_database: {
run: rebuildDB,
title: 'Rebuild database',
job: null
}
}
@@ -265,4 +277,68 @@ async function autoDeleteFiles(data) {
}
}
async function rebuildDB() {
await db_api.backupDB();
let subs_to_add = await guessSubscriptions(false);
subs_to_add = subs_to_add.concat(await guessSubscriptions(true));
const users_to_add = await guessUsers();
for (const user_to_add of users_to_add) {
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const user_exists = await db_api.getRecord('users', {uid: user_to_add});
if (!user_exists) {
await auth_api.registerUser(user_to_add, user_to_add, 'password');
logger.info(`Regenerated user ${user_to_add}`);
}
const user_channel_subs = await guessSubscriptions(false, path.join(usersFileFolder, user_to_add), user_to_add);
const user_playlist_subs = await guessSubscriptions(true, path.join(usersFileFolder, user_to_add), user_to_add);
subs_to_add = subs_to_add.concat(user_channel_subs, user_playlist_subs);
}
for (const sub_to_add of subs_to_add) {
const sub_exists = !!(await subscriptions_api.getSubscriptionByName(sub_to_add['name'], sub_to_add['user_uid']));
// TODO: we shouldn't be creating this here
const new_sub = Object.assign({}, sub_to_add, {paused: true});
if (!sub_exists) {
await subscriptions_api.subscribe(new_sub, sub_to_add['user_uid'], true);
logger.info(`Regenerated subscription ${sub_to_add['name']}`);
}
}
logger.info(`Importing unregistered files`);
await files_api.importUnregisteredFiles();
}
const guessUsers = async () => {
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const userPaths = await utils.getDirectoriesInDirectory(usersFileFolder);
return userPaths.map(userPath => path.basename(userPath));
}
const guessSubscriptions = async (isPlaylist, basePath = null) => {
const guessed_subs = [];
const subscriptionsFileFolder = config_api.getConfigItem('ytdl_subscriptions_base_path');
const subsSubPath = basePath ? path.join(basePath, 'subscriptions') : subscriptionsFileFolder;
const subsPath = path.join(subsSubPath, isPlaylist ? 'playlists' : 'channels');
const subs = await utils.getDirectoriesInDirectory(subsPath);
for (const subPath of subs) {
const sub_backup_path = path.join(subPath, CONSTS.SUBSCRIPTION_BACKUP_PATH);
if (!fs.existsSync(sub_backup_path)) continue;
try {
const sub_backup = fs.readJSONSync(sub_backup_path)
delete sub_backup['_id'];
guessed_subs.push(sub_backup);
} catch(err) {
logger.warn(`Failed to reimport subscription in path ${subPath}`)
logger.warn(err);
}
}
return guessed_subs;
}
exports.TASKS = TASKS;

View File

@@ -337,16 +337,22 @@ describe('Database', async function() {
});
describe('Multi User', async function() {
let user = null;
const user_to_test = 'admin';
const sub_to_test = 'dc834388-3454-41bf-a618-e11cb8c7de1c';
const playlist_to_test = 'ysabVZz4x';
const user_to_test = 'test_user';
const user_password = 'test_pass';
const sub_to_test = '';
const playlist_to_test = '';
beforeEach(async function() {
await db_api.connectToDB();
user = await auth_api.login('admin', 'pass');
await auth_api.deleteUser(user_to_test);
});
describe('Authentication', function() {
it('login', async function() {
describe('Basic', function() {
it('Register', async function() {
const user = await auth_api.registerUser(user_to_test, user_to_test, user_password);
assert(user);
});
it('Login', async function() {
await auth_api.registerUser(user_to_test, user_to_test, user_password);
const user = await auth_api.login(user_to_test, user_password);
assert(user);
});
});
@@ -362,14 +368,14 @@ describe('Multi User', async function() {
});
it('Video access - disallowed', async function() {
await db_api.setVideoProperty(video_to_test, {sharingEnabled: false}, user_to_test);
const video_obj = auth_api.getUserVideo('admin', video_to_test, true);
await db_api.setVideoProperty(video_to_test, {sharingEnabled: false});
const video_obj = auth_api.getUserVideo(user_to_test, video_to_test, true);
assert(!video_obj);
});
it('Video access - allowed', async function() {
await db_api.setVideoProperty(video_to_test, {sharingEnabled: true}, user_to_test);
const video_obj = auth_api.getUserVideo('admin', video_to_test, true);
const video_obj = auth_api.getUserVideo(user_to_test, video_to_test, true);
assert(video_obj);
});
});

View File

@@ -519,6 +519,17 @@ exports.convertFlatObjectToNestedObject = (obj) => {
return result;
}
exports.getDirectoriesInDirectory = async (basePath) => {
try {
const files = await fs.readdir(basePath, { withFileTypes: true });
return files
.filter((file) => file.isDirectory())
.map((file) => path.join(basePath, file.name));
} catch (err) {
return [];
}
}
// objects
function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date, description, view_count, height, abr) {

View File

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

View File

@@ -0,0 +1,39 @@
#!/bin/sh
# THANK YOU TALULAH (https://github.com/nottalulah) for your help in figuring this out
# and also optimizing some code with this commit.
# xoxo :D
case $(uname -m) in
x86_64)
ARCH=Linux-x64;;
aarch64)
ARCH=LinuxArm64;;
armhf)
ARCH=LinuxArm;;
armv7)
ARCH=LinuxArm;;
armv7l)
ARCH=LinuxArm;;
*)
echo "Unsupported architecture: $(uname -m)"
exit 1
esac
echo "(INFO) Architecture detected: $ARCH"
echo "(1/5) READY - Install unzip"
apt-get update && apt-get -y install unzip curl jq libicu70
VERSION=$(curl --silent "https://api.github.com/repos/lay295/TwitchDownloader/releases" | jq -r --arg arch "$ARCH" '[.[] | select(.assets | length > 0) | select(.assets[].name | contains("CLI") and contains($arch))] | max_by(.published_at) | .tag_name')
echo "(2/5) DOWNLOAD - Acquire twitchdownloader"
curl -o twitchdownloader.zip \
--connect-timeout 5 \
--max-time 120 \
--retry 5 \
--retry-delay 0 \
--retry-max-time 40 \
-L "https://github.com/lay295/TwitchDownloader/releases/download/$VERSION/TwitchDownloaderCLI-$VERSION-$ARCH.zip"
unzip twitchdownloader.zip
chmod +x TwitchDownloaderCLI
echo "(3/5) Smoke test"
./TwitchDownloaderCLI --help
cp ./TwitchDownloaderCLI /usr/local/bin/TwitchDownloaderCLI

View File

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

View File

@@ -104,6 +104,7 @@ export type { SubscriptionRequestData } from './models/SubscriptionRequestData';
export type { SuccessObject } from './models/SuccessObject';
export type { TableInfo } from './models/TableInfo';
export type { Task } from './models/Task';
export { TaskType } from './models/TaskType';
export type { TestConnectionStringRequest } from './models/TestConnectionStringRequest';
export type { TestConnectionStringResponse } from './models/TestConnectionStringResponse';
export type { TransferDBRequest } from './models/TransferDBRequest';

View File

@@ -3,5 +3,5 @@
/* eslint-disable */
export type Config = {
YoutubeDLMaterial: Record<string, any>;
YoutubeDLMaterial: any;
};

View File

@@ -26,5 +26,5 @@ export type Download = {
user_uid?: string;
sub_id?: string;
sub_name?: string;
prefetched_info?: Record<string, any>;
prefetched_info?: any;
};

View File

@@ -5,6 +5,6 @@
export type GetFileFormatsResponse = {
success: boolean;
result: {
formats?: Array<Record<string, any>>;
formats?: Array<any>;
};
};

View File

@@ -6,5 +6,5 @@ import type { Subscription } from './Subscription';
export type GetSubscriptionResponse = {
subscription: Subscription;
files: Array<Record<string, any>>;
files: Array<any>;
};

View File

@@ -2,6 +2,8 @@
/* tslint:disable */
/* eslint-disable */
import type { TaskType } from './TaskType';
export type GetTaskRequest = {
task_key: string;
task_key: TaskType;
};

View File

@@ -11,6 +11,6 @@ export type Notification = {
user_uid?: string;
action?: Array<NotificationAction>;
read: boolean;
data?: Record<string, any>;
data?: any;
timestamp: number;
};

View File

@@ -15,5 +15,5 @@ export type Subscription = {
timerange?: string;
custom_args?: string;
custom_output?: string;
videos: Array<Record<string, any>>;
videos: Array<any>;
};

View File

@@ -3,16 +3,17 @@
/* eslint-disable */
import type { Schedule } from './Schedule';
import type { TaskType } from './TaskType';
export type Task = {
key: string;
key: TaskType;
title?: string;
last_ran: number;
last_confirmed: number;
running: boolean;
confirming: boolean;
data: Record<string, any>;
data: any;
error: string;
schedule: Schedule;
options?: Record<string, any>;
options?: any;
};

View File

@@ -0,0 +1,14 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export enum TaskType {
BACKUP_LOCAL_DB = 'backup_local_db',
MISSING_FILES_CHECK = 'missing_files_check',
MISSING_DB_RECORDS = 'missing_db_records',
DUPLICATE_FILES_CHECK = 'duplicate_files_check',
YOUTUBEDL_UPDATE_CHECK = 'youtubedl_update_check',
DELETE_OLD_FILES = 'delete_old_files',
IMPORT_LEGACY_ARCHIVES = 'import_legacy_archives',
REBUILD_DATABASE = 'rebuild_database',
}

View File

@@ -10,5 +10,5 @@ export type UpdateFileRequest = {
/**
* Object with fields to update as keys and their new values
*/
change_obj: Record<string, any>;
change_obj: any;
};

View File

@@ -2,7 +2,9 @@
/* tslint:disable */
/* eslint-disable */
import type { TaskType } from './TaskType';
export type UpdateTaskDataRequest = {
task_key: string;
new_data: Record<string, any>;
task_key: TaskType;
new_data: any;
};

View File

@@ -2,7 +2,9 @@
/* tslint:disable */
/* eslint-disable */
import type { TaskType } from './TaskType';
export type UpdateTaskOptionsRequest = {
task_key: string;
new_options: Record<string, any>;
task_key: TaskType;
new_options: any;
};

View File

@@ -3,8 +3,9 @@
/* eslint-disable */
import type { Schedule } from './Schedule';
import type { TaskType } from './TaskType';
export type UpdateTaskScheduleRequest = {
task_key: string;
task_key: TaskType;
new_schedule: Schedule;
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, ViewChild, Input, EventEmitter } from '@angular/core';
import { Component, OnInit, OnDestroy, ViewChild, Input, EventEmitter, HostListener } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { trigger, transition, animateChild, stagger, query, style, animate } from '@angular/animations';
import { Router } from '@angular/router';
@@ -13,31 +13,7 @@ import { Download } from 'api-types';
@Component({
selector: 'app-downloads',
templateUrl: './downloads.component.html',
styleUrls: ['./downloads.component.scss'],
animations: [
// nice stagger effect when showing existing elements
trigger('list', [
transition(':enter', [
// child animation selector + stagger
query('@items',
stagger(100, animateChild()), { optional: true }
)
]),
]),
trigger('items', [
// cubic-bezier for a tiny bouncing feel
transition(':enter', [
style({ transform: 'scale(0.5)', opacity: 0 }),
animate('500ms cubic-bezier(.8,-0.6,0.2,1.5)',
style({ transform: 'scale(1)', opacity: 1 }))
]),
transition(':leave', [
style({ transform: 'scale(1)', opacity: 1, height: '*' }),
animate('1s cubic-bezier(.8,-0.6,0.2,1.5)',
style({ transform: 'scale(0.5)', opacity: 0, height: '0px', margin: '0px' }))
]),
])
],
styleUrls: ['./downloads.component.scss']
})
export class DownloadsComponent implements OnInit, OnDestroy {
@@ -62,13 +38,79 @@ export class DownloadsComponent implements OnInit, OnDestroy {
3: $localize`Complete`
}
displayedColumns: string[] = ['timestamp_start', 'title', 'step_index', 'sub_name', 'percent_complete', 'actions'];
actionsFlex = 2;
minimizeButtons = false;
displayedColumnsBig: string[] = ['timestamp_start', 'title', 'sub_name', 'percent_complete', 'actions'];
displayedColumnsSmall: string[] = ['title', 'percent_complete', 'actions'];
displayedColumns: string[] = this.displayedColumnsBig;
dataSource = null; // new MatTableDataSource<Download>();
// The purpose of this is to reduce code reuse for displaying these actions as icons or in a menu
downloadActions: DownloadAction[] = [
{
tooltip: $localize`Watch content`,
action: (download: Download) => this.watchContent(download),
show: (download: Download) => download.finished && !download.error,
icon: 'smart_display'
},
{
tooltip: $localize`Show error`,
action: (download: Download) => this.showError(download),
show: (download: Download) => download.finished && !!download.error,
icon: 'warning'
},
{
tooltip: $localize`Restart`,
action: (download: Download) => this.restartDownload(download),
show: (download: Download) => download.finished,
icon: 'restart_alt'
},
{
tooltip: $localize`Pause`,
action: (download: Download) => this.pauseDownload(download),
show: (download: Download) => !download.finished && (!download.paused || !download.finished_step),
icon: 'pause',
loading: (download: Download) => download.paused && !download.finished_step
},
{
tooltip: $localize`Resume`,
action: (download: Download) => this.resumeDownload(download),
show: (download: Download) => !download.finished && download.paused && download.finished_step,
icon: 'play_arrow'
},
{
tooltip: $localize`Resume`,
action: (download: Download) => this.resumeDownload(download),
show: (download: Download) => !download.finished && download.paused && download.finished_step,
icon: 'play_arrow'
},
{
tooltip: $localize`Cancel`,
action: (download: Download) => this.cancelDownload(download),
show: (download: Download) => false && !download.finished && !download.paused, // TODO: add possibility to cancel download
icon: 'cancel'
},
{
tooltip: $localize`Clear`,
action: (download: Download) => this.clearDownload(download),
show: (download: Download) => download.finished || download.paused,
icon: 'delete'
}
]
downloads_retrieved = false;
innerWidth: number;
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
@HostListener('window:resize', ['$event'])
onResize(): void {
this.innerWidth = window.innerWidth;
this.recalculateColumns();
}
sort_downloads = (a: Download, b: Download): number => {
const result = b.timestamp_start - a.timestamp_start;
return result;
@@ -77,6 +119,10 @@ export class DownloadsComponent implements OnInit, OnDestroy {
constructor(public postsService: PostsService, private router: Router, private dialog: MatDialog, private clipboard: Clipboard) { }
ngOnInit(): void {
// Remove sub name as it's not necessary for one-off downloads
if (this.uids) this.displayedColumnsBig = this.displayedColumnsBig.filter(col => col !== 'sub_name');
this.innerWidth = window.innerWidth;
this.recalculateColumns();
if (this.postsService.initialized) {
this.getCurrentDownloadsRecurring();
} else {
@@ -164,8 +210,8 @@ export class DownloadsComponent implements OnInit, OnDestroy {
});
}
pauseDownload(download_uid: string): void {
this.postsService.pauseDownload(download_uid).subscribe(res => {
pauseDownload(download: Download): void {
this.postsService.pauseDownload(download['uid']).subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar($localize`Failed to pause download! See server logs for more info.`);
}
@@ -180,8 +226,8 @@ export class DownloadsComponent implements OnInit, OnDestroy {
});
}
resumeDownload(download_uid: string): void {
this.postsService.resumeDownload(download_uid).subscribe(res => {
resumeDownload(download: Download): void {
this.postsService.resumeDownload(download['uid']).subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar($localize`Failed to resume download! See server logs for more info.`);
}
@@ -196,8 +242,8 @@ export class DownloadsComponent implements OnInit, OnDestroy {
});
}
restartDownload(download_uid: string): void {
this.postsService.restartDownload(download_uid).subscribe(res => {
restartDownload(download: Download): void {
this.postsService.restartDownload(download['uid']).subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar($localize`Failed to restart download! See server logs for more info.`);
} else {
@@ -208,16 +254,16 @@ export class DownloadsComponent implements OnInit, OnDestroy {
});
}
cancelDownload(download_uid: string): void {
this.postsService.cancelDownload(download_uid).subscribe(res => {
cancelDownload(download: Download): void {
this.postsService.cancelDownload(download['uid']).subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar($localize`Failed to cancel download! See server logs for more info.`);
}
});
}
clearDownload(download_uid: string): void {
this.postsService.clearDownload(download_uid).subscribe(res => {
clearDownload(download: Download): void {
this.postsService.clearDownload(download['uid']).subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar($localize`Failed to pause download! See server logs for more info.`);
}
@@ -257,6 +303,7 @@ export class DownloadsComponent implements OnInit, OnDestroy {
}
showError(download: Download): void {
console.log(download)
const copyToClipboardEmitter = new EventEmitter<boolean>();
this.dialog.open(ConfirmDialogComponent, {
data: {
@@ -276,4 +323,22 @@ export class DownloadsComponent implements OnInit, OnDestroy {
}
});
}
recalculateColumns() {
if (this.innerWidth < 650) this.displayedColumns = this.displayedColumnsSmall;
else this.displayedColumns = this.displayedColumnsBig;
this.actionsFlex = this.uids || this.innerWidth < 800 ? 1 : 2;
if (this.innerWidth < 800 && !this.uids || this.innerWidth < 1100 && this.uids) this.minimizeButtons = true;
else this.minimizeButtons = false;
}
}
interface DownloadAction {
tooltip: string,
action: (download: Download) => void,
show: (download: Download) => boolean,
icon: string,
loading?: (download: Download) => boolean
}

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ export class NotificationsComponent implements OnInit {
notifications: Notification[] = null;
filtered_notifications: Notification[] = null;
list_height = '65vh';
@Output() notificationCount = new EventEmitter<number>();
@@ -110,6 +111,8 @@ export class NotificationsComponent implements OnInit {
filterNotifications(): void {
this.filtered_notifications = this.notifications.filter(notification => this.selectedFilters.length === 0 || this.selectedFilters.includes(notification.type));
// We need to do this to get the virtual scroll component to have an appropriate height
this.calculateListHeight();
}
selectedFiltersChanged(event: MatChipListboxChange): void {
@@ -117,6 +120,12 @@ export class NotificationsComponent implements OnInit {
this.filterNotifications();
}
calculateListHeight() {
const avgHeight = 166;
const calcHeight = this.filtered_notifications.length * avgHeight;
this.list_height = calcHeight > window.innerHeight*0.65 ? '65vh' : `${calcHeight}px`;
}
originalOrder = (): number => {
return 0;
}

View File

@@ -1,6 +1,6 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { Task } from 'api-types';
import { Task, TaskType } from 'api-types';
import { PostsService } from 'app/posts.services';
@Component({
@@ -9,7 +9,7 @@ import { PostsService } from 'app/posts.services';
styleUrls: ['./task-settings.component.scss']
})
export class TaskSettingsComponent {
task_key: string;
task_key: TaskType;
new_options = {};
task: Task = null;

View File

@@ -49,7 +49,7 @@
<mat-header-cell *matHeaderCellDef> <ng-container i18n="Actions">Actions</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element">
<div class="container">
<div class="row justify-content-center">
<div class="row justify-content-start">
<div *ngIf="element.data?.uids?.length > 0 || (!element.data?.uids && element.data)" class="col-12 mt-2" style="display: flex; justify-content: center;">
<ng-container>
<button (click)="confirmTask(element.key)" [disabled]="element.running || element.confirming" mat-stroked-button>

View File

@@ -7,7 +7,7 @@ import { ConfirmDialogComponent } from 'app/dialogs/confirm-dialog/confirm-dialo
import { RestoreDbDialogComponent } from 'app/dialogs/restore-db-dialog/restore-db-dialog.component';
import { UpdateTaskScheduleDialogComponent } from 'app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component';
import { PostsService } from 'app/posts.services';
import { Task } from 'api-types';
import { Task, TaskType } from 'api-types';
import { TaskSettingsComponent } from '../task-settings/task-settings.component';
import { Clipboard } from '@angular/cdk/clipboard';
@@ -28,6 +28,15 @@ export class TasksComponent implements OnInit {
db_backups = [];
TASKS_TO_REQUIRE_DIALOG: { [key in TaskType]? : {dialogTitle: string, dialogText: string, submitText: string, warnSubmitColor: boolean}} = {
[TaskType.REBUILD_DATABASE]: {
dialogTitle: $localize`Rebuild database`,
dialogText: $localize`Are you sure you want to rebuild the database? All missing users, subscriptions, and files will be reimported. Note that if missing users are detected, they will be created with the password: 'password'. A backup of your current database will be created.`,
submitText: $localize`Rebuild database`,
warnSubmitColor: false
}
}
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
@@ -79,7 +88,29 @@ export class TasksComponent implements OnInit {
});
}
runTask(task_key: string): void {
runTask(task_key: TaskType): void {
const taskToRequireDialog = this.TASKS_TO_REQUIRE_DIALOG[task_key];
if (taskToRequireDialog) {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
dialogTitle: taskToRequireDialog['dialogTitle'],
dialogText: taskToRequireDialog['dialogText'],
submitText: taskToRequireDialog['submitText'],
warnSubmitColor: taskToRequireDialog['warnSubmitColor']
}
});
dialogRef.afterClosed().subscribe(confirmed => {
if (confirmed) {
this._runTask(task_key);
}
});
return;
}
this._runTask(task_key);
}
_runTask(task_key: TaskType): void {
this.postsService.runTask(task_key).subscribe(res => {
this.getTasks();
this.getDBBackups();
@@ -91,7 +122,7 @@ export class TasksComponent implements OnInit {
});
}
confirmTask(task_key: string): void {
confirmTask(task_key: TaskType): void {
this.postsService.confirmTask(task_key).subscribe(res => {
this.getTasks();
if (res['success']) this.postsService.openSnackBar($localize`Successfully confirmed task!`);

View File

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

View File

@@ -35,36 +35,6 @@
<p>
<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>
<mat-divider></mat-divider>
<div style="margin-top: 10px;">
<h5>Personal settings:</h5>
<mat-form-field>
<mat-label i18n="Sidepanel mode">Sidepanel mode</mat-label>
<mat-select [(ngModel)]="sidepanel_mode" (selectionChange)="sidePanelModeChanged($event.value)">
<mat-option value="over">
Over
</mat-option>
<mat-option value="side">
Side
</mat-option>
</mat-select>
</mat-form-field>
<br/>
<mat-form-field>
<mat-label i18n="File card size">File card size</mat-label>
<mat-select [(ngModel)]="card_size" (selectionChange)="cardSizeOptionChanged($event.value)">
<mat-option value="large">
Large
</mat-option>
<mat-option value="medium">
Medium
</mat-option>
<mat-option value="small">
Small
</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
</mat-dialog-content>

View File

@@ -16,8 +16,6 @@ export class AboutDialogComponent implements OnInit {
checking_for_updates = true;
current_version_tag = CURRENT_VERSION;
sidepanel_mode = this.postsService.sidepanel_mode;
card_size = this.postsService.card_size;
constructor(public postsService: PostsService) { }
@@ -31,15 +29,4 @@ export class AboutDialogComponent implements OnInit {
this.latestGithubRelease = res;
});
}
sidePanelModeChanged(new_mode) {
localStorage.setItem('sidepanel_mode', new_mode);
this.postsService.sidepanel_mode = new_mode;
}
cardSizeOptionChanged(new_size) {
localStorage.setItem('card_size', new_size);
this.postsService.card_size = new_size;
}
}

View File

@@ -13,19 +13,52 @@
</div>
<div style="margin-top: 20px;">
</div>
<mat-divider style="margin-bottom: 20px"></mat-divider>
</div>
<div *ngIf="!postsService.isLoggedIn || !postsService.user">
<h5><mat-icon>warn</mat-icon><ng-container i18n="Not logged in notification">You are not logged in.</ng-container></h5>
<button (click)="loginClicked()" mat-raised-button color="primary"><ng-container i18n="Login">Login</ng-container></button>
</div>
<mat-form-field color="accent">
<mat-label><ng-container i18n="Language select label">Language</ng-container></mat-label>
<mat-select (selectionChange)="localeSelectChanged($event.value)" [(value)]="initialLocale">
<mat-option *ngFor="let locale of supported_locales" [value]="locale">
<ng-container *ngIf="all_locales[locale]">
{{all_locales[locale]['nativeName']}}
</ng-container>
</mat-option>
</mat-select>
</mat-form-field>
<br/>
<mat-form-field>
<mat-label i18n="Sidepanel mode">Sidepanel mode</mat-label>
<mat-select [(ngModel)]="sidepanel_mode" (selectionChange)="sidePanelModeChanged($event.value)">
<mat-option i18n="Over" value="over">
Over
</mat-option>
<mat-option i18n="Side" value="side">
Side
</mat-option>
</mat-select>
</mat-form-field>
<br/>
<mat-form-field>
<mat-label i18n="File card size">File card size</mat-label>
<mat-select [(ngModel)]="card_size" (selectionChange)="cardSizeOptionChanged($event.value)">
<mat-option i18n="Large" value="large">
Large
</mat-option>
<mat-option i18n="Medium" value="medium">
Medium
</mat-option>
<mat-option i18n="Small" value="small">
Small
</mat-option>
</mat-select>
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions>
<div style="width: 100%">
<div style="position: relative">
<button mat-stroked-button mat-dialog-close color="primary"><ng-container i18n="Close">Close</ng-container></button>
<button style="position: absolute; right: 0px;" (click)="logoutClicked()" mat-stroked-button color="warn"><ng-container i18n="Logout">Logout</ng-container></button>
<button *ngIf="postsService.isLoggedIn" style="position: absolute; right: 0px;" (click)="logoutClicked()" mat-stroked-button color="warn"><ng-container i18n="Logout">Logout</ng-container></button>
</div>
</div>
</mat-dialog-actions>

View File

@@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { Router } from '@angular/router';
import { MatDialogRef } from '@angular/material/dialog';
import { isoLangs } from './locales_list';
@Component({
selector: 'app-user-profile-dialog',
@@ -10,9 +11,24 @@ import { MatDialogRef } from '@angular/material/dialog';
})
export class UserProfileDialogComponent implements OnInit {
all_locales = isoLangs;
supported_locales = ['en', 'es', 'de', 'fr', 'nl', 'pt', 'it', 'ca', 'cs', 'nb', 'ru', 'zh', 'ko', 'id', 'en-GB'];
initialLocale = localStorage.getItem('locale');
sidepanel_mode = this.postsService.sidepanel_mode;
card_size = this.postsService.card_size;
constructor(public postsService: PostsService, private router: Router, public dialogRef: MatDialogRef<UserProfileDialogComponent>) { }
ngOnInit(): void {
this.postsService.getSupportedLocales().subscribe(res => {
if (res && res['supported_locales']) {
this.supported_locales = ['en', 'en-GB']; // required
this.supported_locales = this.supported_locales.concat(res['supported_locales']);
}
}, err => {
console.error(`Failed to retrieve list of supported languages! You may need to run: 'node src/postbuild.mjs'. Error below:`);
console.error(err);
});
}
loginClicked() {
@@ -25,4 +41,19 @@ export class UserProfileDialogComponent implements OnInit {
this.dialogRef.close();
}
localeSelectChanged(new_val: string): void {
localStorage.setItem('locale', new_val);
this.postsService.openSnackBar($localize`Language successfully changed! Reload to update the page.`)
}
sidePanelModeChanged(new_mode) {
localStorage.setItem('sidepanel_mode', new_mode);
this.postsService.sidepanel_mode = new_mode;
}
cardSizeOptionChanged(new_size) {
localStorage.setItem('card_size', new_size);
this.postsService.card_size = new_size;
}
}

View File

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

View File

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

View File

@@ -22,20 +22,22 @@
</p>
</ng-container>
<ng-container *ngIf="!db_file || !db_file['description']">
<p style="text-align: center;">
<p i18n="No description" style="text-align: center;">
No description available.
</p>
</ng-container>
</div>
<div class="col-2">
<ng-container *ngIf="db_playlist">
<button (click)="downloadContent()" [disabled]="downloading" mat-icon-button><mat-icon>save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="35"></mat-spinner></button>
<span class="buttons" *ngIf="db_playlist">
<button (click)="downloadContent()" [disabled]="downloading" mat-icon-button><mat-icon>save</mat-icon></button>
<mat-spinner *ngIf="downloading" class="spinner" [diameter]="35"></mat-spinner>
<button *ngIf="(!postsService.isLoggedIn || postsService.permissions.includes('sharing')) && !auto" (click)="openShareDialog()" mat-icon-button><mat-icon>share</mat-icon></button>
</ng-container>
<ng-container *ngIf="db_file">
<button (click)="downloadFile()" [disabled]="downloading" mat-icon-button><mat-icon>cloud_download</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="35"></mat-spinner></button>
</span>
<span class="buttons" *ngIf="db_file">
<button (click)="downloadFile()" [disabled]="downloading" mat-icon-button><mat-icon>cloud_download</mat-icon></button>
<mat-spinner *ngIf="downloading" class="spinner" [diameter]="35"></mat-spinner>
<button *ngIf="!postsService.isLoggedIn || postsService.permissions.includes('sharing')" (click)="openShareDialog()" mat-icon-button><mat-icon>share</mat-icon></button>
</ng-container>
</span>
<ng-container *ngIf="db_file || playlist[currentIndex]"></ng-container>
<button (click)="openFileInfoDialog()" *ngIf="db_file || db_playlist" mat-icon-button><mat-icon>info</mat-icon></button>
<button *ngIf="db_file && db_file.url.includes('twitch.tv')" (click)="drawer.toggle()" mat-icon-button><mat-icon>chat</mat-icon></button>
@@ -56,14 +58,6 @@
<app-twitch-chat #twitchchat [db_file]="db_file" [current_timestamp]="api.currentTime" [sub]="subscription"></app-twitch-chat>
</ng-container>
</mat-drawer>
<!-- <div class="update-playlist-button-div" *ngIf="id && playlistChanged()">
<div class="spinner-div">
<mat-spinner *ngIf="playlist_updating" [diameter]="25"></mat-spinner>
</div>
<button color="primary" [disabled]="playlist_updating" (click)="updatePlaylist()" mat-raised-button><ng-container i18n="Playlist save changes button">Save changes</ng-container>&nbsp;<mat-icon>update</mat-icon></button>
</div> -->
</mat-drawer-container>
</div>
</div>

View File

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

View File

@@ -112,9 +112,10 @@ import {
ImportArchiveRequest,
Archive,
Subscription,
RestartDownloadResponse
RestartDownloadResponse,
TaskType
} from '../api-types';
import { isoLangs } from './settings/locales_list';
import { isoLangs } from './dialogs/user-profile-dialog/locales_list';
import { Title } from '@angular/platform-browser';
import { MatDrawerMode } from '@angular/material/sidenav';
@@ -640,32 +641,32 @@ export class PostsService implements CanActivate {
return this.http.post<SuccessObject>(this.path + 'resetTasks', {}, this.httpOptions);
}
getTask(task_key: string) {
getTask(task_key: TaskType) {
const body: GetTaskRequest = {task_key: task_key};
return this.http.post<GetTaskResponse>(this.path + 'getTask', body, this.httpOptions);
}
runTask(task_key: string) {
runTask(task_key: TaskType) {
const body: GetTaskRequest = {task_key: task_key};
return this.http.post<SuccessObject>(this.path + 'runTask', body, this.httpOptions);
}
confirmTask(task_key: string) {
confirmTask(task_key: TaskType) {
const body: GetTaskRequest = {task_key: task_key};
return this.http.post<SuccessObject>(this.path + 'confirmTask', body, this.httpOptions);
}
updateTaskSchedule(task_key: string, schedule: Schedule) {
updateTaskSchedule(task_key: TaskType, schedule: Schedule) {
const body: UpdateTaskScheduleRequest = {task_key: task_key, new_schedule: schedule};
return this.http.post<SuccessObject>(this.path + 'updateTaskSchedule', body, this.httpOptions);
}
updateTaskData(task_key: string, data: any) {
updateTaskData(task_key: TaskType, data: any) {
const body: UpdateTaskDataRequest = {task_key: task_key, new_data: data};
return this.http.post<SuccessObject>(this.path + 'updateTaskData', body, this.httpOptions);
}
updateTaskOptions(task_key: string, options: any) {
updateTaskOptions(task_key: TaskType, options: any) {
const body: UpdateTaskOptionsRequest = {task_key: task_key, new_options: options};
return this.http.post<SuccessObject>(this.path + 'updateTaskOptions', body, this.httpOptions);
}

View File

@@ -78,23 +78,6 @@
</div>
</div>
</div>
<mat-divider></mat-divider>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12 mt-3">
<mat-form-field color="accent">
<mat-label><ng-container i18n="Language select label">Language</ng-container></mat-label>
<mat-select (selectionChange)="localeSelectChanged($event.value)" [(value)]="initialLocale">
<mat-option *ngFor="let locale of supported_locales" [value]="locale">
<ng-container *ngIf="all_locales[locale]">
{{all_locales[locale]['nativeName']}}
</ng-container>
</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
</div>
</ng-template>
</mat-tab>
<!-- Downloader -->

View File

@@ -1,6 +1,5 @@
import { Component, OnInit, EventEmitter } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { isoLangs } from './locales_list';
import { MatSnackBar } from '@angular/material/snack-bar';
import {DomSanitizer} from '@angular/platform-browser';
import { MatDialog } from '@angular/material/dialog';
@@ -22,10 +21,6 @@ import { GenerateRssUrlComponent } from 'app/dialogs/generate-rss-url/generate-r
styleUrls: ['./settings.component.scss']
})
export class SettingsComponent implements OnInit {
all_locales = isoLangs;
supported_locales = ['en', 'es', 'de', 'fr', 'nl', 'pt', 'it', 'ca', 'cs', 'nb', 'ru', 'zh', 'ko', 'id', 'en-GB'];
initialLocale = localStorage.getItem('locale');
initial_config = null;
new_config = null
loading_config = false;
@@ -83,16 +78,6 @@ export class SettingsComponent implements OnInit {
const tab = this.route.snapshot.paramMap.get('tab');
this.tabIndex = tab && this.TAB_TO_INDEX[tab] ? this.TAB_TO_INDEX[tab] : 0;
this.postsService.getSupportedLocales().subscribe(res => {
if (res && res['supported_locales']) {
this.supported_locales = ['en', 'en-GB']; // required
this.supported_locales = this.supported_locales.concat(res['supported_locales']);
}
}, err => {
console.error(`Failed to retrieve list of supported languages! You may need to run: 'node src/postbuild.mjs'. Error below:`);
console.error(err);
});
}
getConfig(): void {
@@ -207,11 +192,6 @@ export class SettingsComponent implements OnInit {
});
}
localeSelectChanged(new_val: string): void {
localStorage.setItem('locale', new_val);
this.postsService.openSnackBar($localize`Language successfully changed! Reload to update the page.`)
}
generateBookmarklet(): void {
this.bookmarksite('YTDL-Material', this.generated_bookmarklet_code);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1432,7 +1432,7 @@
</trans-unit>
<trans-unit id="994363f08f9fbfa3b3994ff7b35c6904fdff18d8" datatype="html">
<source>Profile</source>
<target state="translated">资料</target>
<target state="translated">个人资料</target>
<context-group purpose="location">
<context context-type="sourcefile">app/app.component.html</context>
<context context-type="linenumber">19</context>
@@ -5015,6 +5015,24 @@
</context-group>
<note priority="1" from="description">Use gotify API setting</note>
</trans-unit>
<trans-unit id="7cedb649779673568447b994463b2882c4e0436a" datatype="html">
<source>Slack Webhook URL</source>
<target state="translated">Slack Webhook 网址</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">397</context>
</context-group>
<note priority="1" from="description">Slack Webhook URL</note>
</trans-unit>
<trans-unit id="3264d82792954815be755b3da01e2625458711dc" datatype="html">
<source>Discord Webhook URL</source>
<target state="translated">Discord Webhook 网址</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">390</context>
</context-group>
<note priority="1" from="description">Discord Webhook URL</note>
</trans-unit>
</body>
</file>
</xliff>