diff --git a/backend/app.js b/backend/app.js index d58e350..f5138a6 100644 --- a/backend/app.js +++ b/backend/app.js @@ -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) => { diff --git a/backend/categories.js b/backend/categories.js new file mode 100644 index 0000000..d0b249a --- /dev/null +++ b/backend/categories.js @@ -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, +} \ No newline at end of file diff --git a/backend/db.js b/backend/db.js index 6f12be1..a2ddb7a 100644 --- a/backend/db.js +++ b/backend/db.js @@ -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) { diff --git a/backend/subscriptions.js b/backend/subscriptions.js index 104568d..5eafc4e 100644 --- a/backend/subscriptions.js +++ b/backend/subscriptions.js @@ -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, diff --git a/src/app/app.component.ts b/src/app/app.component.ts index d8540bb..a6c7630 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -116,6 +116,8 @@ export class AppComponent implements OnInit, AfterViewInit { if (this.allowSubscriptions) { this.postsService.reloadSubscriptions(); } + + this.postsService.reloadCategories(); } // theme stuff diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 69cba76..423f714 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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, diff --git a/src/app/components/logs-viewer/logs-viewer.component.ts b/src/app/components/logs-viewer/logs-viewer.component.ts index fbeeb46..5a218cc 100644 --- a/src/app/components/logs-viewer/logs-viewer.component.ts +++ b/src/app/components/logs-viewer/logs-viewer.component.ts @@ -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 => { diff --git a/src/app/components/recent-videos/recent-videos.component.ts b/src/app/components/recent-videos/recent-videos.component.ts index 8333f79..2eb7f36 100644 --- a/src/app/components/recent-videos/recent-videos.component.ts +++ b/src/app/components/recent-videos/recent-videos.component.ts @@ -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); diff --git a/src/app/dialogs/confirm-dialog/confirm-dialog.component.html b/src/app/dialogs/confirm-dialog/confirm-dialog.component.html index 5c6ef6b..ddfde17 100644 --- a/src/app/dialogs/confirm-dialog/confirm-dialog.component.html +++ b/src/app/dialogs/confirm-dialog/confirm-dialog.component.html @@ -6,7 +6,7 @@ - +
diff --git a/src/app/dialogs/confirm-dialog/confirm-dialog.component.ts b/src/app/dialogs/confirm-dialog/confirm-dialog.component.ts index 36892b8..31ec18a 100644 --- a/src/app/dialogs/confirm-dialog/confirm-dialog.component.ts +++ b/src/app/dialogs/confirm-dialog/confirm-dialog.component.ts @@ -15,12 +15,14 @@ export class ConfirmDialogComponent implements OnInit { doneEmitter: EventEmitter = null; onlyEmitOnDone = false; - + + warnSubmitColor = false; constructor(@Inject(MAT_DIALOG_DATA) public data: any, public dialogRef: MatDialogRef) { 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) { diff --git a/src/app/dialogs/edit-category-dialog/edit-category-dialog.component.html b/src/app/dialogs/edit-category-dialog/edit-category-dialog.component.html new file mode 100644 index 0000000..64aa2be --- /dev/null +++ b/src/app/dialogs/edit-category-dialog/edit-category-dialog.component.html @@ -0,0 +1,60 @@ +

Editing category {{category['name']}}

+ + + + + + + + +
Rules
+ + + + + + OR + AND + + + + + {{propertyOption.label}} + + + + + {{comparatorOption.label}} + + + + + + + + + + + + + + + + + + + + Documentation. + Path is relative to the config download path. Don't include extension. + + +
+ + + + + +
+ +
+
\ No newline at end of file diff --git a/src/app/dialogs/edit-category-dialog/edit-category-dialog.component.scss b/src/app/dialogs/edit-category-dialog/edit-category-dialog.component.scss new file mode 100644 index 0000000..53fcc70 --- /dev/null +++ b/src/app/dialogs/edit-category-dialog/edit-category-dialog.component.scss @@ -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; +} \ No newline at end of file diff --git a/src/app/dialogs/edit-category-dialog/edit-category-dialog.component.spec.ts b/src/app/dialogs/edit-category-dialog/edit-category-dialog.component.spec.ts new file mode 100644 index 0000000..71d64a9 --- /dev/null +++ b/src/app/dialogs/edit-category-dialog/edit-category-dialog.component.spec.ts @@ -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; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ EditCategoryDialogComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EditCategoryDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dialogs/edit-category-dialog/edit-category-dialog.component.ts b/src/app/dialogs/edit-category-dialog/edit-category-dialog.component.ts new file mode 100644 index 0000000..294414e --- /dev/null +++ b/src/app/dialogs/edit-category-dialog/edit-category-dialog.component.ts @@ -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); + } + +} diff --git a/src/app/file-card/file-card.component.ts b/src/app/file-card/file-card.component.ts index 280d131..68a8453 100644 --- a/src/app/file-card/file-card.component.ts +++ b/src/app/file-card/file-card.component.ts @@ -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); diff --git a/src/app/main/main.component.ts b/src/app/main/main.component.ts index b444e11..6ad4260 100644 --- a/src/app/main/main.component.ts +++ b/src/app/main/main.component.ts @@ -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(); }); diff --git a/src/app/player/player.component.ts b/src/app/player/player.component.ts index 3640d7ad..477c112 100644 --- a/src/app/player/player.component.ts +++ b/src/app/player/player.component.ts @@ -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}`; diff --git a/src/app/posts.services.ts b/src/app/posts.services.ts index 3336a7f..6e43e47 100644 --- a/src/app/posts.services.ts +++ b/src/app/posts.services.ts @@ -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() { diff --git a/src/app/settings/settings.component.html b/src/app/settings/settings.component.html index c81ca82..d5fccb6 100644 --- a/src/app/settings/settings.component.html +++ b/src/app/settings/settings.component.html @@ -115,15 +115,38 @@ -
+
Global custom args for downloads on the home page. Args are delimited using two commas like so: ,,
- -
+
+
+ +
+
+
+
Categories
+
+
+
+ {{category['name']}} + + + + +
+
+ +
+
+
+ +
+
+
Use youtube-dl archive
diff --git a/src/app/settings/settings.component.scss b/src/app/settings/settings.component.scss index 9ff0b70..f85952f 100644 --- a/src/app/settings/settings.component.scss +++ b/src/app/settings/settings.component.scss @@ -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); } \ No newline at end of file diff --git a/src/app/settings/settings.component.ts b/src/app/settings/settings.component.ts index 9771251..224a1b2 100644 --- a/src/app/settings/settings.component.ts +++ b/src/app/settings/settings.component.ts @@ -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) { + 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(); + 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 => { diff --git a/src/assets/default.json b/src/assets/default.json index e03372b..893896f 100644 --- a/src/assets/default.json +++ b/src/assets/default.json @@ -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 } } } \ No newline at end of file