Merge pull request #236 from Tzahi12345/categories

Adds rule-based categories
This commit is contained in:
Tzahi12345
2020-10-24 01:13:26 -04:00
committed by GitHub
22 changed files with 718 additions and 182 deletions

View File

@@ -26,6 +26,7 @@ const shortid = require('shortid')
const url_api = require('url'); const url_api = require('url');
var config_api = require('./config.js'); var config_api = require('./config.js');
var subscriptions_api = require('./subscriptions') var subscriptions_api = require('./subscriptions')
var categories_api = require('./categories');
const CONSTS = require('./consts') const CONSTS = require('./consts')
const { spawn } = require('child_process') const { spawn } = require('child_process')
const read_last_lines = require('read-last-lines'); const read_last_lines = require('read-last-lines');
@@ -36,7 +37,7 @@ const is_windows = process.platform === 'win32';
var app = express(); var app = express();
// database setup // database setup
const FileSync = require('lowdb/adapters/FileSync') const FileSync = require('lowdb/adapters/FileSync');
const adapter = new FileSync('./appdata/db.json'); const adapter = new FileSync('./appdata/db.json');
const db = low(adapter) const db = low(adapter)
@@ -79,8 +80,7 @@ config_api.initialize(logger);
auth_api.initialize(users_db, logger); auth_api.initialize(users_db, logger);
db_api.initialize(db, users_db, logger); db_api.initialize(db, users_db, logger);
subscriptions_api.initialize(db, users_db, logger, db_api); subscriptions_api.initialize(db, users_db, logger, db_api);
categories_api.initialize(db, users_db, logger, db_api);
// var GithubContent = require('github-content');
// Set some defaults // Set some defaults
db.defaults( db.defaults(
@@ -173,7 +173,6 @@ const subscription_timeouts = {};
// don't overwrite config if it already happened.. NOT // don't overwrite config if it already happened.. NOT
// let alreadyWritten = db.get('configWriteFlag').value(); // let alreadyWritten = db.get('configWriteFlag').value();
let writeConfigMode = process.env.write_ytdl_config; let writeConfigMode = process.env.write_ytdl_config;
var config = null;
// checks if config exists, if not, a config is auto generated // checks if config exists, if not, a config is auto generated
config_api.configExistsCheck(); config_api.configExistsCheck();
@@ -1077,6 +1076,7 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) {
var is_audio = type === 'audio'; var is_audio = type === 'audio';
var ext = is_audio ? '.mp3' : '.mp4'; var ext = is_audio ? '.mp3' : '.mp4';
var fileFolderPath = type === 'audio' ? audioFolderPath : videoFolderPath; var fileFolderPath = type === 'audio' ? audioFolderPath : videoFolderPath;
let category = null;
// prepend with user if needed // prepend with user if needed
let multiUserMode = null; let multiUserMode = null;
@@ -1093,7 +1093,7 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) {
} }
options.downloading_method = 'exec'; options.downloading_method = 'exec';
const downloadConfig = await generateArgs(url, type, options); let downloadConfig = await generateArgs(url, type, options);
// adds download to download helper // adds download to download helper
const download_uid = uuid(); const download_uid = uuid();
@@ -1115,11 +1115,22 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) {
updateDownloads(); updateDownloads();
// get video info prior to download // get video info prior to download
const info = await getVideoInfoByURL(url, downloadConfig, download); let info = await getVideoInfoByURL(url, downloadConfig, download);
if (!info) { if (!info) {
resolve(false); resolve(false);
return; return;
} else { } else {
// check if it fits into a category. If so, then get info again using new downloadConfig
category = await categories_api.categorize(info);
// set custom output if the category has one and re-retrieve info so the download manager has the right file name
if (category && category['custom_output']) {
options.customOutput = category['custom_output'];
options.noRelativePath = true;
downloadConfig = await generateArgs(url, type, options);
info = await getVideoInfoByURL(url, downloadConfig, download);
}
// store info in download for future use // store info in download for future use
download['_filename'] = info['_filename']; download['_filename'] = info['_filename'];
download['filesize'] = utils.getExpectedFileSize(info); download['filesize'] = utils.getExpectedFileSize(info);
@@ -1161,7 +1172,7 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) {
} catch(e) { } catch(e) {
output_json = null; output_json = null;
} }
var modified_file_name = output_json ? output_json['title'] : null;
if (!output_json) { if (!output_json) {
continue; continue;
} }
@@ -1190,8 +1201,11 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) {
if (!success) logger.error('Failed to apply ID3 tag to audio file ' + output_json['_filename']); if (!success) logger.error('Failed to apply ID3 tag to audio file ' + output_json['_filename']);
} }
const file_path = options.noRelativePath ? path.basename(full_file_path) : full_file_path.substring(fileFolderPath.length, full_file_path.length);
const customPath = options.noRelativePath ? path.dirname(full_file_path).split(path.sep).pop() : null;
// registers file in DB // registers file in DB
file_uid = db_api.registerFileDB(full_file_path.substring(fileFolderPath.length, full_file_path.length), type, multiUserMode); file_uid = db_api.registerFileDB(file_path, type, multiUserMode, null, customPath);
if (file_name) file_names.push(file_name); if (file_name) file_names.push(file_name);
} }
@@ -1406,7 +1420,8 @@ async function generateArgs(url, type, options) {
} }
if (customOutput) { if (customOutput) {
downloadConfig = ['-o', path.join(fileFolderPath, customOutput) + ".%(ext)s", '--write-info-json', '--print-json']; customOutput = options.noRelativePath ? customOutput : path.join(fileFolderPath, customOutput);
downloadConfig = ['-o', `${customOutput}.%(ext)s`, '--write-info-json', '--print-json'];
} else { } else {
downloadConfig = ['-o', path.join(fileFolderPath, videopath + (is_audio ? '.%(ext)s' : '.mp4')), '--write-info-json', '--print-json']; downloadConfig = ['-o', path.join(fileFolderPath, videopath + (is_audio ? '.%(ext)s' : '.mp4')), '--write-info-json', '--print-json'];
} }
@@ -1715,13 +1730,9 @@ app.use(function(req, res, next) {
next(); next();
} else if (req.query.apiKey === admin_token) { } else if (req.query.apiKey === admin_token) {
next(); next();
} else if (req.query.apiKey && config_api.getConfigItem('ytdl_use_api_key')) { } else if (req.query.apiKey && config_api.getConfigItem('ytdl_use_api_key') && req.query.apiKey === config_api.getConfigItem('ytdl_api_key')) {
if (req.query.apiKey === config_api.getConfigItem('ytdl_api_key')) { next();
next(); } else if (req.path.includes('/api/stream/')) {
} else {
res.status(401).send('Invalid API key');
}
} else if (req.path.includes('/api/video/') || req.path.includes('/api/audio/')) {
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}`);
@@ -1734,8 +1745,7 @@ app.use(compression());
const optionalJwt = function (req, res, next) { const optionalJwt = function (req, res, next) {
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode'); const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
if (multiUserMode && ((req.body && req.body.uuid) || (req.query && req.query.uuid)) && (req.path.includes('/api/getFile') || if (multiUserMode && ((req.body && req.body.uuid) || (req.query && req.query.uuid)) && (req.path.includes('/api/getFile') ||
req.path.includes('/api/audio') || req.path.includes('/api/stream') ||
req.path.includes('/api/video') ||
req.path.includes('/api/downloadFile'))) { req.path.includes('/api/downloadFile'))) {
// check if shared video // check if shared video
const using_body = req.body && req.body.uuid; const using_body = req.body && req.body.uuid;
@@ -1875,8 +1885,11 @@ app.get('/api/getMp3s', optionalJwt, async function(req, res) {
mp3s = JSON.parse(JSON.stringify(mp3s)); mp3s = JSON.parse(JSON.stringify(mp3s));
// add thumbnails if present if (config_api.getConfigItem('ytdl_include_thumbnail')) {
await addThumbnails(mp3s); // add thumbnails if present
await addThumbnails(mp3s);
}
res.send({ res.send({
mp3s: mp3s, mp3s: mp3s,
@@ -1899,8 +1912,10 @@ app.get('/api/getMp4s', optionalJwt, async function(req, res) {
mp4s = JSON.parse(JSON.stringify(mp4s)); mp4s = JSON.parse(JSON.stringify(mp4s));
// add thumbnails if present if (config_api.getConfigItem('ytdl_include_thumbnail')) {
await addThumbnails(mp4s); // add thumbnails if present
await addThumbnails(mp4s);
}
res.send({ res.send({
mp4s: mp4s, mp4s: mp4s,
@@ -1988,8 +2003,10 @@ app.post('/api/getAllFiles', optionalJwt, async function (req, res) {
files = JSON.parse(JSON.stringify(files)); files = JSON.parse(JSON.stringify(files));
// add thumbnails if present if (config_api.getConfigItem('ytdl_include_thumbnail')) {
await addThumbnails(files); // add thumbnails if present
await addThumbnails(files);
}
res.send({ res.send({
files: files, files: files,
@@ -2084,6 +2101,54 @@ app.post('/api/disableSharing', optionalJwt, function(req, res) {
}); });
}); });
// categories
app.post('/api/getAllCategories', optionalJwt, async (req, res) => {
const categories = db.get('categories').value();
res.send({categories: categories});
});
app.post('/api/createCategory', optionalJwt, async (req, res) => {
const name = req.body.name;
const new_category = {
name: name,
uid: uuid(),
rules: [],
custom_putput: ''
};
db.get('categories').push(new_category).write();
res.send({
new_category: new_category,
success: !!new_category
});
});
app.post('/api/deleteCategory', optionalJwt, async (req, res) => {
const category_uid = req.body.category_uid;
db.get('categories').remove({uid: category_uid}).write();
res.send({
success: true
});
});
app.post('/api/updateCategory', optionalJwt, async (req, res) => {
const category = req.body.category;
db.get('categories').find({uid: category.uid}).assign(category).write();
res.send({success: true});
});
app.post('/api/updateCategories', optionalJwt, async (req, res) => {
const categories = req.body.categories;
db.get('categories').assign(categories).write();
res.send({success: true});
});
// subscriptions
app.post('/api/subscribe', optionalJwt, async (req, res) => { app.post('/api/subscribe', optionalJwt, async (req, res) => {
let name = req.body.name; let name = req.body.name;
let url = req.body.url; let url = req.body.url;
@@ -2168,10 +2233,17 @@ app.post('/api/deleteSubscriptionFile', optionalJwt, async (req, res) => {
app.post('/api/getSubscription', optionalJwt, async (req, res) => { app.post('/api/getSubscription', optionalJwt, async (req, res) => {
let subID = req.body.id; let subID = req.body.id;
let subName = req.body.name; // if included, subID is optional
let user_uid = req.isAuthenticated() ? req.user.uid : null; let user_uid = req.isAuthenticated() ? req.user.uid : null;
// get sub from db // get sub from db
let subscription = subscriptions_api.getSubscription(subID, user_uid); let subscription = null;
if (subID) {
subscription = subscriptions_api.getSubscription(subID, user_uid)
} else if (subName) {
subscription = subscriptions_api.getSubscriptionByName(subName, user_uid)
}
if (!subscription) { if (!subscription) {
// failed to get subscription from db, send 400 error // failed to get subscription from db, send 400 error
@@ -2401,56 +2473,25 @@ app.post('/api/deletePlaylist', optionalJwt, async (req, res) => {
}) })
}); });
// deletes mp3 file // deletes non-subscription files
app.post('/api/deleteMp3', optionalJwt, async (req, res) => { app.post('/api/deleteFile', optionalJwt, async (req, res) => {
// var name = req.body.name;
var uid = req.body.uid; var uid = req.body.uid;
var type = req.body.type;
var blacklistMode = req.body.blacklistMode; var blacklistMode = req.body.blacklistMode;
if (req.isAuthenticated()) { if (req.isAuthenticated()) {
let success = await auth_api.deleteUserFile(req.user.uid, uid, 'audio', blacklistMode); let success = auth_api.deleteUserFile(req.user.uid, uid, type, blacklistMode);
res.send(success); res.send(success);
return; return;
} }
var audio_obj = db.get('files.audio').find({uid: uid}).value(); var file_obj = db.get(`files.${type}`).find({uid: uid}).value();
var name = audio_obj.id; var name = file_obj.id;
var fullpath = audioFolderPath + name + ".mp3"; var fullpath = file_obj ? file_obj.path : null;
var wasDeleted = false; var wasDeleted = false;
if (await fs.pathExists(fullpath)) if (await fs.pathExists(fullpath))
{ {
deleteAudioFile(name, null, blacklistMode); wasDeleted = type === 'audio' ? await deleteAudioFile(name, path.basename(fullpath), blacklistMode) : await deleteVideoFile(name, path.basename(fullpath), blacklistMode);
db.get('files.audio').remove({uid: uid}).write();
wasDeleted = true;
res.send(wasDeleted);
} else if (audio_obj) {
db.get('files.audio').remove({uid: uid}).write();
wasDeleted = true;
res.send(wasDeleted);
} else {
wasDeleted = false;
res.send(wasDeleted);
}
});
// deletes mp4 file
app.post('/api/deleteMp4', optionalJwt, async (req, res) => {
var uid = req.body.uid;
var blacklistMode = req.body.blacklistMode;
if (req.isAuthenticated()) {
let success = await auth_api.deleteUserFile(req.user.uid, uid, 'video', blacklistMode);
res.send(success);
return;
}
var video_obj = db.get('files.video').find({uid: uid}).value();
var name = video_obj.id;
var fullpath = videoFolderPath + name + ".mp4";
var wasDeleted = false;
if (await fs.pathExists(fullpath))
{
wasDeleted = await deleteVideoFile(name, null, blacklistMode);
db.get('files.video').remove({uid: uid}).write(); db.get('files.video').remove({uid: uid}).write();
// wasDeleted = true; // wasDeleted = true;
res.send(wasDeleted); res.send(wasDeleted);
@@ -2517,17 +2558,6 @@ app.post('/api/downloadFile', optionalJwt, async (req, res) => {
}); });
}); });
app.post('/api/deleteFile', async (req, res) => {
let fileName = req.body.fileName;
let type = req.body.type;
if (type === 'audio') {
deleteAudioFile(fileName);
} else if (type === 'video') {
deleteVideoFile(fileName);
}
res.send({});
});
app.post('/api/downloadArchive', async (req, res) => { app.post('/api/downloadArchive', async (req, res) => {
let sub = req.body.sub; let sub = req.body.sub;
let archive_dir = sub.archive; let archive_dir = sub.archive;
@@ -2595,25 +2625,33 @@ app.post('/api/generateNewAPIKey', function (req, res) {
// Streaming API calls // Streaming API calls
app.get('/api/video/:id', optionalJwt, function(req , res){ app.get('/api/stream/:id', optionalJwt, (req, res) => {
const type = req.query.type;
const ext = type === 'audio' ? '.mp3' : '.mp4';
const mimetype = type === 'audio' ? 'audio/mp3' : 'video/mp4';
var head; var head;
let optionalParams = url_api.parse(req.url,true).query; let optionalParams = url_api.parse(req.url,true).query;
let id = decodeURIComponent(req.params.id); let id = decodeURIComponent(req.params.id);
let file_path = videoFolderPath + id + '.mp4'; let file_path = req.query.file_path ? decodeURIComponent(req.query.file_path) : null;
if (req.isAuthenticated() || req.can_watch) { if (!file_path && (req.isAuthenticated() || req.can_watch)) {
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
if (optionalParams['subName']) { if (optionalParams['subName']) {
const isPlaylist = optionalParams['subPlaylist']; const isPlaylist = optionalParams['subPlaylist'];
file_path = path.join(usersFileFolder, req.user.uid, 'subscriptions', (isPlaylist === 'true' ? 'playlists/' : 'channels/'),optionalParams['subName'], id + '.mp4') file_path = path.join(usersFileFolder, req.user.uid, 'subscriptions', (isPlaylist === 'true' ? 'playlists/' : 'channels/'),optionalParams['subName'], id + ext)
} else { } else {
file_path = path.join(usersFileFolder, req.query.uuid ? req.query.uuid : req.user.uid, 'video', id + '.mp4'); file_path = path.join(usersFileFolder, req.query.uuid ? req.query.uuid : req.user.uid, type, id + ext);
} }
} else if (optionalParams['subName']) { } else if (!file_path && optionalParams['subName']) {
let basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); let basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
const isPlaylist = optionalParams['subPlaylist']; const isPlaylist = optionalParams['subPlaylist'];
basePath += (isPlaylist === 'true' ? 'playlists/' : 'channels/'); basePath += (isPlaylist === 'true' ? 'playlists/' : 'channels/');
file_path = basePath + optionalParams['subName'] + '/' + id + '.mp4'; file_path = basePath + optionalParams['subName'] + '/' + id + ext;
} }
if (!file_path) {
file_path = path.join(videoFolderPath, id + ext);
}
const stat = fs.statSync(file_path) const stat = fs.statSync(file_path)
const fileSize = stat.size const fileSize = stat.size
const range = req.headers.range const range = req.headers.range
@@ -2636,77 +2674,20 @@ app.get('/api/video/:id', optionalJwt, function(req , res){
'Content-Range': `bytes ${start}-${end}/${fileSize}`, 'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes', 'Accept-Ranges': 'bytes',
'Content-Length': chunksize, 'Content-Length': chunksize,
'Content-Type': 'video/mp4', 'Content-Type': mimetype,
} }
res.writeHead(206, head); res.writeHead(206, head);
file.pipe(res); file.pipe(res);
} else { } else {
head = { head = {
'Content-Length': fileSize, 'Content-Length': fileSize,
'Content-Type': 'video/mp4', 'Content-Type': mimetype,
} }
res.writeHead(200, head) res.writeHead(200, head)
fs.createReadStream(file_path).pipe(res) fs.createReadStream(file_path).pipe(res)
} }
}); });
app.get('/api/audio/:id', optionalJwt, function(req , res){
var head;
let id = decodeURIComponent(req.params.id);
let file_path = "audio/" + id + '.mp3';
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
let optionalParams = url_api.parse(req.url,true).query;
if (req.isAuthenticated()) {
if (optionalParams['subName']) {
const isPlaylist = optionalParams['subPlaylist'];
file_path = path.join(usersFileFolder, req.user.uid, 'subscriptions', (isPlaylist === 'true' ? 'playlists/' : 'channels/'),optionalParams['subName'], id + '.mp3')
} else {
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
file_path = path.join(usersFileFolder, req.user.uid, 'audio', id + '.mp3');
}
} else if (optionalParams['subName']) {
let basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
const isPlaylist = optionalParams['subPlaylist'];
basePath += (isPlaylist === 'true' ? 'playlists/' : 'channels/');
file_path = basePath + optionalParams['subName'] + '/' + id + '.mp3';
}
file_path = file_path.replace(/\"/g, '\'');
const stat = fs.statSync(file_path)
const fileSize = stat.size
const range = req.headers.range
if (range) {
const parts = range.replace(/bytes=/, "").split("-")
const start = parseInt(parts[0], 10)
const end = parts[1]
? parseInt(parts[1], 10)
: fileSize-1
const chunksize = (end-start)+1
const file = fs.createReadStream(file_path, {start, end});
if (config_api.descriptors[id]) config_api.descriptors[id].push(file);
else config_api.descriptors[id] = [file];
file.on('close', function() {
let index = config_api.descriptors[id].indexOf(file);
config_api.descriptors[id].splice(index, 1);
logger.debug('Successfully closed stream and removed file reference.');
});
head = {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': 'audio/mp3',
}
res.writeHead(206, head);
file.pipe(res);
} else {
head = {
'Content-Length': fileSize,
'Content-Type': 'audio/mp3',
}
res.writeHead(200, head)
fs.createReadStream(file_path).pipe(res)
}
});
// Downloads management // Downloads management
app.get('/api/downloads', async (req, res) => { app.get('/api/downloads', async (req, res) => {

123
backend/categories.js Normal file
View File

@@ -0,0 +1,123 @@
const config_api = require('./config');
var logger = null;
var db = null;
var users_db = null;
var db_api = null;
function setDB(input_db, input_users_db, input_db_api) { db = input_db; users_db = input_users_db; db_api = input_db_api }
function setLogger(input_logger) { logger = input_logger; }
function initialize(input_db, input_users_db, input_logger, input_db_api) {
setDB(input_db, input_users_db, input_db_api);
setLogger(input_logger);
}
/*
Categories:
Categories are a way to organize videos based on dynamic rules set by the user. Categories are universal (so not per-user).
Categories, besides rules, have an optional custom output. This custom output can help users create their
desired directory structure.
Rules:
A category rule consists of a property, a comparison, and a value. For example, "uploader includes 'VEVO'"
Rules are stored as an object with the above fields. In addition to those fields, it also has a preceding_operator, which
is either OR or AND, and signifies whether the rule should be ANDed with the previous rules, or just ORed. For the first
rule, this field is null.
Ex. (title includes 'Rihanna' OR title includes 'Beyonce' AND uploader includes 'VEVO')
*/
async function categorize(file_json) {
let selected_category = null;
const categories = getCategories();
if (!categories) {
logger.warn('Categories could not be found. Initializing categories...');
db.assign({categories: []}).write();
return null;
return;
}
for (let i = 0; i < categories.length; i++) {
const category = categories[i];
const rules = category['rules'];
// if rules for current category apply, then that is the selected category
if (applyCategoryRules(file_json, rules, category['name'])) {
selected_category = category;
logger.verbose(`Selected category ${category['name']} for ${file_json['webpage_url']}`);
return selected_category;
}
}
return selected_category;
}
function getCategories() {
const categories = db.get('categories').value();
return categories ? categories : null;
}
function applyCategoryRules(file_json, rules, category_name) {
let rules_apply = false;
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
let rule_applies = null;
let preceding_operator = rule['preceding_operator'];
switch (rule['comparator']) {
case 'includes':
rule_applies = file_json[rule['property']].includes(rule['value']);
break;
case 'not_includes':
rule_applies = !(file_json[rule['property']].includes(rule['value']));
break;
case 'equals':
rule_applies = file_json[rule['property']] === rule['value'];
break;
case 'not_equals':
rule_applies = file_json[rule['property']] !== rule['value'];
break;
default:
logger.warn(`Invalid comparison used for category ${category_name}`)
break;
}
// OR the first rule with rules_apply, which will be initially false
if (i === 0) preceding_operator = 'or';
// update rules_apply based on current rule
if (preceding_operator === 'or')
rules_apply = rules_apply || rule_applies;
else
rules_apply = rules_apply && rule_applies;
}
return rules_apply;
}
async function addTagToVideo(tag, video, user_uid) {
// TODO: Implement
}
async function removeTagFromVideo(tag, video, user_uid) {
// TODO: Implement
}
// adds tag to list of existing tags (used for tag suggestions)
async function addTagToExistingTags(tag) {
const existing_tags = db.get('tags').value();
if (!existing_tags.includes(tag)) {
db.get('tags').push(tag).write();
}
}
module.exports = {
initialize: initialize,
categorize: categorize,
}

View File

@@ -15,10 +15,10 @@ function initialize(input_db, input_users_db, input_logger) {
setLogger(input_logger); setLogger(input_logger);
} }
function registerFileDB(file_path, type, multiUserMode = null, sub = null) { function registerFileDB(file_path, type, multiUserMode = null, sub = null, customPath = null) {
let db_path = null; let db_path = null;
const file_id = file_path.substring(0, file_path.length-4); const file_id = file_path.substring(0, file_path.length-4);
const file_object = generateFileObject(file_id, type, multiUserMode && multiUserMode.file_path, sub); const file_object = generateFileObject(file_id, type, customPath || multiUserMode && multiUserMode.file_path, sub);
if (!file_object) { if (!file_object) {
logger.error(`Could not find associated JSON file for ${type} file ${file_id}`); logger.error(`Could not find associated JSON file for ${type} file ${file_id}`);
return false; return false;
@@ -27,7 +27,7 @@ function registerFileDB(file_path, type, multiUserMode = null, sub = null) {
utils.fixVideoMetadataPerms(file_id, type, multiUserMode && multiUserMode.file_path); utils.fixVideoMetadataPerms(file_id, type, multiUserMode && multiUserMode.file_path);
// add thumbnail path // add thumbnail path
file_object['thumbnailPath'] = utils.getDownloadedThumbnail(file_id, type, multiUserMode && multiUserMode.file_path); file_object['thumbnailPath'] = utils.getDownloadedThumbnail(file_id, type, customPath || multiUserMode && multiUserMode.file_path);
if (!sub) { if (!sub) {
if (multiUserMode) { if (multiUserMode) {

View File

@@ -430,6 +430,13 @@ function getSubscription(subID, user_uid = null) {
return db.get('subscriptions').find({id: subID}).value(); return db.get('subscriptions').find({id: subID}).value();
} }
function getSubscriptionByName(subName, user_uid = null) {
if (user_uid)
return users_db.get('users').find({uid: user_uid}).get('subscriptions').find({name: subName}).value();
else
return db.get('subscriptions').find({name: subName}).value();
}
function updateSubscription(sub, user_uid = null) { function updateSubscription(sub, user_uid = null) {
if (user_uid) { if (user_uid) {
users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign(sub).write(); users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign(sub).write();
@@ -482,6 +489,7 @@ async function removeIDFromArchive(archive_path, id) {
module.exports = { module.exports = {
getSubscription : getSubscription, getSubscription : getSubscription,
getSubscriptionByName : getSubscriptionByName,
getAllSubscriptions : getAllSubscriptions, getAllSubscriptions : getAllSubscriptions,
updateSubscription : updateSubscription, updateSubscription : updateSubscription,
subscribe : subscribe, subscribe : subscribe,

View File

@@ -116,6 +116,8 @@ export class AppComponent implements OnInit, AfterViewInit {
if (this.allowSubscriptions) { if (this.allowSubscriptions) {
this.postsService.reloadSubscriptions(); this.postsService.reloadSubscriptions();
} }
this.postsService.reloadCategories();
} }
// theme stuff // theme stuff

View File

@@ -79,6 +79,7 @@ import { UnifiedFileCardComponent } from './components/unified-file-card/unified
import { RecentVideosComponent } from './components/recent-videos/recent-videos.component'; import { RecentVideosComponent } from './components/recent-videos/recent-videos.component';
import { EditSubscriptionDialogComponent } from './dialogs/edit-subscription-dialog/edit-subscription-dialog.component'; import { EditSubscriptionDialogComponent } from './dialogs/edit-subscription-dialog/edit-subscription-dialog.component';
import { CustomPlaylistsComponent } from './components/custom-playlists/custom-playlists.component'; import { CustomPlaylistsComponent } from './components/custom-playlists/custom-playlists.component';
import { EditCategoryDialogComponent } from './dialogs/edit-category-dialog/edit-category-dialog.component';
registerLocaleData(es, 'es'); registerLocaleData(es, 'es');
@@ -123,7 +124,8 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
UnifiedFileCardComponent, UnifiedFileCardComponent,
RecentVideosComponent, RecentVideosComponent,
EditSubscriptionDialogComponent, EditSubscriptionDialogComponent,
CustomPlaylistsComponent CustomPlaylistsComponent,
EditCategoryDialogComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,

View File

@@ -61,7 +61,8 @@ export class LogsViewerComponent implements OnInit {
data: { data: {
dialogTitle: 'Clear logs', dialogTitle: 'Clear logs',
dialogText: 'Would you like to clear your logs? This will delete all your current logs, permanently.', dialogText: 'Would you like to clear your logs? This will delete all your current logs, permanently.',
submitText: 'Clear' submitText: 'Clear',
warnSubmitColor: true
} }
}); });
dialogRef.afterClosed().subscribe(confirmed => { dialogRef.afterClosed().subscribe(confirmed => {

View File

@@ -210,7 +210,7 @@ export class RecentVideosComponent implements OnInit {
if (!this.postsService.config.Extra.file_manager_enabled) { if (!this.postsService.config.Extra.file_manager_enabled) {
// tell server to delete the file once downloaded // tell server to delete the file once downloaded
this.postsService.deleteFile(name, false).subscribe(delRes => { this.postsService.deleteFile(name, type).subscribe(delRes => {
// reload mp4s // reload mp4s
this.getAllFiles(); this.getAllFiles();
}); });
@@ -233,7 +233,7 @@ export class RecentVideosComponent implements OnInit {
} }
deleteNormalFile(file, index, blacklistMode = false) { deleteNormalFile(file, index, blacklistMode = false) {
this.postsService.deleteFile(file.uid, file.isAudio, blacklistMode).subscribe(result => { this.postsService.deleteFile(file.uid, file.isAudio ? 'audio' : 'video', blacklistMode).subscribe(result => {
if (result) { if (result) {
this.postsService.openSnackBar('Delete success!', 'OK.'); this.postsService.openSnackBar('Delete success!', 'OK.');
this.files.splice(index, 1); this.files.splice(index, 1);

View File

@@ -6,7 +6,7 @@
</mat-dialog-content> </mat-dialog-content>
<mat-dialog-actions> <mat-dialog-actions>
<!-- The mat-dialog-close directive optionally accepts a value as a result for the dialog. --> <!-- The mat-dialog-close directive optionally accepts a value as a result for the dialog. -->
<button color="primary" mat-flat-button type="submit" (click)="confirmClicked()">{{submitText}}</button> <button [color]="warnSubmitColor ? 'warn' : 'primary'" mat-flat-button type="submit" (click)="confirmClicked()">{{submitText}}</button>
<div class="mat-spinner" *ngIf="submitClicked"> <div class="mat-spinner" *ngIf="submitClicked">
<mat-spinner [diameter]="25"></mat-spinner> <mat-spinner [diameter]="25"></mat-spinner>
</div> </div>

View File

@@ -16,11 +16,13 @@ export class ConfirmDialogComponent implements OnInit {
doneEmitter: EventEmitter<any> = null; doneEmitter: EventEmitter<any> = null;
onlyEmitOnDone = false; onlyEmitOnDone = false;
warnSubmitColor = false;
constructor(@Inject(MAT_DIALOG_DATA) public data: any, public dialogRef: MatDialogRef<ConfirmDialogComponent>) { constructor(@Inject(MAT_DIALOG_DATA) public data: any, public dialogRef: MatDialogRef<ConfirmDialogComponent>) {
if (this.data.dialogTitle) { this.dialogTitle = this.data.dialogTitle }; if (this.data.dialogTitle) { this.dialogTitle = this.data.dialogTitle };
if (this.data.dialogText) { this.dialogText = this.data.dialogText }; if (this.data.dialogText) { this.dialogText = this.data.dialogText };
if (this.data.submitText) { this.submitText = this.data.submitText }; if (this.data.submitText) { this.submitText = this.data.submitText };
if (this.data.warnSubmitColor) { this.warnSubmitColor = this.data.warnSubmitColor };
// checks if emitter exists, if so don't autoclose as it should be handled by caller // checks if emitter exists, if so don't autoclose as it should be handled by caller
if (this.data.doneEmitter) { if (this.data.doneEmitter) {

View File

@@ -0,0 +1,60 @@
<h4 mat-dialog-title><ng-container i18n="Editing category dialog title">Editing category</ng-container>&nbsp;{{category['name']}}</h4>
<mat-dialog-content style="max-height: 50vh">
<mat-form-field style="width: 250px; margin-bottom: 5px;">
<input matInput placeholder="Name" i18n-placeholder="Name" [(ngModel)]="category['name']" required>
</mat-form-field>
<mat-divider></mat-divider>
<h6 style="margin-top: 20px;" i18n="Rules">Rules</h6>
<mat-list>
<mat-list-item *ngFor="let rule of category['rules']; let i = index">
<mat-form-field [style.visibility]="i === 0 ? 'hidden' : null" class="operator-select">
<mat-select [disabled]="i === 0" [(ngModel)]="rule['preceding_operator']">
<mat-option value="or">OR</mat-option>
<mat-option value="and">AND</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="property-select">
<mat-select [(ngModel)]="rule['property']">
<mat-option *ngFor="let propertyOption of propertyOptions" [value]="propertyOption.value">{{propertyOption.label}}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="comparator-select">
<mat-select [(ngModel)]="rule['comparator']">
<mat-option *ngFor="let comparatorOption of comparatorOptions" [value]="comparatorOption.value">{{comparatorOption.label}}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="value-input">
<input matInput [(ngModel)]="rule['value']">
</mat-form-field>
<button [disabled]="i === category['rules'].length-1" (click)="swapRules(i, i+1)" mat-icon-button><mat-icon>arrow_downward</mat-icon></button>
<button [disabled]="i === 0" (click)="swapRules(i, i-1)" mat-icon-button><mat-icon>arrow_upward</mat-icon></button>
<button (click)="removeRule(i)" mat-icon-button><mat-icon>cancel</mat-icon></button>
</mat-list-item>
</mat-list>
<button style="margin-bottom: 8px;" mat-icon-button (click)="addNewRule()" matTooltip="Add new rule" i18n-matTooltip="Add new rule tooltip"><mat-icon>add</mat-icon></button>
<mat-divider></mat-divider>
<mat-form-field style="width: 250px; margin-top: 10px;">
<input matInput [(ngModel)]="category['custom_output']" placeholder="Custom file output" i18n-placeholder="Category custom file output placeholder">
<mat-hint>
<a target="_blank" href="https://github.com/ytdl-org/youtube-dl/blob/master/README.md#output-template">
<ng-container i18n="Custom output template documentation link">Documentation</ng-container></a>.
<ng-container i18n="Custom Output input hint">Path is relative to the config download path. Don't include extension.</ng-container>
</mat-hint>
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close><ng-container i18n="Cancel">Cancel</ng-container></button>
<button mat-button [disabled]="categoryChanged()" type="submit" (click)="saveClicked()"><ng-container i18n="Save button">Save</ng-container></button>
<div class="mat-spinner" *ngIf="updating">
<mat-spinner [diameter]="25"></mat-spinner>
</div>
</mat-dialog-actions>

View File

@@ -0,0 +1,16 @@
.operator-select {
width: 55px;
}
.property-select {
margin-left: 10px;
width: 110px;
}
.comparator-select {
margin-left: 10px;
}
.value-input {
margin-left: 10px;
}

View File

@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { EditCategoryDialogComponent } from './edit-category-dialog.component';
describe('EditCategoryDialogComponent', () => {
let component: EditCategoryDialogComponent;
let fixture: ComponentFixture<EditCategoryDialogComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ EditCategoryDialogComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditCategoryDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,111 @@
import { Component, OnInit, Inject } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { PostsService } from 'app/posts.services';
@Component({
selector: 'app-edit-category-dialog',
templateUrl: './edit-category-dialog.component.html',
styleUrls: ['./edit-category-dialog.component.scss']
})
export class EditCategoryDialogComponent implements OnInit {
updating = false;
original_category = null;
category = null;
propertyOptions = [
{
value: 'fulltitle',
label: 'Title'
},
{
value: 'id',
label: 'ID'
},
{
value: 'webpage_url',
label: 'URL'
},
{
value: 'view_count',
label: 'Views'
},
{
value: 'uploader',
label: 'Uploader'
},
{
value: '_filename',
label: 'File Name'
},
{
value: 'tags',
label: 'Tags'
}
];
comparatorOptions = [
{
value: 'includes',
label: 'includes'
},
{
value: 'not_includes',
label: 'not includes'
},
{
value: 'equals',
label: 'equals'
},
{
value: 'not_equals',
label: 'not equals'
},
];
constructor(@Inject(MAT_DIALOG_DATA) public data: any, private postsService: PostsService) {
if (this.data) {
this.original_category = this.data.category;
this.category = JSON.parse(JSON.stringify(this.original_category));
}
}
ngOnInit(): void {
}
addNewRule() {
this.category['rules'].push({
preceding_operator: 'or',
property: 'fulltitle',
comparator: 'includes',
value: ''
});
}
saveClicked() {
this.updating = true;
this.postsService.updateCategory(this.category).subscribe(res => {
this.updating = false;
this.original_category = JSON.parse(JSON.stringify(this.category));
this.postsService.reloadCategories();
}, err => {
this.updating = false;
console.error(err);
});
}
categoryChanged() {
return JSON.stringify(this.category) === JSON.stringify(this.original_category);
}
swapRules(original_index, new_index) {
[this.category.rules[original_index], this.category.rules[new_index]] = [this.category.rules[new_index],
this.category.rules[original_index]];
}
removeRule(index) {
this.category['rules'].splice(index, 1);
}
}

View File

@@ -56,7 +56,7 @@ export class FileCardComponent implements OnInit {
deleteFile(blacklistMode = false) { deleteFile(blacklistMode = false) {
if (!this.playlist) { if (!this.playlist) {
this.postsService.deleteFile(this.uid, this.isAudio, blacklistMode).subscribe(result => { this.postsService.deleteFile(this.uid, this.isAudio ? 'audio' : 'video', blacklistMode).subscribe(result => {
if (result) { if (result) {
this.openSnackBar('Delete success!', 'OK.'); this.openSnackBar('Delete success!', 'OK.');
this.removeFile.emit(this.name); this.removeFile.emit(this.name);

View File

@@ -746,7 +746,7 @@ export class MainComponent implements OnInit {
if (!this.fileManagerEnabled) { if (!this.fileManagerEnabled) {
// tell server to delete the file once downloaded // tell server to delete the file once downloaded
this.postsService.deleteFile(name, true).subscribe(delRes => { this.postsService.deleteFile(name, 'video').subscribe(delRes => {
// reload mp3s // reload mp3s
this.getMp3s(); this.getMp3s();
}); });
@@ -763,7 +763,7 @@ export class MainComponent implements OnInit {
if (!this.fileManagerEnabled) { if (!this.fileManagerEnabled) {
// tell server to delete the file once downloaded // tell server to delete the file once downloaded
this.postsService.deleteFile(name, false).subscribe(delRes => { this.postsService.deleteFile(name, 'audio').subscribe(delRes => {
// reload mp4s // reload mp4s
this.getMp4s(); this.getMp4s();
}); });

View File

@@ -124,6 +124,8 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
this.getFile(); this.getFile();
} else if (this.id) { } else if (this.id) {
this.getPlaylistFiles(); this.getPlaylistFiles();
} else if (this.subscriptionName) {
this.getSubscription();
} }
if (this.url) { if (this.url) {
@@ -139,7 +141,7 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
this.currentItem = this.playlist[0]; this.currentItem = this.playlist[0];
this.currentIndex = 0; this.currentIndex = 0;
this.show_player = true; this.show_player = true;
} else if (this.subscriptionName || this.fileNames) { } else if (this.fileNames && !this.subscriptionName) {
this.show_player = true; this.show_player = true;
this.parseFileNames(); this.parseFileNames();
} }
@@ -171,6 +173,25 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
}); });
} }
getSubscription() {
this.postsService.getSubscription(null, this.subscriptionName).subscribe(res => {
const subscription = res['subscription'];
if (this.fileNames) {
subscription.videos.forEach(video => {
if (video['id'] === this.fileNames[0]) {
this.db_file = video;
this.show_player = true;
this.parseFileNames();
}
});
} else {
console.log('no file name specified');
}
}, err => {
this.openSnackBar(`Failed to find subscription ${this.subscriptionName}`, 'Dismiss');
});
}
getPlaylistFiles() { getPlaylistFiles() {
this.postsService.getPlaylist(this.id, null, this.uuid).subscribe(res => { this.postsService.getPlaylist(this.id, null, this.uuid).subscribe(res => {
if (res['playlist']) { if (res['playlist']) {
@@ -202,23 +223,26 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
const fileName = this.fileNames[i]; const fileName = this.fileNames[i];
let baseLocation = null; let baseLocation = null;
let fullLocation = null; let fullLocation = null;
if (!this.subscriptionName) {
baseLocation = this.type + '/';
fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName);
} else {
// default to video but include subscription name param
baseLocation = this.type === 'audio' ? 'audio/' : 'video/';
fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName) + '?subName=' + this.subscriptionName +
'&subPlaylist=' + this.subPlaylist;
}
// adds user token if in multi-user-mode // adds user token if in multi-user-mode
const uuid_str = this.uuid ? `&uuid=${this.uuid}` : ''; const uuid_str = this.uuid ? `&uuid=${this.uuid}` : '';
const uid_str = (this.id || !this.db_file) ? '' : `&uid=${this.db_file.uid}`; const uid_str = (this.id || !this.db_file) ? '' : `&uid=${this.db_file.uid}`;
const type_str = (this.id || !this.db_file || !this.db_file.type) ? '' : `&type=${this.db_file.type}` const type_str = (this.type || !this.db_file) ? `&type=${this.type}` : `&type=${this.db_file.type}`
const id_str = this.id ? `&id=${this.id}` : ''; const id_str = this.id ? `&id=${this.id}` : '';
const file_path_str = (!this.db_file) ? '' : `&file_path=${encodeURIComponent(this.db_file.path)}`;
if (!this.subscriptionName) {
baseLocation = 'stream/';
fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName) + `?test=test${type_str}${file_path_str}`;
} else {
// default to video but include subscription name param
baseLocation = 'stream/';
fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName) + '?subName=' + this.subscriptionName +
'&subPlaylist=' + this.subPlaylist + `${file_path_str}${type_str}`;
}
if (this.postsService.isLoggedIn) { if (this.postsService.isLoggedIn) {
fullLocation += (this.subscriptionName ? '&' : '?') + `jwt=${this.postsService.token}`; fullLocation += (this.subscriptionName ? '&' : '&') + `jwt=${this.postsService.token}`;
if (this.is_shared) { fullLocation += `${uuid_str}${uid_str}${type_str}${id_str}`; } if (this.is_shared) { fullLocation += `${uuid_str}${uid_str}${type_str}${id_str}`; }
} else if (this.is_shared) { } else if (this.is_shared) {
fullLocation += (this.subscriptionName ? '&' : '?') + `test=test${uuid_str}${uid_str}${type_str}${id_str}`; fullLocation += (this.subscriptionName ? '&' : '?') + `test=test${uuid_str}${uid_str}${type_str}${id_str}`;

View File

@@ -53,6 +53,7 @@ export class PostsService implements CanActivate {
// global vars // global vars
config = null; config = null;
subscriptions = null; subscriptions = null;
categories = null;
sidenav = null; sidenav = null;
locale = isoLangs['en']; locale = isoLangs['en'];
@@ -211,12 +212,8 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'setConfig', {new_config_file: config}, this.httpOptions); return this.http.post(this.path + 'setConfig', {new_config_file: config}, this.httpOptions);
} }
deleteFile(uid: string, isAudio: boolean, blacklistMode = false) { deleteFile(uid: string, type: string, blacklistMode = false) {
if (isAudio) { return this.http.post(this.path + 'deleteFile', {uid: uid, type: type, blacklistMode: blacklistMode}, this.httpOptions);
return this.http.post(this.path + 'deleteMp3', {uid: uid, blacklistMode: blacklistMode}, this.httpOptions);
} else {
return this.http.post(this.path + 'deleteMp4', {uid: uid, blacklistMode: blacklistMode}, this.httpOptions);
}
} }
getMp3s() { getMp3s() {
@@ -310,6 +307,34 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'deletePlaylist', {playlistID: playlistID, type: type}, this.httpOptions); return this.http.post(this.path + 'deletePlaylist', {playlistID: playlistID, type: type}, this.httpOptions);
} }
// categories
getAllCategories() {
return this.http.post(this.path + 'getAllCategories', {}, this.httpOptions);
}
createCategory(name) {
return this.http.post(this.path + 'createCategory', {name: name}, this.httpOptions);
}
deleteCategory(category_uid) {
return this.http.post(this.path + 'deleteCategory', {category_uid: category_uid}, this.httpOptions);
}
updateCategory(category) {
return this.http.post(this.path + 'updateCategory', {category: category}, this.httpOptions);
}
updateCategories(categories) {
return this.http.post(this.path + 'updateCategories', {categories: categories}, this.httpOptions);
}
reloadCategories() {
this.getAllCategories().subscribe(res => {
this.categories = res['categories'];
});
}
createSubscription(url, name, timerange = null, streamingOnly = false, audioOnly = false, customArgs = null, customFileOutput = null) { createSubscription(url, name, timerange = null, streamingOnly = false, audioOnly = false, customArgs = null, customFileOutput = null) {
return this.http.post(this.path + 'subscribe', {url: url, name: name, timerange: timerange, streamingOnly: streamingOnly, return this.http.post(this.path + 'subscribe', {url: url, name: name, timerange: timerange, streamingOnly: streamingOnly,
audioOnly: audioOnly, customArgs: customArgs, customFileOutput: customFileOutput}, this.httpOptions); audioOnly: audioOnly, customArgs: customArgs, customFileOutput: customFileOutput}, this.httpOptions);
@@ -328,8 +353,8 @@ export class PostsService implements CanActivate {
file_uid: file_uid}, this.httpOptions) file_uid: file_uid}, this.httpOptions)
} }
getSubscription(id) { getSubscription(id, name = null) {
return this.http.post(this.path + 'getSubscription', {id: id}, this.httpOptions); return this.http.post(this.path + 'getSubscription', {id: id, name: name}, this.httpOptions);
} }
getAllSubscriptions() { getAllSubscriptions() {

View File

@@ -115,15 +115,38 @@
</mat-form-field> </mat-form-field>
</div> </div>
<div class="col-12 mt-4"> <div class="col-12 mt-4 mb-5">
<mat-form-field class="text-field" style="margin-right: 12px;" color="accent"> <mat-form-field class="text-field" style="margin-right: 12px;" color="accent">
<textarea matInput [(ngModel)]="new_config['Downloader']['custom_args']" placeholder="Custom args" i18n-placeholder="Custom args input placeholder"></textarea> <textarea matInput [(ngModel)]="new_config['Downloader']['custom_args']" placeholder="Custom args" i18n-placeholder="Custom args input placeholder"></textarea>
<mat-hint><ng-container i18n="Custom args setting input hint">Global custom args for downloads on the home page. Args are delimited using two commas like so: ,,</ng-container></mat-hint> <mat-hint><ng-container i18n="Custom args setting input hint">Global custom args for downloads on the home page. Args are delimited using two commas like so: ,,</ng-container></mat-hint>
<button class="args-edit-button" (click)="openArgsModifierDialog()" mat-icon-button><mat-icon>edit</mat-icon></button> <button class="args-edit-button" (click)="openArgsModifierDialog()" mat-icon-button><mat-icon>edit</mat-icon></button>
</mat-form-field> </mat-form-field>
</div> </div>
</div>
<div class="col-12 mt-5"> </div>
<mat-divider></mat-divider>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12 mt-3 mb-2">
<h6 i18n="Categories">Categories</h6>
<div cdkDropList class="category-list" (cdkDropListDropped)="dropCategory($event)">
<div class="category-box" *ngFor="let category of postsService.categories" cdkDrag>
<div class="category-custom-placeholder" *cdkDragPlaceholder></div>
{{category['name']}}
<span style="float: right">
<button mat-icon-button (click)="openEditCategoryDialog(category)"><mat-icon>edit</mat-icon></button>
<button mat-icon-button (click)="deleteCategory(category)"><mat-icon>cancel</mat-icon></button>
</span>
</div>
</div>
<button style="margin-top: 10px;" mat-mini-fab (click)="openAddCategoryDialog()"><mat-icon>add</mat-icon></button>
</div>
</div>
</div>
<mat-divider></mat-divider>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12 mt-3">
<mat-checkbox color="accent" [(ngModel)]="new_config['Downloader']['use_youtubedl_archive']"><ng-container i18n="Use youtubedl archive setting">Use youtube-dl archive</ng-container></mat-checkbox> <mat-checkbox color="accent" [(ngModel)]="new_config['Downloader']['use_youtubedl_archive']"><ng-container i18n="Use youtubedl archive setting">Use youtube-dl archive</ng-container></mat-checkbox>
</div> </div>

View File

@@ -31,3 +31,54 @@
margin-bottom: 12px; margin-bottom: 12px;
bottom: 4px; bottom: 4px;
} }
.category-list {
width: 500px;
max-width: 100%;
border: solid 1px #ccc;
min-height: 60px;
display: block;
// background: white;
border-radius: 4px;
overflow: hidden;
}
.category-box {
padding: 20px 10px;
border-bottom: solid 1px #ccc;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
cursor: move;
// background: white;
font-size: 14px;
}
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 4px;
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
0 8px 10px 1px rgba(0, 0, 0, 0.14),
0 3px 14px 2px rgba(0, 0, 0, 0.12);
}
.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.category-box:last-child {
border: none;
}
.category-list.cdk-drop-list-dragging .category-box:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.category-custom-placeholder {
background: #ccc;
border: dotted 3px #999;
min-height: 60px;
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}

View File

@@ -9,6 +9,9 @@ import { CURRENT_VERSION } from 'app/consts';
import { MatCheckboxChange } from '@angular/material/checkbox'; import { MatCheckboxChange } from '@angular/material/checkbox';
import { CookiesUploaderDialogComponent } from 'app/dialogs/cookies-uploader-dialog/cookies-uploader-dialog.component'; import { CookiesUploaderDialogComponent } from 'app/dialogs/cookies-uploader-dialog/cookies-uploader-dialog.component';
import { ConfirmDialogComponent } from 'app/dialogs/confirm-dialog/confirm-dialog.component'; import { ConfirmDialogComponent } from 'app/dialogs/confirm-dialog/confirm-dialog.component';
import { moveItemInArray, CdkDragDrop } from '@angular/cdk/drag-drop';
import { InputDialogComponent } from 'app/input-dialog/input-dialog.component';
import { EditCategoryDialogComponent } from 'app/dialogs/edit-category-dialog/edit-category-dialog.component';
@Component({ @Component({
selector: 'app-settings', selector: 'app-settings',
@@ -77,6 +80,74 @@ export class SettingsComponent implements OnInit {
}) })
} }
dropCategory(event: CdkDragDrop<string[]>) {
moveItemInArray(this.postsService.categories, event.previousIndex, event.currentIndex);
this.postsService.updateCategories(this.postsService.categories).subscribe(res => {
}, err => {
this.postsService.openSnackBar('Failed to update categories!');
});
}
openAddCategoryDialog() {
const done = new EventEmitter<any>();
const dialogRef = this.dialog.open(InputDialogComponent, {
width: '300px',
data: {
inputTitle: 'Name the category',
inputPlaceholder: 'Name',
submitText: 'Add',
doneEmitter: done
}
});
done.subscribe(name => {
// Eventually do additional checks on name
if (name) {
this.postsService.createCategory(name).subscribe(res => {
if (res['success']) {
this.postsService.reloadCategories();
dialogRef.close();
const new_category = res['new_category'];
this.openEditCategoryDialog(new_category);
}
});
}
});
}
deleteCategory(category) {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
dialogTitle: 'Delete category',
dialogText: `Would you like to delete ${category['name']}?`,
submitText: 'Delete',
warnSubmitColor: true
}
});
dialogRef.afterClosed().subscribe(confirmed => {
if (confirmed) {
this.postsService.deleteCategory(category['uid']).subscribe(res => {
if (res['success']) {
this.postsService.openSnackBar(`Successfully deleted ${category['name']}!`);
this.postsService.reloadCategories();
}
}, err => {
this.postsService.openSnackBar(`Failed to delete ${category['name']}!`);
});
}
});
}
openEditCategoryDialog(category) {
this.dialog.open(EditCategoryDialogComponent, {
data: {
category: category
}
});
}
generateAPIKey() { generateAPIKey() {
this.postsService.generateNewAPIKey().subscribe(res => { this.postsService.generateNewAPIKey().subscribe(res => {
if (res['new_api_key']) { if (res['new_api_key']) {
@@ -162,7 +233,8 @@ export class SettingsComponent implements OnInit {
dialogTitle: 'Kill downloads', dialogTitle: 'Kill downloads',
dialogText: 'Are you sure you want to kill all downloads? Any subscription and non-subscription downloads will end immediately, though this operation may take a minute or so to complete.', dialogText: 'Are you sure you want to kill all downloads? Any subscription and non-subscription downloads will end immediately, though this operation may take a minute or so to complete.',
submitText: 'Kill all downloads', submitText: 'Kill all downloads',
doneEmitter: done doneEmitter: done,
warnSubmitColor: true
} }
}); });
done.subscribe(confirmed => { done.subscribe(confirmed => {

View File

@@ -7,9 +7,11 @@
"Downloader": { "Downloader": {
"path-audio": "audio/", "path-audio": "audio/",
"path-video": "video/", "path-video": "video/",
"use_youtubedl_archive": true, "use_youtubedl_archive": false,
"custom_args": "", "custom_args": "",
"safe_download_override": false "safe_download_override": false,
"include_thumbnail": false,
"include_metadata": true
}, },
"Extra": { "Extra": {
"title_top": "YoutubeDL-Material", "title_top": "YoutubeDL-Material",
@@ -33,12 +35,20 @@
"Subscriptions": { "Subscriptions": {
"allow_subscriptions": true, "allow_subscriptions": true,
"subscriptions_base_path": "subscriptions/", "subscriptions_base_path": "subscriptions/",
"subscriptions_check_interval": "30", "subscriptions_check_interval": "300",
"subscriptions_use_youtubedl_archive": true "subscriptions_use_youtubedl_archive": true
}, },
"Users": { "Users": {
"base_path": "users/", "base_path": "users/",
"allow_registration": true "allow_registration": true,
"auth_method": "internal",
"ldap_config": {
"url": "ldap://localhost:389",
"bindDN": "cn=root",
"bindCredentials": "secret",
"searchBase": "ou=passport-ldapauth",
"searchFilter": "(uid={{username}})"
}
}, },
"Advanced": { "Advanced": {
"use_default_downloading_agent": true, "use_default_downloading_agent": true,
@@ -47,7 +57,7 @@
"allow_advanced_download": true, "allow_advanced_download": true,
"jwt_expiration": 86400, "jwt_expiration": 86400,
"logger_level": "debug", "logger_level": "debug",
"use_cookies": true "use_cookies": false
} }
} }
} }