diff --git a/Public API v1.yaml b/Public API v1.yaml index ad43d48..0ca76da 100644 --- a/Public API v1.yaml +++ b/Public API v1.yaml @@ -666,11 +666,367 @@ paths: schema: $ref: '#/components/schemas/GetDownloadRequest' description: '' - description: "Gets a single download using its download_id and session_id. session_id is the device fingerprint. If none was provided at the time of download, then set session_id is 'undeclared'." + description: "Gets a single download using its download_id." security: - Auth query parameter: [] tags: - downloader + /api/pauseDownload: + post: + summary: Pauses one download + operationId: post-api-pause-download + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessObject' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GetDownloadRequest' + description: '' + description: "Pause a single download using its download_id." + security: + - Auth query parameter: [] + tags: + - downloader + /api/pauseAllDownloads: + post: + tags: + - downloader + summary: Pauses all downloads + operationId: post-api-pause-all-downloads + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessObject' + security: + - Auth query parameter: [] + /api/resumeDownload: + post: + summary: Resume one download + operationId: post-api-resume-download + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessObject' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GetDownloadRequest' + description: '' + description: "Resume a single download using its download_id." + security: + - Auth query parameter: [] + tags: + - downloader + /api/resumeAllDownloads: + post: + tags: + - downloader + summary: Resumes all downloads + operationId: post-api-resume-all-downloads + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessObject' + security: + - Auth query parameter: [] + /api/restartDownload: + post: + summary: Restart one download + operationId: post-api-restart-download + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessObject' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GetDownloadRequest' + description: '' + description: "Restart a single download using its download_id." + security: + - Auth query parameter: [] + tags: + - downloader + /api/cancelDownload: + post: + summary: Cancel one download + operationId: post-api-cancel-download + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessObject' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GetDownloadRequest' + description: '' + description: "Cancel a single download using its download_id." + security: + - Auth query parameter: [] + tags: + - downloader + /api/clearDownload: + post: + summary: Clear one download + operationId: post-api-clear-download + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessObject' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GetDownloadRequest' + description: '' + description: "Clears a single download from the downloaded list using its download_id." + security: + - Auth query parameter: [] + tags: + - downloader + /api/clearFinishedDownloads: + post: + tags: + - downloader + summary: Clear finished downloads + operationId: post-api-clear-finished-downloads + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessObject' + security: + - Auth query parameter: [] + /api/getTask: + post: + summary: Get info for one task + operationId: post-api-get-task + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/GetTaskResponse' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GetTaskRequest' + description: '' + description: "Gets a single task using its key." + security: + - Auth query parameter: [] + tags: + - tasks + /api/getTasks: + post: + tags: + - tasks + summary: Get tasks + operationId: post-api-get-tasks + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/GetAllTasksResponse' + /api/resetTasks: + post: + tags: + - tasks + summary: Resets all tasks + operationId: post-api-reset-tasks + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessObject' + security: + - Auth query parameter: [] + /api/runTask: + post: + tags: + - tasks + summary: Runs one task + operationId: post-api-run-task + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessObject' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GetTaskRequest' + /api/confirmTask: + post: + tags: + - tasks + summary: Confirms a task + operationId: post-api-confirm-task + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessObject' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GetTaskRequest' + /api/cancelTask: + post: + tags: + - tasks + summary: Cancels a task + operationId: post-api-cancel-task + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessObject' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GetTaskRequest' + /api/updateTaskSchedule: + post: + tags: + - tasks + summary: Updates task schedule + operationId: post-api-update-task-schedule + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessObject' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateTaskScheduleRequest' + /api/updateTaskData: + post: + tags: + - tasks + summary: Updates task data + operationId: post-api-update-task-data + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessObject' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateTaskDataRequest' + /api/getDBBackups: + post: + tags: + - tasks + summary: Get database backups + operationId: post-api-get-database-backups + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/GetDBBackupsResponse' + requestBody: + content: + application/json: + schema: + type: object + /api/restoreDBBackup: + post: + tags: + - tasks + summary: Restore database backup + operationId: post-api-restore-database-backup + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessObject' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RestoreDBBackupRequest' /api/auth/login: post: summary: Login @@ -1231,6 +1587,59 @@ components: type: array items: $ref: '#/components/schemas/Download' + GetTaskRequest: + type: object + properties: + task_key: + type: string + required: + - task_key + UpdateTaskScheduleRequest: + type: object + properties: + task_key: + type: string + new_schedule: + $ref: '#/components/schemas/Schedule' + required: + - task_key + - new_schedule + UpdateTaskDataRequest: + type: object + properties: + task_key: + type: string + new_data: + type: object + required: + - task_key + - new_data + GetTaskResponse: + type: object + properties: + task: + $ref: '#/components/schemas/Task' + GetAllTasksResponse: + type: object + properties: + tasks: + type: array + items: + $ref: '#/components/schemas/Task' + GetDBBackupsResponse: + type: object + properties: + tasks: + type: array + items: + $ref: '#/components/schemas/DBBackup' + RestoreDBBackupRequest: + type: object + required: + - file_name + properties: + file_name: + type: string GetMp3sResponse: required: - mp3s @@ -1506,6 +1915,10 @@ components: type: string playlist_id: type: string + url: + type: string + type: + $ref: '#/components/schemas/FileType' DownloadArchiveRequest: required: - sub @@ -1967,6 +2380,77 @@ components: type: string sub_name: type: string + Task: + required: + - key + - last_ran + - last_confirmed + - running + - confirming + - data + - error + - schedule + type: object + properties: + key: + type: string + last_ran: + type: number + last_confirmed: + type: number + running: + type: boolean + confirming: + type: boolean + data: + type: object + error: + type: string + schedule: + type: object + Schedule: + required: + - type + - data + type: object + properties: + type: + type: string + enum: + - timestamp + - recurring + data: + type: object + properties: + dayOfWeek: + type: array + items: + type: number + hour: + type: number + minute: + type: number + timestamp: + type: number + DBBackup: + required: + - name + - timestamp + - size + - source + type: object + properties: + name: + type: string + timestamp: + type: number + size: + type: number + source: + type: string + enum: + - local + - remote SubscriptionRequestData: required: - id diff --git a/backend/app.js b/backend/app.js index b12d502..2882663 100644 --- a/backend/app.js +++ b/backend/app.js @@ -13,7 +13,6 @@ const unzipper = require('unzipper'); const db_api = require('./db'); const utils = require('./utils') const low = require('lowdb') -const ProgressBar = require('progress'); const fetch = require('node-fetch'); const URL = require('url').URL; const CONSTS = require('./consts') @@ -28,11 +27,11 @@ const youtubedl = require('youtube-dl'); const logger = require('./logger'); const config_api = require('./config.js'); const downloader_api = require('./downloader'); +const tasks_api = require('./tasks'); const subscriptions_api = require('./subscriptions'); const categories_api = require('./categories'); const twitch_api = require('./twitch'); - -const is_windows = process.platform === 'win32'; +const youtubedl_api = require('./youtube-dl'); var app = express(); @@ -60,9 +59,6 @@ const admin_token = '4241b401-7236-493e-92b5-b72696b9d853'; config_api.initialize(); db_api.initialize(db, users_db); auth_api.initialize(db_api); -downloader_api.initialize(db_api); -subscriptions_api.initialize(db_api, downloader_api); -categories_api.initialize(db_api); // Set some defaults db.defaults( @@ -359,34 +355,6 @@ async function downloadReleaseFiles(tag) { }); } -// helper function to download file using fetch -async function fetchFile(url, path, file_label) { - var len = null; - const res = await fetch(url); - - len = parseInt(res.headers.get("Content-Length"), 10); - - var bar = new ProgressBar(` Downloading ${file_label} [:bar] :percent :etas`, { - complete: '=', - incomplete: ' ', - width: 20, - total: len - }); - const fileStream = fs.createWriteStream(path); - await new Promise((resolve, reject) => { - res.body.pipe(fileStream); - res.body.on("error", (err) => { - reject(err); - }); - res.body.on('data', function (chunk) { - bar.tick(chunk.length); - }); - fileStream.on("finish", function() { - resolve(); - }); - }); - } - async function downloadReleaseZip(tag) { return new Promise(async resolve => { // get name of zip file, which depends on the version @@ -397,7 +365,7 @@ async function downloadReleaseZip(tag) { let output_path = path.join(__dirname, `youtubedl-material-release-${tag}.zip`); // download zip from release - await fetchFile(latest_zip_link, output_path, 'update ' + tag); + await utils.fetchFile(latest_zip_link, output_path, 'update ' + tag); resolve(true); }); @@ -568,8 +536,6 @@ async function loadConfig() { watchSubscriptionsInterval(); } - db_api.importUnregisteredFiles(); - // start the server here startServer(); @@ -710,156 +676,8 @@ async function getUrlInfos(url) { async function startYoutubeDL() { // auto update youtube-dl - await autoUpdateYoutubeDL(); -} - -// auto updates the underlying youtube-dl binary, not YoutubeDL-Material -async function autoUpdateYoutubeDL() { - const download_sources = { - 'youtube-dl': { - 'tags_url': 'https://api.github.com/repos/ytdl-org/youtube-dl/tags', - 'func': downloadLatestYoutubeDLBinary - }, - 'youtube-dlc': { - 'tags_url': 'https://api.github.com/repos/blackjack4494/yt-dlc/tags', - 'func': downloadLatestYoutubeDLCBinary - }, - 'yt-dlp': { - 'tags_url': 'https://api.github.com/repos/yt-dlp/yt-dlp/tags', - 'func': downloadLatestYoutubeDLPBinary - } - } - return new Promise(async resolve => { - const default_downloader = config_api.getConfigItem('ytdl_default_downloader'); - const tags_url = download_sources[default_downloader]['tags_url']; - // get current version - let current_app_details_exists = fs.existsSync(CONSTS.DETAILS_BIN_PATH); - if (!current_app_details_exists) { - logger.warn(`Failed to get youtube-dl binary details at location '${CONSTS.DETAILS_BIN_PATH}'. Generating file...`); - fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, {"version":"2020.00.00", "downloader": default_downloader}); - } - let current_app_details = JSON.parse(fs.readFileSync(CONSTS.DETAILS_BIN_PATH)); - let current_version = current_app_details['version']; - let current_downloader = current_app_details['downloader']; - let stored_binary_path = current_app_details['path']; - if (!stored_binary_path || typeof stored_binary_path !== 'string') { - // logger.info(`INFO: Failed to get youtube-dl binary path at location: ${CONSTS.DETAILS_BIN_PATH}, attempting to guess actual path...`); - const guessed_base_path = 'node_modules/youtube-dl/bin/'; - const guessed_file_path = guessed_base_path + 'youtube-dl' + (is_windows ? '.exe' : ''); - if (fs.existsSync(guessed_file_path)) { - stored_binary_path = guessed_file_path; - // logger.info('INFO: Guess successful! Update process continuing...') - } else { - logger.error(`Guess '${guessed_file_path}' is not correct. Cancelling update check. Verify that your youtube-dl binaries exist by running npm install.`); - resolve(false); - return; - } - } - - // got version, now let's check the latest version from the youtube-dl API - - - fetch(tags_url, {method: 'Get'}) - .then(async res => res.json()) - .then(async (json) => { - // check if the versions are different - if (!json || !json[0]) { - logger.error(`Failed to check ${default_downloader} version for an update.`) - resolve(false); - return false; - } - const latest_update_version = json[0]['name']; - if (current_version !== latest_update_version || default_downloader !== current_downloader) { - // versions different or different downloader is being used, download new update - logger.info(`Found new update for ${default_downloader}. Updating binary...`); - try { - await checkExistsWithTimeout(stored_binary_path, 10000); - } catch(e) { - logger.error(`Failed to update ${default_downloader} - ${e}`); - } - - await download_sources[default_downloader]['func'](latest_update_version); - - resolve(true); - } else { - resolve(false); - } - }) - .catch(err => { - logger.error(`Failed to check ${default_downloader} version for an update.`) - logger.error(err); - resolve(false); - return false; - }); - }); -} - -async function downloadLatestYoutubeDLBinary(new_version) { - const file_ext = is_windows ? '.exe' : ''; - - const download_url = `https://github.com/ytdl-org/youtube-dl/releases/latest/download/youtube-dl${file_ext}`; - const output_path = `node_modules/youtube-dl/bin/youtube-dl${file_ext}`; - - await fetchFile(download_url, output_path, `youtube-dl ${new_version}`); - - updateDetailsJSON(new_version, 'youtube-dl'); -} - -async function downloadLatestYoutubeDLCBinary(new_version) { - const file_ext = is_windows ? '.exe' : ''; - - const download_url = `https://github.com/blackjack4494/yt-dlc/releases/latest/download/youtube-dlc${file_ext}`; - const output_path = `node_modules/youtube-dl/bin/youtube-dl${file_ext}`; - - await fetchFile(download_url, output_path, `youtube-dlc ${new_version}`); - - updateDetailsJSON(new_version, 'youtube-dlc'); -} - -async function downloadLatestYoutubeDLPBinary(new_version) { - const file_ext = is_windows ? '.exe' : ''; - - const download_url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp${file_ext}`; - const output_path = `node_modules/youtube-dl/bin/youtube-dl${file_ext}`; - - await fetchFile(download_url, output_path, `yt-dlp ${new_version}`); - - updateDetailsJSON(new_version, 'yt-dlp'); -} - -function updateDetailsJSON(new_version, downloader) { - const details_json = fs.readJSONSync(CONSTS.DETAILS_BIN_PATH); - if (new_version) details_json['version'] = new_version; - details_json['downloader'] = downloader; - fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, details_json); -} - -async function checkExistsWithTimeout(filePath, timeout) { - return new Promise(function (resolve, reject) { - - var timer = setTimeout(function () { - if (watcher) watcher.close(); - reject(new Error('File did not exists and was not created during the timeout.')); - }, timeout); - - fs.access(filePath, fs.constants.R_OK, function (err) { - if (!err) { - clearTimeout(timer); - if (watcher) watcher.close(); - resolve(); - } - }); - - var dir = path.dirname(filePath); - var basename = path.basename(filePath); - var watcher = fs.watch(dir, function (eventType, filename) { - if (eventType === 'rename' && filename === basename) { - clearTimeout(timer); - if (watcher) watcher.close(); - resolve(); - } - }); - }); + const update_available = await youtubedl_api.checkForYoutubeDLUpdate(); + if (update_available) await youtubedl_api.updateYoutubeDL(update_available); } app.use(function(req, res, next) { @@ -1878,6 +1696,104 @@ app.post('/api/cancelDownload', optionalJwt, async (req, res) => { res.send({success: success}); }); +// tasks + +app.post('/api/getTasks', optionalJwt, async (req, res) => { + const tasks = await db_api.getRecords('tasks'); + for (let task of tasks) { + if (task['schedule']) task['next_invocation'] = tasks_api.TASKS[task['key']]['job'].nextInvocation().getTime(); + } + res.send({tasks: tasks}); +}); + +app.post('/api/resetTasks', optionalJwt, async (req, res) => { + const tasks_keys = Object.keys(tasks_api.TASKS); + for (let i = 0; i < tasks_keys.length; i++) { + const task_key = tasks_keys[i]; + tasks_api.TASKS[task_key]['job'] = null; + } + await db_api.removeAllRecords('tasks'); + await tasks_api.setupTasks(); + res.send({success: true}); +}); + +app.post('/api/getTask', optionalJwt, async (req, res) => { + const task_key = req.body.task_key; + const task = await db_api.getRecord('tasks', {key: task_key}); + if (task['schedule']) task['next_invocation'] = tasks_api.TASKS[task_key]['job'].nextInvocation().getTime(); + res.send({task: task}); +}); + +app.post('/api/runTask', optionalJwt, async (req, res) => { + const task_key = req.body.task_key; + const task = await db_api.getRecord('tasks', {key: task_key}); + + let success = true; + if (task['running'] || task['confirming']) success = false; + else await tasks_api.executeRun(task_key); + + res.send({success: success}); +}); + +app.post('/api/confirmTask', optionalJwt, async (req, res) => { + const task_key = req.body.task_key; + const task = await db_api.getRecord('tasks', {key: task_key}); + + let success = true; + if (task['running'] || task['confirming'] || !task['data']) success = false; + else await tasks_api.executeConfirm(task_key); + + res.send({success: success}); +}); + +app.post('/api/updateTaskSchedule', optionalJwt, async (req, res) => { + const task_key = req.body.task_key; + const new_schedule = req.body.new_schedule; + + await tasks_api.updateTaskSchedule(task_key, new_schedule); + + res.send({success: true}); +}); + +app.post('/api/updateTaskData', optionalJwt, async (req, res) => { + const task_key = req.body.task_key; + const new_data = req.body.new_data; + + const success = await db_api.updateRecord('tasks', {key: task_key}, {data: new_data}); + + res.send({success: success}); +}); + +app.post('/api/getDBBackups', optionalJwt, async (req, res) => { + const backup_dir = path.join('appdata', 'db_backup'); + const db_backups = []; + + const candidate_backups = await utils.recFindByExt(backup_dir, 'bak', null, [], false); + for (let i = 0; i < candidate_backups.length; i++) { + const candidate_backup = candidate_backups[i]; + + // must have specific format + if (candidate_backup.split('.').length - 1 !== 4) continue; + + const candidate_backup_path = candidate_backup; + const stats = fs.statSync(candidate_backup_path); + + db_backups.push({ name: path.basename(candidate_backup), timestamp: parseInt(candidate_backup.split('.')[2]), size: stats.size, source: candidate_backup.includes('local') ? 'local' : 'remote' }); + } + + db_backups.sort((a,b) => b.timestamp - a.timestamp); + + res.send({db_backups: db_backups}); +}); + +app.post('/api/restoreDBBackup', optionalJwt, async (req, res) => { + const file_name = req.body.file_name; + + const success = await db_api.restoreDB(file_name); + + res.send({success: success}); +}); + // logs management app.post('/api/logs', optionalJwt, async function(req, res) { diff --git a/backend/authentication/auth.js b/backend/authentication/auth.js index 7aad070..de54a0b 100644 --- a/backend/authentication/auth.js +++ b/backend/authentication/auth.js @@ -1,6 +1,7 @@ const config_api = require('../config'); const consts = require('../consts'); const logger = require('../logger'); +const db_api = require('../db'); const jwt = require('jsonwebtoken'); const { uuid } = require('uuidv4'); @@ -12,15 +13,12 @@ var JwtStrategy = require('passport-jwt').Strategy, ExtractJwt = require('passport-jwt').ExtractJwt; // other required vars -let db_api = null; let SERVER_SECRET = null; let JWT_EXPIRATION = null; let opts = null; let saltRounds = null; -exports.initialize = function(db_api) { - setDB(db_api); - +exports.initialize = function() { /************************* * Authentication module ************************/ @@ -51,10 +49,6 @@ exports.initialize = function(db_api) { })); } -function setDB(input_db_api) { - db_api = input_db_api; -} - exports.passport = require('passport'); exports.passport.serializeUser(function(user, done) { diff --git a/backend/categories.js b/backend/categories.js index 269ae9c..2236d9f 100644 --- a/backend/categories.js +++ b/backend/categories.js @@ -1,14 +1,6 @@ const utils = require('./utils'); const logger = require('./logger'); - -var db_api = null; - -function setDB(input_db_api) { db_api = input_db_api } - -function initialize(input_db_api) { - setDB(input_db_api); -} - +const db_api = require('./db'); /* Categories: @@ -137,7 +129,6 @@ function applyCategoryRules(file_json, rules, category_name) { // } module.exports = { - initialize: initialize, categorize: categorize, getCategories: getCategories, getCategoriesAsPlaylists: getCategoriesAsPlaylists diff --git a/backend/db.js b/backend/db.js index 18f9064..591b92b 100644 --- a/backend/db.js +++ b/backend/db.js @@ -54,6 +54,10 @@ const tables = { name: 'download_queue', primary_key: 'uid' }, + tasks: { + name: 'tasks', + primary_key: 'key' + }, test: { name: 'test' } @@ -300,6 +304,7 @@ exports.getFileDirectoriesAndDBs = async () => { } exports.importUnregisteredFiles = async () => { + const imported_files = []; const dirs_to_check = await exports.getFileDirectoriesAndDBs(); // run through check list and check each file to see if it's missing from the db @@ -316,12 +321,17 @@ exports.importUnregisteredFiles = async () => { const file_is_registered = !!(files_with_same_url.find(file_with_same_url => path.resolve(file_with_same_url.path) === path.resolve(file.path))); if (!file_is_registered) { // add additional info - await exports.registerFileDB(file['path'], dir_to_check.type, dir_to_check.user_uid, null, dir_to_check.sub_id, null); - logger.verbose(`Added discovered file to the database: ${file.id}`); + const file_obj = await exports.registerFileDB(file['path'], dir_to_check.type, dir_to_check.user_uid, null, dir_to_check.sub_id, null); + if (file_obj) { + imported_files.push(file_obj['uid']); + logger.verbose(`Added discovered file to the database: ${file.id}`); + } else { + logger.error(`Failed to import ${file['path']} automatically.`); + } } } } - + return imported_files; } exports.addMetadataPropertyToDB = async (property_key) => { @@ -744,6 +754,66 @@ exports.removeRecord = async (table, filter_obj) => { return !!(output['result']['ok']); } +// exports.removeRecordsByUIDBulk = async (table, uids) => { +// // local db override +// if (using_local_db) { +// applyFilterLocalDB(local_db.get(table), filter_obj, 'remove').write(); +// return true; +// } + +// const table_collection = database.collection(table); + +// let bulk = table_collection.initializeOrderedBulkOp(); // Initialize the Ordered Batch + +// const item_ids_to_remove = + +// for (let i = 0; i < item_ids_to_update.length; i++) { +// const item_id_to_update = item_ids_to_update[i]; +// bulk.find({[key_label]: item_id_to_update }).updateOne({ +// "$set": update_obj[item_id_to_update] +// }); +// } + +// const output = await bulk.execute(); +// return !!(output['result']['ok']); +// } + + +exports.findDuplicatesByKey = async (table, key) => { + let duplicates = []; + if (using_local_db) { + // this can probably be optimized + const all_records = await exports.getRecords(table); + const existing_records = {}; + for (let i = 0; i < all_records.length; i++) { + const record = all_records[i]; + const value = record[key]; + + if (existing_records[value]) { + duplicates.push(record); + } + + existing_records[value] = true; + } + return duplicates; + } + + const duplicated_values = await database.collection(table).aggregate([ + {"$group" : { "_id": `$${key}`, "count": { "$sum": 1 } } }, + {"$match": {"_id" :{ "$ne" : null } , "count" : {"$gt": 1} } }, + {"$project": {[key] : "$_id", "_id" : 0} } + ]).toArray(); + + for (let i = 0; i < duplicated_values.length; i++) { + const duplicated_value = duplicated_values[i]; + const duplicated_records = await exports.getRecords(table, duplicated_value, false); + if (duplicated_records.length > 1) { + duplicates = duplicates.concat(duplicated_records.slice(1, duplicated_records.length)); + } + } + return duplicates; +} + exports.removeAllRecords = async (table = null, filter_obj = null) => { // local db override const tables_to_remove = table ? [table] : tables_list; @@ -917,6 +987,52 @@ const createDownloadsRecords = (downloads) => { return new_downloads; } +exports.backupDB = async () => { + const backup_dir = path.join('appdata', 'db_backup'); + fs.ensureDirSync(backup_dir); + const backup_file_name = `${using_local_db ? 'local' : 'remote'}_db.json.${Date.now()/1000}.bak`; + const path_to_backups = path.join(backup_dir, backup_file_name); + + logger.verbose(`Backing up ${using_local_db ? 'local' : 'remote'} DB to ${path_to_backups}`); + + const table_to_records = {}; + for (let i = 0; i < tables_list.length; i++) { + const table = tables_list[i]; + table_to_records[table] = await exports.getRecords(table); + } + + fs.writeJsonSync(path_to_backups, table_to_records); + + return backup_file_name; +} + +exports.restoreDB = async (file_name) => { + const path_to_backup = path.join('appdata', 'db_backup', file_name); + + logger.debug('Reading database backup file.'); + const table_to_records = fs.readJSONSync(path_to_backup); + + if (!table_to_records) { + logger.error(`Failed to restore DB! Backup file '${path_to_backup}' could not be read.`); + return false; + } + + logger.debug('Clearing database.'); + await exports.removeAllRecords(); + + logger.debug('Database cleared! Beginning restore.'); + let success = true; + for (let i = 0; i < tables_list.length; i++) { + const table = tables_list[i]; + if (!table_to_records[table] || table_to_records[table].length === 0) continue; + success &= await exports.bulkInsertRecordsIntoTable(table, table_to_records[table]); + } + + logger.debug('Restore finished!'); + + return success; +} + exports.transferDB = async (local_to_remote) => { const table_to_records = {}; for (let i = 0; i < tables_list.length; i++) { @@ -926,9 +1042,8 @@ exports.transferDB = async (local_to_remote) => { using_local_db = !local_to_remote; if (local_to_remote) { - // backup local DB - logger.debug('Backup up Local DB...'); - await fs.copyFile('appdata/local_db.json', `appdata/local_db.json.${Date.now()/1000}.bak`); + logger.debug('Backup up DB...'); + await exports.backupDB(); const db_connected = await exports.connectToDB(5, true); if (!db_connected) { logger.error('Failed to transfer database - could not connect to MongoDB. Verify that your connection URL is valid.'); diff --git a/backend/downloader.js b/backend/downloader.js index e151f87..f139a1f 100644 --- a/backend/downloader.js +++ b/backend/downloader.js @@ -14,26 +14,19 @@ const twitch_api = require('./twitch'); const { create } = require('xmlbuilder2'); const categories_api = require('./categories'); const utils = require('./utils'); - -let db_api = null; +const db_api = require('./db'); const mutex = new Mutex(); let should_check_downloads = true; const archivePath = path.join(__dirname, 'appdata', 'archives'); -function setDB(input_db_api) { db_api = input_db_api } - -exports.initialize = (input_db_api) => { - setDB(input_db_api); - categories_api.initialize(db_api); - if (db_api.database_initialized) { - setupDownloads(); - } else { - db_api.database_initialized_bs.subscribe(init => { - if (init) setupDownloads(); - }); - } +if (db_api.database_initialized) { + setupDownloads(); +} else { + db_api.database_initialized_bs.subscribe(init => { + if (init) setupDownloads(); + }); } exports.createDownload = async (url, type, options, user_uid = null, sub_id = null, sub_name = null) => { diff --git a/backend/package-lock.json b/backend/package-lock.json index d8d0e37..76daecd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -599,6 +599,15 @@ } } }, + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, "camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", @@ -909,6 +918,15 @@ "readable-stream": "^3.4.0" } }, + "cron-parser": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-3.5.0.tgz", + "integrity": "sha512-wyVZtbRs6qDfFd8ap457w3XVntdvqcwBGxBoTvJQH9KGVKL/fB+h2k3C8AqiVxvUQKN1Ps/Ns46CNViOpVDhfQ==", + "requires": { + "is-nan": "^1.3.2", + "luxon": "^1.26.0" + } + }, "cross-spawn": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.1.tgz", @@ -968,6 +986,15 @@ "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz", "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==" }, + "define-properties": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", + "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==", + "requires": { + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + } + }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1358,11 +1385,26 @@ "rimraf": "2" } }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" }, + "get-intrinsic": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", + "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + } + }, "get-stream": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.1.0.tgz", @@ -1460,11 +1502,32 @@ "har-schema": "^2.0.0" } }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "requires": { + "function-bind": "^1.1.1" + } + }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" }, + "has-property-descriptors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", + "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", + "requires": { + "get-intrinsic": "^1.1.1" + } + }, + "has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" + }, "has-yarn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-2.1.0.tgz", @@ -1635,6 +1698,15 @@ "is-path-inside": "^3.0.1" } }, + "is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + } + }, "is-npm": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-4.0.0.tgz", @@ -2034,6 +2106,11 @@ } } }, + "long-timeout": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", + "integrity": "sha1-lyHXiLR+C8taJMLivuGg2lXatRQ=" + }, "lowdb": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/lowdb/-/lowdb-1.0.0.tgz", @@ -2051,6 +2128,11 @@ "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==" }, + "luxon": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.28.0.tgz", + "integrity": "sha512-TfTiyvZhwBYM/7QdAVDh+7dBTBA29v4ik0Ce9zda3Mnf8on1S5KJI8P2jKFZ8+5C0jhmr0KwJEO/Wdpm0VeWJQ==" + }, "make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -2407,6 +2489,16 @@ "iconv-lite": "^0.4.15" } }, + "node-schedule": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/node-schedule/-/node-schedule-2.1.0.tgz", + "integrity": "sha512-nl4JTiZ7ZQDc97MmpTq9BQjYhq7gOtoh7SiPH069gBFBj0PzD8HI7zyFs6rzqL8Y5tTiEEYLxgtbx034YPrbyQ==", + "requires": { + "cron-parser": "^3.5.0", + "long-timeout": "0.1.1", + "sorted-array-functions": "^1.3.0" + } + }, "nodemon": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.7.tgz", @@ -2475,6 +2567,11 @@ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" + }, "on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -3053,6 +3150,11 @@ "is-arrayish": "^0.3.1" } }, + "sorted-array-functions": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz", + "integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==" + }, "sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", diff --git a/backend/package.json b/backend/package.json index d30ded2..76db3a5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -52,6 +52,7 @@ "multer": "^1.4.2", "node-fetch": "^2.6.7", "node-id3": "^0.1.14", + "node-schedule": "^2.1.0", "nodemon": "^2.0.7", "passport": "^0.4.1", "passport-http": "^0.3.0", diff --git a/backend/subscriptions.js b/backend/subscriptions.js index 869cf76..1b35a4f 100644 --- a/backend/subscriptions.js +++ b/backend/subscriptions.js @@ -8,15 +8,8 @@ const logger = require('./logger'); const debugMode = process.env.YTDL_MODE === 'debug'; -let db_api = null; -let downloader_api = null; - -function setDB(input_db_api) { db_api = input_db_api } - -function initialize(input_db_api, input_downloader_api) { - setDB(input_db_api); - downloader_api = input_downloader_api; -} +const db_api = require('./db'); +const downloader_api = require('./downloader'); async function subscribe(sub, user_uid = null) { const result_obj = { @@ -542,7 +535,6 @@ module.exports = { unsubscribe : unsubscribe, deleteSubscriptionFile : deleteSubscriptionFile, getVideosForSub : getVideosForSub, - initialize : initialize, updateSubscriptionPropertyMultiple : updateSubscriptionPropertyMultiple, generateOptionsForSubscriptionDownload: generateOptionsForSubscriptionDownload } diff --git a/backend/tasks.js b/backend/tasks.js new file mode 100644 index 0000000..2014da3 --- /dev/null +++ b/backend/tasks.js @@ -0,0 +1,195 @@ +const db_api = require('./db'); +const youtubedl_api = require('./youtube-dl'); + +const fs = require('fs-extra'); +const logger = require('./logger'); +const scheduler = require('node-schedule'); + +const TASKS = { + backup_local_db: { + run: db_api.backupDB, + title: 'Backup DB', + job: null + }, + missing_files_check: { + run: checkForMissingFiles, + confirm: deleteMissingFiles, + title: 'Missing files check', + job: null + }, + missing_db_records: { + run: db_api.importUnregisteredFiles, + title: 'Import missing DB records', + job: null + }, + duplicate_files_check: { + run: checkForDuplicateFiles, + confirm: removeDuplicates, + title: 'Find duplicate files in DB', + job: null + }, + youtubedl_update_check: { + run: youtubedl_api.checkForYoutubeDLUpdate, + confirm: youtubedl_api.updateYoutubeDL, + title: 'Update youtube-dl', + job: null + } +} + +function scheduleJob(task_key, schedule) { + // schedule has to be converted from our format to one node-schedule can consume + let converted_schedule = null; + if (schedule['type'] === 'timestamp') { + converted_schedule = new Date(schedule['data']['timestamp']); + } else if (schedule['type'] === 'recurring') { + const dayOfWeek = schedule['data']['dayOfWeek'] ? schedule['data']['dayOfWeek'] : null; + const hour = schedule['data']['hour'] ? schedule['data']['hour'] : null; + const minute = schedule['data']['minute'] ? schedule['data']['minute'] : null; + converted_schedule = new scheduler.RecurrenceRule(null, null, null, dayOfWeek, hour, minute); + } else { + logger.error(`Failed to schedule job '${task_key}' as the type '${schedule['type']}' is invalid.`) + return null; + } + + return scheduler.scheduleJob(converted_schedule, async () => { + const task_state = await db_api.getRecord('tasks', {key: task_key}); + if (task_state['running'] || task_state['confirming']) { + logger.verbose(`Skipping running task ${task_state['key']} as it is already in progress.`); + return; + } + + // remove schedule if it's a one-time task + if (task_state['schedule']['type'] !== 'recurring') await db_api.updateRecord('tasks', {key: task_key}, {schedule: null}); + // we're just "running" the task, any confirmation should be user-initiated + exports.executeRun(task_key); + }); +} + +if (db_api.database_initialized) { + exports.setupTasks(); +} else { + db_api.database_initialized_bs.subscribe(init => { + if (init) exports.setupTasks(); + }); +} + +exports.setupTasks = async () => { + const tasks_keys = Object.keys(TASKS); + for (let i = 0; i < tasks_keys.length; i++) { + const task_key = tasks_keys[i]; + const task_in_db = await db_api.getRecord('tasks', {key: task_key}); + if (!task_in_db) { + // insert task metadata into table if missing + await db_api.insertRecordIntoTable('tasks', { + key: task_key, + title: TASKS[task_key]['title'], + last_ran: null, + last_confirmed: null, + running: false, + confirming: false, + data: null, + error: null, + schedule: null, + options: {} + }); + } else { + // reset task if necessary + await db_api.updateRecord('tasks', {key: task_key}, {running: false, confirming: false}); + + // schedule task and save job + if (task_in_db['schedule']) { + // prevent timestamp schedules from being set to the past + if (task_in_db['schedule']['type'] === 'timestamp' && task_in_db['schedule']['data']['timestamp'] < Date.now()) { + await db_api.updateRecord('tasks', {key: task_key}, {schedule: null}); + continue; + } + TASKS[task_key]['job'] = scheduleJob(task_key, task_in_db['schedule']); + } + } + } +} + +exports.executeTask = async (task_key) => { + if (!TASKS[task_key]) { + logger.error(`Task ${task_key} does not exist!`); + return; + } + logger.verbose(`Executing task ${task_key}`); + await exports.executeRun(task_key); + if (!TASKS[task_key]['confirm']) return; + await exports.executeConfirm(task_key); + logger.verbose(`Finished executing ${task_key}`); +} + +exports.executeRun = async (task_key) => { + logger.verbose(`Running task ${task_key}`); + // don't set running to true when backup up DB as it will be stick "running" if restored + if (task_key !== 'backup_local_db') await db_api.updateRecord('tasks', {key: task_key}, {running: true}); + const data = await TASKS[task_key].run(); + await db_api.updateRecord('tasks', {key: task_key}, {data: TASKS[task_key]['confirm'] ? data : null, last_ran: Date.now()/1000, running: false}); + logger.verbose(`Finished running task ${task_key}`); +} + +exports.executeConfirm = async (task_key) => { + logger.verbose(`Confirming task ${task_key}`); + if (!TASKS[task_key]['confirm']) { + return null; + } + await db_api.updateRecord('tasks', {key: task_key}, {confirming: true}); + const task_obj = await db_api.getRecord('tasks', {key: task_key}); + const data = task_obj['data']; + await TASKS[task_key].confirm(data); + await db_api.updateRecord('tasks', {key: task_key}, {confirming: false, last_confirmed: Date.now()/1000, data: null}); + logger.verbose(`Finished confirming task ${task_key}`); +} + +exports.updateTaskSchedule = async (task_key, schedule) => { + logger.verbose(`Updating schedule for task ${task_key}`); + await db_api.updateRecord('tasks', {key: task_key}, {schedule: schedule}); + if (TASKS[task_key]['job']) { + TASKS[task_key]['job'].cancel(); + } + if (schedule) { + TASKS[task_key]['job'] = scheduleJob(task_key, schedule); + } +} + +// missing files check + +async function checkForMissingFiles() { + const missing_files = []; + const all_files = await db_api.getRecords('files'); + for (let i = 0; i < all_files.length; i++) { + const file_to_check = all_files[i]; + const file_exists = fs.existsSync(file_to_check['path']); + if (!file_exists) missing_files.push(file_to_check['uid']); + } + return {uids: missing_files}; +} + +async function deleteMissingFiles(data) { + const uids = data['uids']; + for (let i = 0; i < uids.length; i++) { + const uid = uids[i]; + await db_api.removeRecord('files', {uid: uid}); + } +} + +// duplicate files check + +async function checkForDuplicateFiles() { + const duplicate_files = await db_api.findDuplicatesByKey('files', 'path'); + const duplicate_uids = duplicate_files.map(duplicate_file => duplicate_file['uid']); + if (duplicate_uids && duplicate_uids.length > 0) { + return {uids: duplicate_uids}; + } + return {uids: []}; +} + +async function removeDuplicates(data) { + for (let i = 0; i < data['uids'].length; i++) { + await db_api.removeRecord('files', {uid: data['uids'][i]}); + } +} + +exports.TASKS = TASKS; \ No newline at end of file diff --git a/backend/test/tests.js b/backend/test/tests.js index c52fa13..9ae95a8 100644 --- a/backend/test/tests.js +++ b/backend/test/tests.js @@ -70,6 +70,17 @@ describe('Database', async function() { const success = await db_api.getRecord('test', {test: 'test'}); assert(success); }); + + it('Restore db', async function() { + const db_stats = await db_api.getDBStats(); + + const file_name = await db_api.backupDB(); + await db_api.restoreDB(file_name); + + const new_db_stats = await db_api.getDBStats(); + + assert(JSON.stringify(db_stats), JSON.stringify(new_db_stats)); + }); }); describe('Export', function() { @@ -83,12 +94,37 @@ describe('Database', async function() { await db_api.removeAllRecords('test'); }); it('Add and read record', async function() { + this.timeout(120000); await db_api.insertRecordIntoTable('test', {test_add: 'test', test_undefined: undefined, test_null: undefined}); const added_record = await db_api.getRecord('test', {test_add: 'test', test_undefined: undefined, test_null: null}); assert(added_record['test_add'] === 'test'); await db_api.removeRecord('test', {test_add: 'test'}); }); + it('Find duplicates by key', async function() { + const test_duplicates = [ + { + test: 'testing', + key: '1' + }, + { + test: 'testing', + key: '2' + }, + { + test: 'testing_missing', + key: '3' + }, + { + test: 'testing', + key: '4' + } + ]; + await db_api.insertRecordsIntoTable('test', test_duplicates); + const duplicates = await db_api.findDuplicatesByKey('test', 'test'); + console.log(duplicates); + }); + it('Update record', async function() { await db_api.insertRecordIntoTable('test', {test_update: 'test'}); await db_api.updateRecord('test', {test_update: 'test'}, {added_field: true}); @@ -122,6 +158,7 @@ describe('Database', async function() { }); it('Bulk add', async function() { + this.timeout(120000); const NUM_RECORDS_TO_ADD = 2002; // max batch ops is 1000 const test_records = []; for (let i = 0; i < NUM_RECORDS_TO_ADD; i++) { @@ -291,7 +328,6 @@ describe('Multi User', async function() { describe('Downloader', function() { const downloader_api = require('../downloader'); - downloader_api.initialize(db_api); const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'; const sub_id = 'dc834388-3454-41bf-a618-e11cb8c7de1c'; const options = { @@ -348,5 +384,98 @@ describe('Downloader', function() { const sample_json = fs.readJSONSync('./test/sample.info.json'); downloader_api.generateNFOFile(sample_json, nfo_file_path); assert(fs.existsSync(nfo_file_path), true); + fs.unlinkSync(nfo_file_path); }); }); + +describe('Tasks', function() { + const tasks_api = require('../tasks'); + beforeEach(async function() { + await db_api.connectToDB(); + await db_api.removeAllRecords('tasks'); + + const dummy_task = { + run: async () => { await utils.wait(500); return true; }, + confirm: async () => { await utils.wait(500); return true; }, + title: 'Dummy task', + job: null + }; + tasks_api.TASKS['dummy_task'] = dummy_task; + + await tasks_api.initialize(); + }); + it('Backup db', async function() { + const backups_original = await utils.recFindByExt('appdata', 'bak'); + const original_length = backups_original.length; + await tasks_api.executeTask('backup_local_db'); + const backups_new = await utils.recFindByExt('appdata', 'bak'); + const new_length = backups_new.length; + assert(original_length, new_length-1); + }); + + it('Check for missing files', async function() { + await db_api.removeAllRecords('files', {uid: 'test'}); + const test_missing_file = {uid: 'test', path: 'test/missing_file.mp4'}; + await db_api.insertRecordIntoTable('files', test_missing_file); + await tasks_api.executeTask('missing_files_check'); + const task_obj = await db_api.getRecord('tasks', {key: 'missing_files_check'}); + assert(task_obj['data'] && task_obj['data']['uids'] && task_obj['data']['uids'].length >= 1, true); + }); + + it('Check for duplicate files', async function() { + this.timeout(300000); + await db_api.removeAllRecords('files', {uid: 'test1'}); + await db_api.removeAllRecords('files', {uid: 'test2'}); + const test_duplicate_file1 = {uid: 'test1', path: 'test/missing_file.mp4'}; + const test_duplicate_file2 = {uid: 'test2', path: 'test/missing_file.mp4'}; + const test_duplicate_file3 = {uid: 'test3', path: 'test/missing_file.mp4'}; + await db_api.insertRecordIntoTable('files', test_duplicate_file1); + await db_api.insertRecordIntoTable('files', test_duplicate_file2); + await db_api.insertRecordIntoTable('files', test_duplicate_file3); + await tasks_api.executeTask('duplicate_files_check'); + const task_obj = await db_api.getRecord('tasks', {key: 'duplicate_files_check'}); + const duplicated_record_count = await db_api.getRecords('files', {path: 'test/missing_file.mp4'}, true); + assert(task_obj['data'] && task_obj['data']['uids'] && task_obj['data']['uids'].length >= 1, true); + assert(duplicated_record_count == 1, true); + }); + + it('Import unregistered files', async function() { + this.timeout(300000); + + // pre-test cleanup + await db_api.removeAllRecords('files', {title: 'Sample File'}); + if (fs.existsSync('video/sample.info.json')) fs.unlinkSync('video/sample.info.json'); + if (fs.existsSync('video/sample.mp4')) fs.unlinkSync('video/sample.mp4'); + + // copies in files + fs.copyFileSync('test/sample.info.json', 'video/sample.info.json'); + fs.copyFileSync('test/sample.mp4', 'video/sample.mp4'); + await tasks_api.executeTask('missing_db_records'); + const imported_file = await db_api.getRecord('files', {title: 'Sample File'}); + assert(!!imported_file, true); + + // post-test cleanup + if (fs.existsSync('video/sample.info.json')) fs.unlinkSync('video/sample.info.json'); + if (fs.existsSync('video/sample.mp4')) fs.unlinkSync('video/sample.mp4'); + }); + + it('Schedule and cancel task', async function() { + const today_4_hours = new Date(); + today_4_hours.setHours(today_4_hours.getHours() + 4); + await tasks_api.updateTaskSchedule('dummy_task', today_4_hours); + assert(!!tasks_api.TASKS['dummy_task']['job'], true); + await tasks_api.updateTaskSchedule('dummy_task', null); + assert(!!tasks_api.TASKS['dummy_task']['job'], false); + }); + + it('Schedule and run task', async function() { + this.timeout(5000); + const today_1_second = new Date(); + today_1_second.setSeconds(today_1_second.getSeconds() + 1); + await tasks_api.updateTaskSchedule('dummy_task', today_1_second); + assert(!!tasks_api.TASKS['dummy_task']['job'], true); + await utils.wait(2000); + const dummy_task_obj = await db_api.getRecord('tasks', {key: 'dummy_task'}); + assert(dummy_task_obj['data'], true); + }); +}); \ No newline at end of file diff --git a/backend/utils.js b/backend/utils.js index 137274d..0271ed0 100644 --- a/backend/utils.js +++ b/backend/utils.js @@ -1,10 +1,13 @@ -const fs = require('fs-extra') -const path = require('path') +const fs = require('fs-extra'); +const path = require('path'); const ffmpeg = require('fluent-ffmpeg'); +const archiver = require('archiver'); +const fetch = require('node-fetch'); +const ProgressBar = require('progress'); + const config_api = require('./config'); const logger = require('./logger'); -const CONSTS = require('./consts') -const archiver = require('archiver'); +const CONSTS = require('./consts'); const is_windows = process.platform === 'win32'; @@ -266,7 +269,7 @@ function getCurrentDownloader() { return details_json['downloader']; } -async function recFindByExt(base,ext,files,result) +async function recFindByExt(base, ext, files, result, recursive = true) { files = files || (await fs.readdir(base)) result = result || [] @@ -275,6 +278,7 @@ async function recFindByExt(base,ext,files,result) var newbase = path.join(base,file) if ( (await fs.stat(newbase)).isDirectory() ) { + if (!recursive) continue; result = await recFindByExt(newbase,ext,await fs.readdir(newbase),result) } else @@ -355,6 +359,62 @@ async function cropFile(file_path, start, end, ext) { }); } +async function checkExistsWithTimeout(filePath, timeout) { + return new Promise(function (resolve, reject) { + + var timer = setTimeout(function () { + if (watcher) watcher.close(); + reject(new Error('File did not exists and was not created during the timeout.')); + }, timeout); + + fs.access(filePath, fs.constants.R_OK, function (err) { + if (!err) { + clearTimeout(timer); + if (watcher) watcher.close(); + resolve(); + } + }); + + var dir = path.dirname(filePath); + var basename = path.basename(filePath); + var watcher = fs.watch(dir, function (eventType, filename) { + if (eventType === 'rename' && filename === basename) { + clearTimeout(timer); + if (watcher) watcher.close(); + resolve(); + } + }); + }); +} + +// helper function to download file using fetch +async function fetchFile(url, path, file_label) { + var len = null; + const res = await fetch(url); + + len = parseInt(res.headers.get("Content-Length"), 10); + + var bar = new ProgressBar(` Downloading ${file_label} [:bar] :percent :etas`, { + complete: '=', + incomplete: ' ', + width: 20, + total: len + }); + const fileStream = fs.createWriteStream(path); + await new Promise((resolve, reject) => { + res.body.pipe(fileStream); + res.body.on("error", (err) => { + reject(err); + }); + res.body.on('data', function (chunk) { + bar.tick(chunk.length); + }); + fileStream.on("finish", function() { + resolve(); + }); + }); +} + // objects function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date, description, view_count, height, abr) { @@ -396,5 +456,7 @@ module.exports = { cropFile: cropFile, createEdgeNGrams: createEdgeNGrams, wait: wait, + checkExistsWithTimeout: checkExistsWithTimeout, + fetchFile: fetchFile, File: File } diff --git a/backend/youtube-dl.js b/backend/youtube-dl.js new file mode 100644 index 0000000..80432fb --- /dev/null +++ b/backend/youtube-dl.js @@ -0,0 +1,127 @@ +const fs = require('fs-extra'); +const fetch = require('node-fetch'); + +const logger = require('./logger'); +const utils = require('./utils'); +const CONSTS = require('./consts'); +const config_api = require('./config.js'); + +const is_windows = process.platform === 'win32'; + +const download_sources = { + 'youtube-dl': { + 'tags_url': 'https://api.github.com/repos/ytdl-org/youtube-dl/tags', + 'func': downloadLatestYoutubeDLBinary + }, + 'youtube-dlc': { + 'tags_url': 'https://api.github.com/repos/blackjack4494/yt-dlc/tags', + 'func': downloadLatestYoutubeDLCBinary + }, + 'yt-dlp': { + 'tags_url': 'https://api.github.com/repos/yt-dlp/yt-dlp/tags', + 'func': downloadLatestYoutubeDLPBinary + } +} + +exports.checkForYoutubeDLUpdate = async () => { + return new Promise(async resolve => { + const default_downloader = config_api.getConfigItem('ytdl_default_downloader'); + const tags_url = download_sources[default_downloader]['tags_url']; + // get current version + let current_app_details_exists = fs.existsSync(CONSTS.DETAILS_BIN_PATH); + if (!current_app_details_exists) { + logger.warn(`Failed to get youtube-dl binary details at location '${CONSTS.DETAILS_BIN_PATH}'. Generating file...`); + fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, {"version":"2020.00.00", "downloader": default_downloader}); + } + let current_app_details = JSON.parse(fs.readFileSync(CONSTS.DETAILS_BIN_PATH)); + let current_version = current_app_details['version']; + let current_downloader = current_app_details['downloader']; + let stored_binary_path = current_app_details['path']; + if (!stored_binary_path || typeof stored_binary_path !== 'string') { + // logger.info(`INFO: Failed to get youtube-dl binary path at location: ${CONSTS.DETAILS_BIN_PATH}, attempting to guess actual path...`); + const guessed_base_path = 'node_modules/youtube-dl/bin/'; + const guessed_file_path = guessed_base_path + 'youtube-dl' + (is_windows ? '.exe' : ''); + if (fs.existsSync(guessed_file_path)) { + stored_binary_path = guessed_file_path; + // logger.info('INFO: Guess successful! Update process continuing...') + } else { + logger.error(`Guess '${guessed_file_path}' is not correct. Cancelling update check. Verify that your youtube-dl binaries exist by running npm install.`); + resolve(null); + return; + } + } + + // got version, now let's check the latest version from the youtube-dl API + + + fetch(tags_url, {method: 'Get'}) + .then(async res => res.json()) + .then(async (json) => { + // check if the versions are different + if (!json || !json[0]) { + logger.error(`Failed to check ${default_downloader} version for an update.`) + resolve(null); + return; + } + const latest_update_version = json[0]['name']; + if (current_version !== latest_update_version || default_downloader !== current_downloader) { + // versions different or different downloader is being used, download new update + resolve(latest_update_version); + } else { + resolve(null); + } + return; + }) + .catch(err => { + logger.error(`Failed to check ${default_downloader} version for an update.`) + logger.error(err); + resolve(null); + return; + }); + }); +} + +exports.updateYoutubeDL = async (latest_update_version) => { + const default_downloader = config_api.getConfigItem('ytdl_default_downloader'); + await download_sources[default_downloader]['func'](latest_update_version); +} + +async function downloadLatestYoutubeDLBinary(new_version) { + const file_ext = is_windows ? '.exe' : ''; + + const download_url = `https://github.com/ytdl-org/youtube-dl/releases/latest/download/youtube-dl${file_ext}`; + const output_path = `node_modules/youtube-dl/bin/youtube-dl${file_ext}`; + + await utils.fetchFile(download_url, output_path, `youtube-dl ${new_version}`); + + updateDetailsJSON(new_version, 'youtube-dl'); +} + +async function downloadLatestYoutubeDLCBinary(new_version) { + const file_ext = is_windows ? '.exe' : ''; + + const download_url = `https://github.com/blackjack4494/yt-dlc/releases/latest/download/youtube-dlc${file_ext}`; + const output_path = `node_modules/youtube-dl/bin/youtube-dl${file_ext}`; + + await utils.fetchFile(download_url, output_path, `youtube-dlc ${new_version}`); + + updateDetailsJSON(new_version, 'youtube-dlc'); +} + +async function downloadLatestYoutubeDLPBinary(new_version) { + const file_ext = is_windows ? '.exe' : ''; + + const download_url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp${file_ext}`; + const output_path = `node_modules/youtube-dl/bin/youtube-dl${file_ext}`; + + await utils.fetchFile(download_url, output_path, `yt-dlp ${new_version}`); + + updateDetailsJSON(new_version, 'yt-dlp'); +} + +function updateDetailsJSON(new_version, downloader) { + const details_json = fs.readJSONSync(CONSTS.DETAILS_BIN_PATH); + if (new_version) details_json['version'] = new_version; + details_json['downloader'] = downloader; + fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, details_json); +} diff --git a/src/api-types/index.ts b/src/api-types/index.ts index c08044a..43f0f06 100644 --- a/src/api-types/index.ts +++ b/src/api-types/index.ts @@ -21,6 +21,7 @@ export type { CreatePlaylistRequest } from './models/CreatePlaylistRequest'; export type { CreatePlaylistResponse } from './models/CreatePlaylistResponse'; export type { CropFileSettings } from './models/CropFileSettings'; export type { DatabaseFile } from './models/DatabaseFile'; +export { DBBackup } from './models/DBBackup'; export type { DBInfoResponse } from './models/DBInfoResponse'; export type { DeleteCategoryRequest } from './models/DeleteCategoryRequest'; export type { DeleteMp3Mp4Request } from './models/DeleteMp3Mp4Request'; @@ -44,6 +45,8 @@ export type { GetAllDownloadsRequest } from './models/GetAllDownloadsRequest'; export type { GetAllDownloadsResponse } from './models/GetAllDownloadsResponse'; export type { GetAllFilesResponse } from './models/GetAllFilesResponse'; export type { GetAllSubscriptionsResponse } from './models/GetAllSubscriptionsResponse'; +export type { GetAllTasksResponse } from './models/GetAllTasksResponse'; +export type { GetDBBackupsResponse } from './models/GetDBBackupsResponse'; export type { GetDownloadRequest } from './models/GetDownloadRequest'; export type { GetDownloadResponse } from './models/GetDownloadResponse'; export type { GetFileFormatsRequest } from './models/GetFileFormatsRequest'; @@ -63,6 +66,8 @@ export type { GetPlaylistsResponse } from './models/GetPlaylistsResponse'; export type { GetRolesResponse } from './models/GetRolesResponse'; export type { GetSubscriptionRequest } from './models/GetSubscriptionRequest'; export type { GetSubscriptionResponse } from './models/GetSubscriptionResponse'; +export type { GetTaskRequest } from './models/GetTaskRequest'; +export type { GetTaskResponse } from './models/GetTaskResponse'; export type { GetUsersResponse } from './models/GetUsersResponse'; export type { IncrementViewCountRequest } from './models/IncrementViewCountRequest'; export type { inline_response_200_15 } from './models/inline_response_200_15'; @@ -71,6 +76,8 @@ export type { LoginResponse } from './models/LoginResponse'; export type { Playlist } from './models/Playlist'; export type { RegisterRequest } from './models/RegisterRequest'; export type { RegisterResponse } from './models/RegisterResponse'; +export type { RestoreDBBackupRequest } from './models/RestoreDBBackupRequest'; +export { Schedule } from './models/Schedule'; export type { SetConfigRequest } from './models/SetConfigRequest'; export type { SharingToggle } from './models/SharingToggle'; export type { SubscribeRequest } from './models/SubscribeRequest'; @@ -79,6 +86,7 @@ export type { Subscription } from './models/Subscription'; export type { SubscriptionRequestData } from './models/SubscriptionRequestData'; export type { SuccessObject } from './models/SuccessObject'; export type { TableInfo } from './models/TableInfo'; +export type { Task } from './models/Task'; export type { TestConnectionStringRequest } from './models/TestConnectionStringRequest'; export type { TestConnectionStringResponse } from './models/TestConnectionStringResponse'; export type { TransferDBRequest } from './models/TransferDBRequest'; @@ -93,6 +101,8 @@ export type { UpdateConcurrentStreamResponse } from './models/UpdateConcurrentSt export type { UpdatePlaylistRequest } from './models/UpdatePlaylistRequest'; export type { UpdaterStatus } from './models/UpdaterStatus'; export type { UpdateServerRequest } from './models/UpdateServerRequest'; +export type { UpdateTaskDataRequest } from './models/UpdateTaskDataRequest'; +export type { UpdateTaskScheduleRequest } from './models/UpdateTaskScheduleRequest'; export type { UpdateUserRequest } from './models/UpdateUserRequest'; export type { User } from './models/User'; export { UserPermission } from './models/UserPermission'; diff --git a/src/api-types/models/DBBackup.ts b/src/api-types/models/DBBackup.ts new file mode 100644 index 0000000..710c591 --- /dev/null +++ b/src/api-types/models/DBBackup.ts @@ -0,0 +1,21 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + + +export interface DBBackup { + name: string; + timestamp: number; + size: number; + source: DBBackup.source; +} + +export namespace DBBackup { + + export enum source { + LOCAL = 'local', + REMOTE = 'remote', + } + + +} \ No newline at end of file diff --git a/src/api-types/models/DownloadFileRequest.ts b/src/api-types/models/DownloadFileRequest.ts index 31ba393..a874a8c 100644 --- a/src/api-types/models/DownloadFileRequest.ts +++ b/src/api-types/models/DownloadFileRequest.ts @@ -2,6 +2,7 @@ /* tslint:disable */ /* eslint-disable */ +import { FileType } from './FileType'; export interface DownloadFileRequest { uid?: string; @@ -9,5 +10,5 @@ export interface DownloadFileRequest { sub_id?: string; playlist_id?: string; url?: string; - type?: string; + type?: FileType; } \ No newline at end of file diff --git a/src/api-types/models/GetAllTasksResponse.ts b/src/api-types/models/GetAllTasksResponse.ts new file mode 100644 index 0000000..221d44a --- /dev/null +++ b/src/api-types/models/GetAllTasksResponse.ts @@ -0,0 +1,9 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import { Task } from './Task'; + +export interface GetAllTasksResponse { + tasks?: Array; +} \ No newline at end of file diff --git a/src/api-types/models/GetDBBackupsResponse.ts b/src/api-types/models/GetDBBackupsResponse.ts new file mode 100644 index 0000000..b02ced9 --- /dev/null +++ b/src/api-types/models/GetDBBackupsResponse.ts @@ -0,0 +1,9 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import { DBBackup } from './DBBackup'; + +export interface GetDBBackupsResponse { + tasks?: Array; +} \ No newline at end of file diff --git a/src/api-types/models/GetFullTwitchChatRequest.ts b/src/api-types/models/GetFullTwitchChatRequest.ts index 6d6a3a6..64a043d 100644 --- a/src/api-types/models/GetFullTwitchChatRequest.ts +++ b/src/api-types/models/GetFullTwitchChatRequest.ts @@ -15,8 +15,5 @@ export interface GetFullTwitchChatRequest { * User UID */ uuid?: string; - /** - * Subscription - */ sub?: Subscription; } \ No newline at end of file diff --git a/src/api-types/models/GetTaskRequest.ts b/src/api-types/models/GetTaskRequest.ts new file mode 100644 index 0000000..655a69f --- /dev/null +++ b/src/api-types/models/GetTaskRequest.ts @@ -0,0 +1,8 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + + +export interface GetTaskRequest { + task_key: string; +} \ No newline at end of file diff --git a/src/api-types/models/GetTaskResponse.ts b/src/api-types/models/GetTaskResponse.ts new file mode 100644 index 0000000..7f11c6e --- /dev/null +++ b/src/api-types/models/GetTaskResponse.ts @@ -0,0 +1,9 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import { Task } from './Task'; + +export interface GetTaskResponse { + task?: Task; +} \ No newline at end of file diff --git a/src/api-types/models/RestoreDBBackupRequest.ts b/src/api-types/models/RestoreDBBackupRequest.ts new file mode 100644 index 0000000..b5fde8a --- /dev/null +++ b/src/api-types/models/RestoreDBBackupRequest.ts @@ -0,0 +1,8 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + + +export interface RestoreDBBackupRequest { + file_name: string; +} \ No newline at end of file diff --git a/src/api-types/models/Schedule.ts b/src/api-types/models/Schedule.ts new file mode 100644 index 0000000..452202d --- /dev/null +++ b/src/api-types/models/Schedule.ts @@ -0,0 +1,24 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + + +export interface Schedule { + type: Schedule.type; + data: { +dayOfWeek?: Array, +hour?: number, +minute?: number, +timestamp?: number, +}; +} + +export namespace Schedule { + + export enum type { + TIMESTAMP = 'timestamp', + RECURRING = 'recurring', + } + + +} \ No newline at end of file diff --git a/src/api-types/models/Task.ts b/src/api-types/models/Task.ts new file mode 100644 index 0000000..95c864a --- /dev/null +++ b/src/api-types/models/Task.ts @@ -0,0 +1,15 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + + +export interface Task { + key: string; + last_ran: number; + last_confirmed: number; + running: boolean; + confirming: boolean; + data: any; + error: string; + schedule: any; +} \ No newline at end of file diff --git a/src/api-types/models/UpdateTaskDataRequest.ts b/src/api-types/models/UpdateTaskDataRequest.ts new file mode 100644 index 0000000..7768eaa --- /dev/null +++ b/src/api-types/models/UpdateTaskDataRequest.ts @@ -0,0 +1,9 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + + +export interface UpdateTaskDataRequest { + task_key: string; + new_data: any; +} \ No newline at end of file diff --git a/src/api-types/models/UpdateTaskScheduleRequest.ts b/src/api-types/models/UpdateTaskScheduleRequest.ts new file mode 100644 index 0000000..b9e61d6 --- /dev/null +++ b/src/api-types/models/UpdateTaskScheduleRequest.ts @@ -0,0 +1,10 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import { Schedule } from './Schedule'; + +export interface UpdateTaskScheduleRequest { + task_key: string; + new_schedule: Schedule; +} \ No newline at end of file diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 8620fcb..ab9c609 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -8,6 +8,7 @@ import { PostsService } from './posts.services'; import { LoginComponent } from './components/login/login.component'; import { DownloadsComponent } from './components/downloads/downloads.component'; import { SettingsComponent } from './settings/settings.component'; +import { TasksComponent } from './components/tasks/tasks.component'; const routes: Routes = [ { path: 'home', component: MainComponent, canActivate: [PostsService] }, @@ -17,6 +18,7 @@ const routes: Routes = [ { path: 'settings', component: SettingsComponent, canActivate: [PostsService] }, { path: 'login', component: LoginComponent }, { path: 'downloads', component: DownloadsComponent, canActivate: [PostsService] }, + { path: 'tasks', component: TasksComponent, canActivate: [PostsService] }, { path: '', redirectTo: '/home', pathMatch: 'full' } ]; diff --git a/src/app/app.component.html b/src/app/app.component.html index df61ad2..f1270c7 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -44,6 +44,7 @@ Login Subscriptions Downloads + Tasks Settings diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 7b26e63..3d6d040 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -28,6 +28,7 @@ import { MatTabsModule } from '@angular/material/tabs'; import { MatPaginatorModule } from '@angular/material/paginator'; import { MatSortModule } from '@angular/material/sort'; import { MatTableModule } from '@angular/material/table'; +import { MatDatepickerModule } from '@angular/material/datepicker'; import { DragDropModule } from '@angular/cdk/drag-drop'; import { ClipboardModule } from '@angular/cdk/clipboard'; import { TextFieldModule } from '@angular/cdk/text-field'; @@ -87,6 +88,9 @@ import { LinkifyPipe, SeeMoreComponent } from './components/see-more/see-more.co import { H401Interceptor } from './http.interceptor'; import { ConcurrentStreamComponent } from './components/concurrent-stream/concurrent-stream.component'; import { SkipAdButtonComponent } from './components/skip-ad-button/skip-ad-button.component'; +import { TasksComponent } from './components/tasks/tasks.component'; +import { UpdateTaskScheduleDialogComponent } from './dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component'; +import { RestoreDbDialogComponent } from './dialogs/restore-db-dialog/restore-db-dialog.component'; registerLocaleData(es, 'es'); @@ -135,7 +139,10 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible TwitchChatComponent, SeeMoreComponent, ConcurrentStreamComponent, - SkipAdButtonComponent + SkipAdButtonComponent, + TasksComponent, + UpdateTaskScheduleDialogComponent, + RestoreDbDialogComponent ], imports: [ CommonModule, @@ -171,6 +178,7 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible MatPaginatorModule, MatSortModule, MatTableModule, + MatDatepickerModule, MatChipsModule, DragDropModule, ClipboardModule, diff --git a/src/app/components/tasks/tasks.component.html b/src/app/components/tasks/tasks.component.html new file mode 100644 index 0000000..662e3be --- /dev/null +++ b/src/app/components/tasks/tasks.component.html @@ -0,0 +1,95 @@ +
+
+ + + + Title + + + {{element.title}} + + + + + + + Last ran + + {{element.last_ran*1000 | date: 'short'}} + N/A + + + + + + Last confirmed + + {{element.last_confirmed*1000 | date: 'short'}} + N/A + + + + + + Status + + + + Scheduled for  + {{element.next_invocation | date: 'short'}}repeat + + + Not scheduled + + + + + + + Actions + +
+
+
+ + + +
+
+ +
+
+ +
+
+
+
+
+ + + +
+ + + +
+ + + +
+ +
+

No tasks available!

+
\ No newline at end of file diff --git a/src/app/components/tasks/tasks.component.scss b/src/app/components/tasks/tasks.component.scss new file mode 100644 index 0000000..ed84df9 --- /dev/null +++ b/src/app/components/tasks/tasks.component.scss @@ -0,0 +1,32 @@ +mat-header-cell, mat-cell { + justify-content: center; +} + +.one-line { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.icon-button-spinner { + position: absolute; + top: 7px; + left: 6px; +} + +.downloads-action-button-div { + margin-top: 10px; + margin-left: 5px; +} + +.rounded-top { + border-radius: 16px 16px 0px 0px !important; +} + +.rounded-bottom { + border-radius: 0px 0px 16px 16px !important; +} + +.rounded { + border-radius: 16px 16px 16px 16px !important; +} \ No newline at end of file diff --git a/src/app/components/tasks/tasks.component.spec.ts b/src/app/components/tasks/tasks.component.spec.ts new file mode 100644 index 0000000..d4ab7a5 --- /dev/null +++ b/src/app/components/tasks/tasks.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TasksComponent } from './tasks.component'; + +describe('TasksComponent', () => { + let component: TasksComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ TasksComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TasksComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/tasks/tasks.component.ts b/src/app/components/tasks/tasks.component.ts new file mode 100644 index 0000000..8fec372 --- /dev/null +++ b/src/app/components/tasks/tasks.component.ts @@ -0,0 +1,164 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort } from '@angular/material/sort'; +import { MatTableDataSource } from '@angular/material/table'; +import { ConfirmDialogComponent } from 'app/dialogs/confirm-dialog/confirm-dialog.component'; +import { RestoreDbDialogComponent } from 'app/dialogs/restore-db-dialog/restore-db-dialog.component'; +import { UpdateTaskScheduleDialogComponent } from 'app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component'; +import { PostsService } from 'app/posts.services'; + +@Component({ + selector: 'app-tasks', + templateUrl: './tasks.component.html', + styleUrls: ['./tasks.component.scss'] +}) +export class TasksComponent implements OnInit { + + interval_id = null; + tasks_check_interval = 1500; + tasks = null; + tasks_retrieved = false; + + displayedColumns: string[] = ['title', 'last_ran', 'last_confirmed', 'status', 'actions']; + dataSource = null; + + db_backups = []; + + @ViewChild(MatPaginator) paginator: MatPaginator; + @ViewChild(MatSort) sort: MatSort; + + constructor(private postsService: PostsService, private dialog: MatDialog) { } + + ngOnInit(): void { + if (this.postsService.initialized) { + this.getTasksRecurring(); + } else { + this.postsService.service_initialized.subscribe(init => { + if (init) { + this.getTasksRecurring(); + } + }); + } + } + + ngOnDestroy(): void { + if (this.interval_id) { clearInterval(this.interval_id) } + } + + getTasksRecurring(): void { + this.getTasks(); + this.interval_id = setInterval(() => { + this.getTasks(); + }, this.tasks_check_interval); + } + + getTasks(): void { + this.postsService.getTasks().subscribe(res => { + if (this.tasks) { + if (JSON.stringify(this.tasks) === JSON.stringify(res['tasks'])) return; + for (const task of res['tasks']) { + const task_index = this.tasks.map(t => t.key).indexOf(task['key']); + this.tasks[task_index] = task; + } + this.dataSource = new MatTableDataSource(this.tasks); + } else { + this.tasks = res['tasks']; + this.dataSource = new MatTableDataSource(this.tasks); + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + } + }); + } + + runTask(task_key: string): void { + this.postsService.runTask(task_key).subscribe(res => { + this.getTasks(); + this.getDBBackups(); + if (res['success']) this.postsService.openSnackBar($localize`Successfully ran task!`); + else this.postsService.openSnackBar($localize`Failed to run task!`); + }, err => { + this.postsService.openSnackBar($localize`Failed to run task!`); + console.error(err); + }); + } + + confirmTask(task_key: string): void { + this.postsService.confirmTask(task_key).subscribe(res => { + this.getTasks(); + if (res['success']) this.postsService.openSnackBar($localize`Successfully confirmed task!`); + else this.postsService.openSnackBar($localize`Failed to confirm task!`); + }, err => { + this.postsService.openSnackBar($localize`Failed to confirm task!`); + console.error(err); + }); + } + + scheduleTask(task: any): void { + // open dialog + const dialogRef = this.dialog.open(UpdateTaskScheduleDialogComponent, { + data: { + task: task + } + }); + dialogRef.afterClosed().subscribe(schedule => { + if (schedule || schedule === null) { + this.postsService.updateTaskSchedule(task['key'], schedule).subscribe(res => { + this.getTasks(); + console.log(res); + }); + } + }); + } + + getDBBackups(): void { + this.postsService.getDBBackups().subscribe(res => { + this.db_backups = res['db_backups']; + }); + } + + openRestoreDBBackupDialog(): void { + this.dialog.open(RestoreDbDialogComponent, { + data: { + db_backups: this.db_backups + }, + width: '80vw' + }) + } + + resetTasks(): void { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + data: { + dialogTitle: $localize`Reset tasks`, + dialogText: $localize`Would you like to reset your tasks? All your schedules will be removed as well.`, + submitText: $localize`Reset`, + warnSubmitColor: true + } + }); + dialogRef.afterClosed().subscribe(confirmed => { + if (confirmed) { + this.postsService.resetTasks().subscribe(res => { + if (res['success']) { + this.postsService.openSnackBar($localize`Tasks successfully reset!`); + } else { + this.postsService.openSnackBar($localize`Failed to reset tasks!`); + } + }, err => { + this.postsService.openSnackBar($localize`Failed to reset tasks!`); + console.error(err); + }); + } + }); + } + +} + +export interface Task { + key: string; + title: string; + last_ran: number; + last_confirmed: number; + running: boolean; + confirming: boolean; + data: unknown; +} \ No newline at end of file diff --git a/src/app/dialogs/restore-db-dialog/restore-db-dialog.component.html b/src/app/dialogs/restore-db-dialog/restore-db-dialog.component.html new file mode 100644 index 0000000..abcb1e7 --- /dev/null +++ b/src/app/dialogs/restore-db-dialog/restore-db-dialog.component.html @@ -0,0 +1,29 @@ +

Restore DB from backup

+ + + + +
+
+
+ {{db_backup.timestamp*1000 | date: 'short'}} +
+
+ {{(db_backup.size/1000).toFixed(2)}} kB +
+
+ {{db_backup.source}} +
+
+
+
+
+
+ + + + +
+ +
+
\ No newline at end of file diff --git a/src/app/dialogs/restore-db-dialog/restore-db-dialog.component.scss b/src/app/dialogs/restore-db-dialog/restore-db-dialog.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/dialogs/restore-db-dialog/restore-db-dialog.component.spec.ts b/src/app/dialogs/restore-db-dialog/restore-db-dialog.component.spec.ts new file mode 100644 index 0000000..422e482 --- /dev/null +++ b/src/app/dialogs/restore-db-dialog/restore-db-dialog.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RestoreDbDialogComponent } from './restore-db-dialog.component'; + +describe('RestoreDbDialogComponent', () => { + let component: RestoreDbDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ RestoreDbDialogComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RestoreDbDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dialogs/restore-db-dialog/restore-db-dialog.component.ts b/src/app/dialogs/restore-db-dialog/restore-db-dialog.component.ts new file mode 100644 index 0000000..204a8c1 --- /dev/null +++ b/src/app/dialogs/restore-db-dialog/restore-db-dialog.component.ts @@ -0,0 +1,51 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { PostsService } from 'app/posts.services'; + +@Component({ + selector: 'app-restore-db-dialog', + templateUrl: './restore-db-dialog.component.html', + styleUrls: ['./restore-db-dialog.component.scss'] +}) +export class RestoreDbDialogComponent implements OnInit { + + db_backups = []; + selected_backup = null; + restoring = false; + + constructor(@Inject(MAT_DIALOG_DATA) public data: any, private dialogRef: MatDialogRef, private postsService: PostsService) { + if (this.data?.db_backups) { + this.db_backups = this.data.db_backups; + } + + this.getDBBackups(); + } + + ngOnInit(): void { + } + + getDBBackups(): void { + this.postsService.getDBBackups().subscribe(res => { + this.db_backups = res['db_backups']; + }); + } + + restoreClicked(): void { + this.restoring = true; + if (this.selected_backup.length !== 1) return; + this.postsService.restoreDBBackup(this.selected_backup[0]).subscribe(res => { + this.restoring = false; + if (res['success']) { + this.postsService.openSnackBar('Database successfully restored!'); + this.dialogRef.close(); + } else { + this.postsService.openSnackBar('Failed to restore database! See logs for more info.'); + } + }, err => { + this.restoring = false; + this.postsService.openSnackBar('Failed to restore database! See browser console for more info.'); + console.error(err); + }); + } + +} diff --git a/src/app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component.html b/src/app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component.html new file mode 100644 index 0000000..7782e15 --- /dev/null +++ b/src/app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component.html @@ -0,0 +1,53 @@ +

Update task schedule

+ + +
+
+
+ Enabled +
+
+ Recurring +
+
+ + + Weekly + Daily + + +
+
+ + Choose a date + + + + +
+
+ + + M + T + W + T + F + S + S + +
+
+ + Time + + +
+
+
+
+ + + + + \ No newline at end of file diff --git a/src/app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component.scss b/src/app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component.spec.ts b/src/app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component.spec.ts new file mode 100644 index 0000000..fe52670 --- /dev/null +++ b/src/app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UpdateTaskScheduleDialogComponent } from './update-task-schedule-dialog.component'; + +describe('UpdateTaskScheduleDialogComponent', () => { + let component: UpdateTaskScheduleDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ UpdateTaskScheduleDialogComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(UpdateTaskScheduleDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component.ts b/src/app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component.ts new file mode 100644 index 0000000..ef61cf4 --- /dev/null +++ b/src/app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component.ts @@ -0,0 +1,83 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { Schedule } from 'api-types'; +import { PostsService } from 'app/posts.services'; + +@Component({ + selector: 'app-update-task-schedule-dialog', + templateUrl: './update-task-schedule-dialog.component.html', + styleUrls: ['./update-task-schedule-dialog.component.scss'] +}) +export class UpdateTaskScheduleDialogComponent implements OnInit { + + enabled = true; + recurring = false; + days_of_week = []; + interval = 'daily'; + time = null; + date = null; + today = new Date(); + + constructor(@Inject(MAT_DIALOG_DATA) public data: any, private dialogRef: MatDialogRef, private postsService: PostsService) { + this.processTask(this.data.task); + this.postsService.getTask(this.data.task.key).subscribe(res => { + this.processTask(res['task']); + }); + } + + ngOnInit(): void { + } + + processTask(task) { + if (!task['schedule']) { + this.enabled = false; + return; + } + + const schedule: Schedule = task['schedule']; + + this.recurring = schedule['type'] === Schedule.type.RECURRING; + + if (this.recurring) { + this.time = `${schedule['data']['hour']}:${schedule['data']['minute']}`; + + if (schedule['data']['dayOfWeek']) { + this.days_of_week = schedule['data']['dayOfWeek']; + this.interval = 'weekly'; + } else { + this.interval = 'daily'; + } + } else { + const schedule_date = new Date(schedule['data']['timestamp']); + this.time = `${schedule_date.getHours()}:${schedule_date.getMinutes()}` + this.date = schedule_date; + } + } + + updateTaskSchedule(): void { + if (!this.enabled) { + this.dialogRef.close(null); + return; + } + + if (!this.time) { + // needs time! + } + + const hours = parseInt(this.time.split(':')[0]); + const minutes = parseInt(this.time.split(':')[1]); + + const schedule: Schedule = {type: this.recurring ? Schedule.type.RECURRING : Schedule.type.TIMESTAMP, data: null}; + if (this.recurring) { + schedule['data'] = {hour: hours, minute: minutes}; + if (this.interval === 'weekly') { + schedule['data']['dayOfWeek'] = this.days_of_week; + } + } else { + this.date.setHours(hours, minutes); + console.log(this.date); + schedule['data'] = {timestamp: this.date.getTime()}; + } + this.dialogRef.close(schedule); + } +} diff --git a/src/app/player/player.component.html b/src/app/player/player.component.html index 74c0b1b..0bf510f 100644 --- a/src/app/player/player.component.html +++ b/src/app/player/player.component.html @@ -33,8 +33,7 @@
- - + diff --git a/src/app/player/player.component.ts b/src/app/player/player.component.ts index 5fe3936..9c82d1a 100644 --- a/src/app/player/player.component.ts +++ b/src/app/player/player.component.ts @@ -9,7 +9,6 @@ import { ShareMediaDialogComponent } from '../dialogs/share-media-dialog/share-m import { FileType } from '../../api-types'; import { TwitchChatComponent } from 'app/components/twitch-chat/twitch-chat.component'; import { VideoInfoDialogComponent } from 'app/dialogs/video-info-dialog/video-info-dialog.component'; -import { HttpClient, HttpParams } from '@angular/common/http'; export interface IMedia { title: string; @@ -110,7 +109,7 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy { } constructor(public postsService: PostsService, private route: ActivatedRoute, private dialog: MatDialog, private router: Router, - public snackBar: MatSnackBar, private cdr: ChangeDetectorRef, private http: HttpClient) { + public snackBar: MatSnackBar, private cdr: ChangeDetectorRef) { } processConfig() { @@ -315,10 +314,8 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy { downloadFile() { const filename = this.playlist[0].title; const ext = (this.playlist[0].type === 'audio/mp3') ? '.mp3' : '.mp4'; - const type = this.playlist[0].type; - const url = this.playlist[0].url; this.downloading = true; - this.postsService.downloadFileFromServer(this.uid, this.uuid, this.sub_id, url, type).subscribe(res => { + this.postsService.downloadFileFromServer(this.uid, this.uuid).subscribe(res => { this.downloading = false; const blob: Blob = res; saveAs(blob, filename + ext); @@ -328,24 +325,6 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy { }); } - downloadVideo() { - const filename = this.currentItem.label; - const ext = (this.currentItem.type === 'audio/mp3') ? '.mp3' : '.mp4'; - // const type = this.currentItem.type; - const url = this.currentItem.src; - this.downloading = true; - this.http.get(url, { - responseType: 'blob' - }).subscribe(res => { - const blob: Blob = res; - this.downloading = false; - saveAs(blob, filename + ext); - }, err => { - console.log(err); - this.downloading = false; - }) - } - playlistPostCreationHandler(playlistID) { // changes the route without moving from the current view or // triggering a navigation event diff --git a/src/app/posts.services.ts b/src/app/posts.services.ts index f157d47..5674fec 100644 --- a/src/app/posts.services.ts +++ b/src/app/posts.services.ts @@ -90,6 +90,12 @@ import { DBInfoResponse, GetFileFormatsRequest, GetFileFormatsResponse, + GetTaskRequest, + GetTaskResponse, + UpdateTaskScheduleRequest, + UpdateTaskDataRequest, + RestoreDBBackupRequest, + Schedule, } from '../api-types'; import { isoLangs } from './settings/locales_list'; import { Title } from '@angular/platform-browser'; @@ -351,15 +357,12 @@ export class PostsService implements CanActivate { return this.http.post(this.path + 'getAllFiles', {sort: sort, range: range, text_search: text_search, file_type_filter: file_type_filter}, this.httpOptions); } - downloadFileFromServer(uid: string, uuid: string = null, sub_id: string = null, url: string = null, type: string = null) { + downloadFileFromServer(uid: string, uuid: string = null) { const body: DownloadFileRequest = { uid: uid, - uuid: uuid, - sub_id: sub_id, - url: url, - type: type + uuid: uuid }; - return this.http.post(this.path + 'downloadFile', body, {responseType: 'blob', params: this.httpOptions.params}); + return this.http.post(this.path + 'downloadFileFromServer', body, {responseType: 'blob', params: this.httpOptions.params}); } getFullTwitchChat(id, type, uuid = null, sub = null) { @@ -544,38 +547,85 @@ export class PostsService implements CanActivate { return this.http.post(this.path + 'download', body, this.httpOptions); } - pauseDownload(download_uid) { - return this.http.post(this.path + 'pauseDownload', {download_uid: download_uid}, this.httpOptions); + pauseDownload(download_uid: string) { + const body: GetDownloadRequest = {download_uid: download_uid}; + return this.http.post(this.path + 'pauseDownload', body, this.httpOptions); } pauseAllDownloads() { return this.http.post(this.path + 'pauseAllDownloads', {}, this.httpOptions); } - resumeDownload(download_uid) { - return this.http.post(this.path + 'resumeDownload', {download_uid: download_uid}, this.httpOptions); + resumeDownload(download_uid: string) { + const body: GetDownloadRequest = {download_uid: download_uid}; + return this.http.post(this.path + 'resumeDownload', body, this.httpOptions); } resumeAllDownloads() { return this.http.post(this.path + 'resumeAllDownloads', {}, this.httpOptions); } - restartDownload(download_uid) { - return this.http.post(this.path + 'restartDownload', {download_uid: download_uid}, this.httpOptions); + restartDownload(download_uid: string) { + const body: GetDownloadRequest = {download_uid: download_uid}; + return this.http.post(this.path + 'restartDownload', body, this.httpOptions); } - cancelDownload(download_uid) { - return this.http.post(this.path + 'cancelDownload', {download_uid: download_uid}, this.httpOptions); + cancelDownload(download_uid: string) { + const body: GetDownloadRequest = {download_uid: download_uid}; + return this.http.post(this.path + 'cancelDownload', body, this.httpOptions); } - clearDownload(download_uid) { - return this.http.post(this.path + 'clearDownload', {download_uid: download_uid}, this.httpOptions); + clearDownload(download_uid: string) { + const body: GetDownloadRequest = {download_uid: download_uid}; + return this.http.post(this.path + 'clearDownload', body, this.httpOptions); } clearFinishedDownloads() { return this.http.post(this.path + 'clearFinishedDownloads', {}, this.httpOptions); } + getTasks() { + return this.http.post(this.path + 'getTasks', {}, this.httpOptions); + } + + resetTasks() { + return this.http.post(this.path + 'resetTasks', {}, this.httpOptions); + } + + getTask(task_key: string) { + const body: GetTaskRequest = {task_key: task_key}; + return this.http.post(this.path + 'getTask', body, this.httpOptions); + } + + runTask(task_key: string) { + const body: GetTaskRequest = {task_key: task_key}; + return this.http.post(this.path + 'runTask', body, this.httpOptions); + } + + confirmTask(task_key: string) { + const body: GetTaskRequest = {task_key: task_key}; + return this.http.post(this.path + 'confirmTask', body, this.httpOptions); + } + + updateTaskSchedule(task_key: string, schedule: Schedule) { + const body: UpdateTaskScheduleRequest = {task_key: task_key, new_schedule: schedule}; + return this.http.post(this.path + 'updateTaskSchedule', body, this.httpOptions); + } + + updateTaskData(task_key: string, data: any) { + const body: UpdateTaskDataRequest = {task_key: task_key, new_data: data}; + return this.http.post(this.path + 'updateTaskData', body, this.httpOptions); + } + + getDBBackups() { + return this.http.post(this.path + 'getDBBackups', {}, this.httpOptions); + } + + restoreDBBackup(file_name: string) { + const body: RestoreDBBackupRequest = {file_name: file_name}; + return this.http.post(this.path + 'restoreDBBackup', body, this.httpOptions); + } + getVersionInfo() { return this.http.get(this.path + 'versionInfo', this.httpOptions); }