diff --git a/backend/app.js b/backend/app.js index 38ab93d..2d8bd7a 100644 --- a/backend/app.js +++ b/backend/app.js @@ -27,6 +27,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'); @@ -37,7 +38,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) @@ -80,6 +81,15 @@ 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); +categories_api.initialize(db, users_db, logger, db_api); + + +async function test() { + const test_cat = await categories_api.categorize(fs.readJSONSync('video/Claire Lost Her First Tooth!.info.json')); + console.log(test_cat); +} + +test(); // var GithubContent = require('github-content'); @@ -1115,6 +1125,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; @@ -1131,7 +1142,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(); @@ -1153,11 +1164,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); @@ -1445,7 +1467,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']; } @@ -2131,6 +2154,43 @@ 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: [] + }; + + db.get('categories').push(new_category).write(); + + res.send({ + new_category: new_category, + success: !!new_category + }); +}); + +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; diff --git a/backend/categories.js b/backend/categories.js new file mode 100644 index 0000000..ac3a3c7 --- /dev/null +++ b/backend/categories.js @@ -0,0 +1,112 @@ +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) { + return new Promise(resolve => { + let selected_category = null; + const categories = getCategories(); + if (!categories) { + logger.warn('Categories could not be found. Initializing categories...'); + db.assign({categories: []}).write(); + resolve(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']}`); + } + } + + resolve(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; +} + + + +module.exports = { + initialize: initialize, + categorize: categorize, +} \ No newline at end of file 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/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..fe6cec7 --- /dev/null +++ b/src/app/dialogs/edit-category-dialog/edit-category-dialog.component.html @@ -0,0 +1,47 @@ +

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

+ + + + + + +
Rules
+ + + + + + OR + AND + + + + + {{propertyOption.label}} + + + + + {{comparatorOption.label}} + + + + + + + + + + + + +
+ + + + + +
+ +
+
\ 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..928ff1c --- /dev/null +++ b/src/app/dialogs/edit-category-dialog/edit-category-dialog.component.ts @@ -0,0 +1,110 @@ +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)); + }, 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/posts.services.ts b/src/app/posts.services.ts index 67e3bd1..3e45e81 100644 --- a/src/app/posts.services.ts +++ b/src/app/posts.services.ts @@ -52,6 +52,7 @@ export class PostsService implements CanActivate { // global vars config = null; subscriptions = null; + categories = null; sidenav = null; constructor(private http: HttpClient, private router: Router, @Inject(DOCUMENT) private document: Document, @@ -296,6 +297,31 @@ 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) { + console.log(name); + return this.http.post(this.path + 'createCategory', {name: name}, 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); diff --git a/src/app/settings/settings.component.html b/src/app/settings/settings.component.html index 719683b..8e6fc0d 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 4a7684f..ec0c6d4 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,47 @@ export class SettingsComponent implements OnInit { }) } + dropCategory(event: CdkDragDrop) { + moveItemInArray(this.postsService.categories, event.previousIndex, event.currentIndex); + this.postsService.updateCategories(this.postsService.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); + } + }); + } + }); + } + + openEditCategoryDialog(category) { + this.dialog.open(EditCategoryDialogComponent, { + data: { + category: category + } + }); + } + generateAPIKey() { this.postsService.generateNewAPIKey().subscribe(res => { if (res['new_api_key']) {