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');
var config_api = require('./config.js');
var subscriptions_api = require('./subscriptions')
var categories_api = require('./categories');
const CONSTS = require('./consts')
const { spawn } = require('child_process')
const read_last_lines = require('read-last-lines');
@@ -36,7 +37,7 @@ const is_windows = process.platform === 'win32';
var app = express();
// database setup
const FileSync = require('lowdb/adapters/FileSync')
const FileSync = require('lowdb/adapters/FileSync');
const adapter = new FileSync('./appdata/db.json');
const db = low(adapter)
@@ -79,8 +80,7 @@ config_api.initialize(logger);
auth_api.initialize(users_db, logger);
db_api.initialize(db, users_db, logger);
subscriptions_api.initialize(db, users_db, logger, db_api);
// var GithubContent = require('github-content');
categories_api.initialize(db, users_db, logger, db_api);
// Set some defaults
db.defaults(
@@ -173,7 +173,6 @@ const subscription_timeouts = {};
// don't overwrite config if it already happened.. NOT
// let alreadyWritten = db.get('configWriteFlag').value();
let writeConfigMode = process.env.write_ytdl_config;
var config = null;
// checks if config exists, if not, a config is auto generated
config_api.configExistsCheck();
@@ -1077,6 +1076,7 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) {
var is_audio = type === 'audio';
var ext = is_audio ? '.mp3' : '.mp4';
var fileFolderPath = type === 'audio' ? audioFolderPath : videoFolderPath;
let category = null;
// prepend with user if needed
let multiUserMode = null;
@@ -1093,7 +1093,7 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) {
}
options.downloading_method = 'exec';
const downloadConfig = await generateArgs(url, type, options);
let downloadConfig = await generateArgs(url, type, options);
// adds download to download helper
const download_uid = uuid();
@@ -1115,11 +1115,22 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) {
updateDownloads();
// get video info prior to download
const info = await getVideoInfoByURL(url, downloadConfig, download);
let info = await getVideoInfoByURL(url, downloadConfig, download);
if (!info) {
resolve(false);
return;
} 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
download['_filename'] = info['_filename'];
download['filesize'] = utils.getExpectedFileSize(info);
@@ -1161,7 +1172,7 @@ async function downloadFileByURL_exec(url, type, options, sessionID = null) {
} catch(e) {
output_json = null;
}
var modified_file_name = output_json ? output_json['title'] : null;
if (!output_json) {
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']);
}
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
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);
}
@@ -1406,7 +1420,8 @@ async function generateArgs(url, type, options) {
}
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 {
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();
} else if (req.query.apiKey === admin_token) {
next();
} else if (req.query.apiKey && config_api.getConfigItem('ytdl_use_api_key')) {
if (req.query.apiKey === config_api.getConfigItem('ytdl_api_key')) {
next();
} else {
res.status(401).send('Invalid API key');
}
} else if (req.path.includes('/api/video/') || req.path.includes('/api/audio/')) {
} else if (req.query.apiKey && config_api.getConfigItem('ytdl_use_api_key') && req.query.apiKey === config_api.getConfigItem('ytdl_api_key')) {
next();
} else if (req.path.includes('/api/stream/')) {
next();
} else {
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 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') ||
req.path.includes('/api/audio') ||
req.path.includes('/api/video') ||
req.path.includes('/api/stream') ||
req.path.includes('/api/downloadFile'))) {
// check if shared video
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));
// add thumbnails if present
await addThumbnails(mp3s);
if (config_api.getConfigItem('ytdl_include_thumbnail')) {
// add thumbnails if present
await addThumbnails(mp3s);
}
res.send({
mp3s: mp3s,
@@ -1899,8 +1912,10 @@ app.get('/api/getMp4s', optionalJwt, async function(req, res) {
mp4s = JSON.parse(JSON.stringify(mp4s));
// add thumbnails if present
await addThumbnails(mp4s);
if (config_api.getConfigItem('ytdl_include_thumbnail')) {
// add thumbnails if present
await addThumbnails(mp4s);
}
res.send({
mp4s: mp4s,
@@ -1988,8 +2003,10 @@ app.post('/api/getAllFiles', optionalJwt, async function (req, res) {
files = JSON.parse(JSON.stringify(files));
// add thumbnails if present
await addThumbnails(files);
if (config_api.getConfigItem('ytdl_include_thumbnail')) {
// add thumbnails if present
await addThumbnails(files);
}
res.send({
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) => {
let name = req.body.name;
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) => {
let subID = req.body.id;
let subName = req.body.name; // if included, subID is optional
let user_uid = req.isAuthenticated() ? req.user.uid : null;
// 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) {
// failed to get subscription from db, send 400 error
@@ -2401,56 +2473,25 @@ app.post('/api/deletePlaylist', optionalJwt, async (req, res) => {
})
});
// deletes mp3 file
app.post('/api/deleteMp3', optionalJwt, async (req, res) => {
// var name = req.body.name;
// deletes non-subscription files
app.post('/api/deleteFile', optionalJwt, async (req, res) => {
var uid = req.body.uid;
var type = req.body.type;
var blacklistMode = req.body.blacklistMode;
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);
return;
}
var audio_obj = db.get('files.audio').find({uid: uid}).value();
var name = audio_obj.id;
var fullpath = audioFolderPath + name + ".mp3";
var file_obj = db.get(`files.${type}`).find({uid: uid}).value();
var name = file_obj.id;
var fullpath = file_obj ? file_obj.path : null;
var wasDeleted = false;
if (await fs.pathExists(fullpath))
{
deleteAudioFile(name, null, 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);
wasDeleted = type === 'audio' ? await deleteAudioFile(name, path.basename(fullpath), blacklistMode) : await deleteVideoFile(name, path.basename(fullpath), blacklistMode);
db.get('files.video').remove({uid: uid}).write();
// wasDeleted = true;
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) => {
let sub = req.body.sub;
let archive_dir = sub.archive;
@@ -2595,25 +2625,33 @@ app.post('/api/generateNewAPIKey', function (req, res) {
// 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;
let optionalParams = url_api.parse(req.url,true).query;
let id = decodeURIComponent(req.params.id);
let file_path = videoFolderPath + id + '.mp4';
if (req.isAuthenticated() || req.can_watch) {
let file_path = req.query.file_path ? decodeURIComponent(req.query.file_path) : null;
if (!file_path && (req.isAuthenticated() || req.can_watch)) {
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
if (optionalParams['subName']) {
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 {
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');
const isPlaylist = optionalParams['subPlaylist'];
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 fileSize = stat.size
const range = req.headers.range
@@ -2636,77 +2674,20 @@ app.get('/api/video/:id', optionalJwt, function(req , res){
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': 'video/mp4',
'Content-Type': mimetype,
}
res.writeHead(206, head);
file.pipe(res);
} else {
head = {
'Content-Length': fileSize,
'Content-Type': 'video/mp4',
'Content-Type': mimetype,
}
res.writeHead(200, head)
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
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);
}
function registerFileDB(file_path, type, multiUserMode = null, sub = null) {
function registerFileDB(file_path, type, multiUserMode = null, sub = null, customPath = null) {
let db_path = null;
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) {
logger.error(`Could not find associated JSON file for ${type} file ${file_id}`);
return false;
@@ -27,7 +27,7 @@ function registerFileDB(file_path, type, multiUserMode = null, sub = null) {
utils.fixVideoMetadataPerms(file_id, type, multiUserMode && multiUserMode.file_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 (multiUserMode) {

View File

@@ -430,6 +430,13 @@ function getSubscription(subID, user_uid = null) {
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) {
if (user_uid) {
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 = {
getSubscription : getSubscription,
getSubscriptionByName : getSubscriptionByName,
getAllSubscriptions : getAllSubscriptions,
updateSubscription : updateSubscription,
subscribe : subscribe,

View File

@@ -116,6 +116,8 @@ export class AppComponent implements OnInit, AfterViewInit {
if (this.allowSubscriptions) {
this.postsService.reloadSubscriptions();
}
this.postsService.reloadCategories();
}
// 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 { EditSubscriptionDialogComponent } from './dialogs/edit-subscription-dialog/edit-subscription-dialog.component';
import { CustomPlaylistsComponent } from './components/custom-playlists/custom-playlists.component';
import { EditCategoryDialogComponent } from './dialogs/edit-category-dialog/edit-category-dialog.component';
registerLocaleData(es, 'es');
@@ -123,7 +124,8 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
UnifiedFileCardComponent,
RecentVideosComponent,
EditSubscriptionDialogComponent,
CustomPlaylistsComponent
CustomPlaylistsComponent,
EditCategoryDialogComponent
],
imports: [
CommonModule,

View File

@@ -61,7 +61,8 @@ export class LogsViewerComponent implements OnInit {
data: {
dialogTitle: 'Clear logs',
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 => {

View File

@@ -210,7 +210,7 @@ export class RecentVideosComponent implements OnInit {
if (!this.postsService.config.Extra.file_manager_enabled) {
// tell server to delete the file once downloaded
this.postsService.deleteFile(name, false).subscribe(delRes => {
this.postsService.deleteFile(name, type).subscribe(delRes => {
// reload mp4s
this.getAllFiles();
});
@@ -233,7 +233,7 @@ export class RecentVideosComponent implements OnInit {
}
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) {
this.postsService.openSnackBar('Delete success!', 'OK.');
this.files.splice(index, 1);

View File

@@ -6,7 +6,7 @@
</mat-dialog-content>
<mat-dialog-actions>
<!-- 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">
<mat-spinner [diameter]="25"></mat-spinner>
</div>

View File

@@ -15,12 +15,14 @@ export class ConfirmDialogComponent implements OnInit {
doneEmitter: EventEmitter<any> = null;
onlyEmitOnDone = false;
warnSubmitColor = false;
constructor(@Inject(MAT_DIALOG_DATA) public data: any, public dialogRef: MatDialogRef<ConfirmDialogComponent>) {
if (this.data.dialogTitle) { this.dialogTitle = this.data.dialogTitle };
if (this.data.dialogText) { this.dialogText = this.data.dialogText };
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
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) {
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) {
this.openSnackBar('Delete success!', 'OK.');
this.removeFile.emit(this.name);

View File

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

View File

@@ -124,6 +124,8 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
this.getFile();
} else if (this.id) {
this.getPlaylistFiles();
} else if (this.subscriptionName) {
this.getSubscription();
}
if (this.url) {
@@ -139,7 +141,7 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
this.currentItem = this.playlist[0];
this.currentIndex = 0;
this.show_player = true;
} else if (this.subscriptionName || this.fileNames) {
} else if (this.fileNames && !this.subscriptionName) {
this.show_player = true;
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() {
this.postsService.getPlaylist(this.id, null, this.uuid).subscribe(res => {
if (res['playlist']) {
@@ -202,23 +223,26 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
const fileName = this.fileNames[i];
let baseLocation = 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
const uuid_str = this.uuid ? `&uuid=${this.uuid}` : '';
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 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) {
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}`; }
} else if (this.is_shared) {
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
config = null;
subscriptions = null;
categories = null;
sidenav = null;
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);
}
deleteFile(uid: string, isAudio: boolean, blacklistMode = false) {
if (isAudio) {
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);
}
deleteFile(uid: string, type: string, blacklistMode = false) {
return this.http.post(this.path + 'deleteFile', {uid: uid, type: type, blacklistMode: blacklistMode}, this.httpOptions);
}
getMp3s() {
@@ -310,6 +307,34 @@ export class PostsService implements CanActivate {
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) {
return this.http.post(this.path + 'subscribe', {url: url, name: name, timerange: timerange, streamingOnly: streamingOnly,
audioOnly: audioOnly, customArgs: customArgs, customFileOutput: customFileOutput}, this.httpOptions);
@@ -328,8 +353,8 @@ export class PostsService implements CanActivate {
file_uid: file_uid}, this.httpOptions)
}
getSubscription(id) {
return this.http.post(this.path + 'getSubscription', {id: id}, this.httpOptions);
getSubscription(id, name = null) {
return this.http.post(this.path + 'getSubscription', {id: id, name: name}, this.httpOptions);
}
getAllSubscriptions() {

View File

@@ -115,15 +115,38 @@
</mat-form-field>
</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">
<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>
<button class="args-edit-button" (click)="openArgsModifierDialog()" mat-icon-button><mat-icon>edit</mat-icon></button>
</mat-form-field>
</div>
<div class="col-12 mt-5">
</div>
</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>
</div>

View File

@@ -30,4 +30,55 @@
margin-left: 15px;
margin-bottom: 12px;
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 { CookiesUploaderDialogComponent } from 'app/dialogs/cookies-uploader-dialog/cookies-uploader-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({
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() {
this.postsService.generateNewAPIKey().subscribe(res => {
if (res['new_api_key']) {
@@ -162,7 +233,8 @@ export class SettingsComponent implements OnInit {
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.',
submitText: 'Kill all downloads',
doneEmitter: done
doneEmitter: done,
warnSubmitColor: true
}
});
done.subscribe(confirmed => {

View File

@@ -7,9 +7,11 @@
"Downloader": {
"path-audio": "audio/",
"path-video": "video/",
"use_youtubedl_archive": true,
"use_youtubedl_archive": false,
"custom_args": "",
"safe_download_override": false
"safe_download_override": false,
"include_thumbnail": false,
"include_metadata": true
},
"Extra": {
"title_top": "YoutubeDL-Material",
@@ -33,12 +35,20 @@
"Subscriptions": {
"allow_subscriptions": true,
"subscriptions_base_path": "subscriptions/",
"subscriptions_check_interval": "30",
"subscriptions_check_interval": "300",
"subscriptions_use_youtubedl_archive": true
},
"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": {
"use_default_downloading_agent": true,
@@ -47,7 +57,7 @@
"allow_advanced_download": true,
"jwt_expiration": 86400,
"logger_level": "debug",
"use_cookies": true
"use_cookies": false
}
}
}