Files
YoutubeDL-Material/backend/app.js
Isaac Abadi 091f81bb38 Added UI for managing tasks
Added ability to schedule tasks based on timestamp

Fixed mismatched types between frontend and openapi yaml

Simplified imports for several backend components
2022-04-21 03:01:49 -04:00

2101 lines
70 KiB
JavaScript

const { uuid } = require('uuidv4');
const fs = require('fs-extra');
const { promisify } = require('util');
const auth_api = require('./authentication/auth');
const winston = require('winston');
const path = require('path');
const compression = require('compression');
const multer = require('multer');
const express = require("express");
const bodyParser = require("body-parser");
const archiver = require('archiver');
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')
const read_last_lines = require('read-last-lines');
const ps = require('ps-node');
// needed if bin/details somehow gets deleted
if (!fs.existsSync(CONSTS.DETAILS_BIN_PATH)) fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, {"version":"2000.06.06","path":"node_modules\\youtube-dl\\bin\\youtube-dl.exe","exec":"youtube-dl.exe","downloader":"youtube-dl"})
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';
var app = express();
// database setup
const FileSync = require('lowdb/adapters/FileSync');
const adapter = new FileSync('./appdata/db.json');
const db = low(adapter)
const users_adapter = new FileSync('./appdata/users.json');
const users_db = low(users_adapter);
// env var setup
const umask = process.env.YTDL_UMASK;
if (umask) process.umask(parseInt(umask));
// check if debug mode
let debugMode = process.env.YTDL_MODE === 'debug';
const admin_token = '4241b401-7236-493e-92b5-b72696b9d853';
// logging setup
config_api.initialize();
db_api.initialize(db, users_db);
auth_api.initialize(db_api);
// Set some defaults
db.defaults(
{
playlists: [],
files: [],
configWriteFlag: false,
downloads: {},
subscriptions: [],
files_to_db_migration_complete: false
}).write();
users_db.defaults(
{
users: [],
roles: {
"admin": {
"permissions": [
'filemanager',
'settings',
'subscriptions',
'sharing',
'advanced_download',
'downloads_manager'
]
}, "user": {
"permissions": [
'filemanager',
'subscriptions',
'sharing'
]
}
}
}
).write();
// config values
let url = null;
let backendPort = null;
let useDefaultDownloadingAgent = null;
let customDownloadingAgent = null;
let allowSubscriptions = null;
let archivePath = path.join(__dirname, 'appdata', 'archives');
// other needed values
let url_domain = null;
let updaterStatus = null;
const concurrentStreams = {};
if (debugMode) logger.info('YTDL-Material in debug mode!');
// check if just updated
const just_updated = fs.existsSync('restart_update.json');
if (just_updated) {
updaterStatus = {
updating: false,
details: 'Update complete! You are now on ' + CONSTS['CURRENT_VERSION']
}
fs.unlinkSync('restart_update.json');
}
if (fs.existsSync('restart_general.json')) fs.unlinkSync('restart_general.json');
// updates & starts youtubedl (commented out b/c of repo takedown)
// startYoutubeDL();
var validDownloadingAgents = [
'aria2c',
'avconv',
'axel',
'curl',
'ffmpeg',
'httpie',
'wget'
];
const subscription_timeouts = {};
let version_info = null;
if (fs.existsSync('version.json')) {
version_info = fs.readJSONSync('version.json');
logger.verbose(`Version info: ${JSON.stringify(version_info, null, 2)}`);
} else {
version_info = {'type': 'N/A', 'tag': 'N/A', 'commit': 'N/A', 'date': 'N/A'};
}
// don't overwrite config if it already happened.. NOT
// let alreadyWritten = db.get('configWriteFlag').value();
let writeConfigMode = process.env.write_ytdl_config;
// checks if config exists, if not, a config is auto generated
config_api.configExistsCheck();
if (writeConfigMode) {
setAndLoadConfig();
} else {
loadConfig();
}
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
// use passport
app.use(auth_api.passport.initialize());
// actual functions
async function checkMigrations() {
// 4.1->4.2 migration
const simplified_db_migration_complete = db.get('simplified_db_migration_complete').value();
if (!simplified_db_migration_complete) {
logger.info('Beginning migration: 4.1->4.2+')
let success = await simplifyDBFileStructure();
success = success && await db_api.addMetadataPropertyToDB('view_count');
success = success && await db_api.addMetadataPropertyToDB('description');
success = success && await db_api.addMetadataPropertyToDB('height');
success = success && await db_api.addMetadataPropertyToDB('abr');
// sets migration to complete
db.set('simplified_db_migration_complete', true).write();
if (success) { logger.info('4.1->4.2+ migration complete!'); }
else { logger.error('Migration failed: 4.1->4.2+'); }
}
const new_db_system_migration_complete = db.get('new_db_system_migration_complete').value();
if (!new_db_system_migration_complete) {
logger.info('Beginning migration: 4.2->4.3+')
let success = await db_api.importJSONToDB(db.value(), users_db.value());
// sets migration to complete
db.set('new_db_system_migration_complete', true).write();
if (success) { logger.info('4.2->4.3+ migration complete!'); }
else { logger.error('Migration failed: 4.2->4.3+'); }
}
return true;
}
async function simplifyDBFileStructure() {
// back up db files
const old_db_file = fs.readJSONSync('./appdata/db.json');
const old_users_db_file = fs.readJSONSync('./appdata/users.json');
fs.writeJSONSync('appdata/db.old.json', old_db_file);
fs.writeJSONSync('appdata/users.old.json', old_users_db_file);
// simplify
let users = users_db.get('users').value();
for (let i = 0; i < users.length; i++) {
const user = users[i];
if (user['files']['video'] !== undefined && user['files']['audio'] !== undefined) {
const user_files = user['files']['video'].concat(user['files']['audio']);
const user_db_path = users_db.get('users').find({uid: user['uid']});
user_db_path.assign({files: user_files}).write();
}
if (user['playlists']['video'] !== undefined && user['playlists']['audio'] !== undefined) {
const user_playlists = user['playlists']['video'].concat(user['playlists']['audio']);
const user_db_path = users_db.get('users').find({uid: user['uid']});
user_db_path.assign({playlists: user_playlists}).write();
}
}
if (db.get('files.video').value() !== undefined && db.get('files.audio').value() !== undefined) {
const files = db.get('files.video').value().concat(db.get('files.audio').value());
db.assign({files: files}).write();
}
if (db.get('playlists.video').value() !== undefined && db.get('playlists.audio').value() !== undefined) {
const playlists = db.get('playlists.video').value().concat(db.get('playlists.audio').value());
db.assign({playlists: playlists}).write();
}
return true;
}
async function startServer() {
if (process.env.USING_HEROKU && process.env.PORT) {
// default to heroku port if using heroku
backendPort = process.env.PORT || backendPort;
// set config to port
await setPortItemFromENV();
}
app.listen(backendPort,function(){
logger.info(`YoutubeDL-Material ${CONSTS['CURRENT_VERSION']} started on PORT ${backendPort}`);
});
}
async function restartServer(is_update = false) {
logger.info(`${is_update ? 'Update complete! ' : ''}Restarting server...`);
// the following line restarts the server through nodemon
fs.writeFileSync(`restart${is_update ? '_update' : '_general'}.json`, 'internal use only');
process.exit(1);
}
async function updateServer(tag) {
// no tag provided means update to the latest version
if (!tag) {
const new_version_available = await isNewVersionAvailable();
if (!new_version_available) {
logger.error('ERROR: Failed to update - no update is available.');
return false;
}
}
return new Promise(async resolve => {
// backup current dir
updaterStatus = {
updating: true,
'details': 'Backing up key server files...'
}
let backup_succeeded = await backupServerLite();
if (!backup_succeeded) {
resolve(false);
return false;
}
updaterStatus = {
updating: true,
'details': 'Downloading requested release...'
}
// grab new package.json and public folder
await downloadReleaseFiles(tag);
updaterStatus = {
updating: true,
'details': 'Installing new dependencies...'
}
// run npm install
await installDependencies();
updaterStatus = {
updating: true,
'details': 'Update complete! Restarting server...'
}
restartServer(true);
}, err => {
logger.error(err);
updaterStatus = {
updating: false,
error: true,
'details': 'Update failed. Check error logs for more info.'
}
});
}
async function downloadReleaseFiles(tag) {
tag = tag ? tag : await getLatestVersion();
return new Promise(async resolve => {
logger.info('Downloading new files...')
// downloads the latest release zip file
await downloadReleaseZip(tag);
// deletes contents of public dir
fs.removeSync(path.join(__dirname, 'public'));
fs.mkdirSync(path.join(__dirname, 'public'));
let replace_ignore_list = ['youtubedl-material/appdata/default.json',
'youtubedl-material/appdata/db.json',
'youtubedl-material/appdata/users.json',
'youtubedl-material/appdata/*']
logger.info(`Installing update ${tag}...`)
// downloads new package.json and adds new public dir files from the downloaded zip
fs.createReadStream(path.join(__dirname, `youtubedl-material-release-${tag}.zip`)).pipe(unzipper.Parse())
.on('entry', function (entry) {
var fileName = entry.path;
var is_dir = fileName.substring(fileName.length-1, fileName.length) === '/'
if (!is_dir && fileName.includes('youtubedl-material/public/')) {
// get public folder files
const actualFileName = fileName.replace('youtubedl-material/public/', '');
if (actualFileName.length !== 0 && actualFileName.substring(actualFileName.length-1, actualFileName.length) !== '/') {
fs.ensureDirSync(path.join(__dirname, 'public', path.dirname(actualFileName)));
entry.pipe(fs.createWriteStream(path.join(__dirname, 'public', actualFileName)));
} else {
entry.autodrain();
}
} else if (!is_dir && !replace_ignore_list.includes(fileName)) {
// get package.json
const actualFileName = fileName.replace('youtubedl-material/', '');
logger.verbose('Downloading file ' + actualFileName);
entry.pipe(fs.createWriteStream(path.join(__dirname, actualFileName)));
} else {
entry.autodrain();
}
})
.on('close', function () {
resolve(true);
});
});
}
// 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
const latest_release_link = `https://github.com/Tzahi12345/YoutubeDL-Material/releases/download/${tag}/`;
const tag_without_v = tag.substring(1, tag.length);
const zip_file_name = `youtubedl-material-${tag_without_v}.zip`
const latest_zip_link = latest_release_link + zip_file_name;
let output_path = path.join(__dirname, `youtubedl-material-release-${tag}.zip`);
// download zip from release
await fetchFile(latest_zip_link, output_path, 'update ' + tag);
resolve(true);
});
}
async function installDependencies() {
var child_process = require('child_process');
var exec = promisify(child_process.exec);
await exec('npm install',{stdio:[0,1,2]});
return true;
}
async function backupServerLite() {
await fs.ensureDir(path.join(__dirname, 'appdata', 'backups'));
let output_path = path.join('appdata', 'backups', `backup-${Date.now()}.zip`);
logger.info(`Backing up your non-video/audio files to ${output_path}. This may take up to a few seconds/minutes.`);
let output = fs.createWriteStream(path.join(__dirname, output_path));
await new Promise(resolve => {
var archive = archiver('zip', {
gzip: true,
zlib: { level: 9 } // Sets the compression level.
});
archive.on('error', function(err) {
logger.error(err);
resolve(false);
});
// pipe archive data to the output file
archive.pipe(output);
// ignore certain directories (ones with video or audio files)
const files_to_ignore = [path.join(config_api.getConfigItem('ytdl_subscriptions_base_path'), '**'),
path.join(config_api.getConfigItem('ytdl_audio_folder_path'), '**'),
path.join(config_api.getConfigItem('ytdl_video_folder_path'), '**'),
'appdata/backups/backup-*.zip'];
archive.glob('**/*', {
ignore: files_to_ignore
});
resolve(archive.finalize());
});
// wait a tiny bit for the zip to reload in fs
await utils.wait(100);
return true;
}
async function isNewVersionAvailable() {
// gets tag of the latest version of youtubedl-material, compare to current version
const latest_tag = await getLatestVersion();
const current_tag = CONSTS['CURRENT_VERSION'];
if (latest_tag > current_tag) {
return true;
} else {
return false;
}
}
async function getLatestVersion() {
const res = await fetch('https://api.github.com/repos/tzahi12345/youtubedl-material/releases/latest', {method: 'Get'});
const json = await res.json();
if (json['message']) {
// means there's an error in getting latest version
logger.error(`ERROR: Received the following message from GitHub's API:`);
logger.error(json['message']);
if (json['documentation_url']) logger.error(`Associated URL: ${json['documentation_url']}`)
}
return json['tag_name'];
}
async function killAllDownloads() {
const lookupAsync = promisify(ps.lookup);
let resultList = null;
try {
resultList = await lookupAsync({
command: 'youtube-dl'
});
} catch (err) {
// failed to get list of processes
logger.error('Failed to get a list of running youtube-dl processes.');
logger.error(err);
return {
details: err,
success: false
};
}
// processes that contain the string 'youtube-dl' in the name will be looped
resultList.forEach(function( process ){
if (process) {
ps.kill(process.pid, 'SIGKILL', function( err ) {
if (err) {
// failed to kill, process may have ended on its own
logger.warn(`Failed to kill process with PID ${process.pid}`);
logger.warn(err);
}
else {
logger.verbose(`Process ${process.pid} has been killed!`);
}
});
}
});
return {
success: true
};
}
async function setPortItemFromENV() {
config_api.setConfigItem('ytdl_port', backendPort.toString());
await utils.wait(100);
return true;
}
async function setAndLoadConfig() {
await setConfigFromEnv();
await loadConfig();
}
async function setConfigFromEnv() {
let config_items = getEnvConfigItems();
let success = config_api.setConfigItems(config_items);
if (success) {
logger.info('Config items set using ENV variables.');
await utils.wait(100);
return true;
} else {
logger.error('ERROR: Failed to set config items using ENV variables.');
return false;
}
}
async function loadConfig() {
loadConfigValues();
// connect to DB
await db_api.connectToDB();
db_api.database_initialized = true;
db_api.database_initialized_bs.next(true);
// creates archive path if missing
await fs.ensureDir(archivePath);
// check migrations
await checkMigrations();
// now this is done here due to youtube-dl's repo takedown
await startYoutubeDL();
// get subscriptions
if (allowSubscriptions) {
// set downloading to false
let subscriptions = await subscriptions_api.getAllSubscriptions();
subscriptions_api.updateSubscriptionPropertyMultiple(subscriptions, {downloading: false});
// runs initially, then runs every ${subscriptionCheckInterval} seconds
const watchSubscriptionsInterval = function() {
watchSubscriptions();
const subscriptionsCheckInterval = config_api.getConfigItem('ytdl_subscriptions_check_interval');
setTimeout(watchSubscriptionsInterval, subscriptionsCheckInterval*1000);
}
watchSubscriptionsInterval();
}
db_api.importUnregisteredFiles();
// start the server here
startServer();
return true;
}
function loadConfigValues() {
url = !debugMode ? config_api.getConfigItem('ytdl_url') : 'http://localhost:4200';
backendPort = config_api.getConfigItem('ytdl_port');
useDefaultDownloadingAgent = config_api.getConfigItem('ytdl_use_default_downloading_agent');
customDownloadingAgent = config_api.getConfigItem('ytdl_custom_downloading_agent');
allowSubscriptions = config_api.getConfigItem('ytdl_allow_subscriptions');
if (!useDefaultDownloadingAgent && validDownloadingAgents.indexOf(customDownloadingAgent) !== -1 ) {
logger.info(`Using non-default downloading agent \'${customDownloadingAgent}\'`)
} else {
customDownloadingAgent = null;
}
// empty url defaults to default URL
if (!url || url === '') url = 'http://example.com'
url_domain = new URL(url);
let logger_level = config_api.getConfigItem('ytdl_logger_level');
const possible_levels = ['error', 'warn', 'info', 'verbose', 'debug'];
if (!possible_levels.includes(logger_level)) {
logger.error(`${logger_level} is not a valid logger level! Choose one of the following: ${possible_levels.join(', ')}.`)
logger_level = 'info';
}
logger.level = logger_level;
winston.loggers.get('console').level = logger_level;
logger.transports[2].level = logger_level;
}
function calculateSubcriptionRetrievalDelay(subscriptions_amount) {
// frequency is once every 5 mins by default
const subscriptionsCheckInterval = config_api.getConfigItem('ytdl_subscriptions_check_interval');
let interval_in_ms = subscriptionsCheckInterval * 1000;
const subinterval_in_ms = interval_in_ms/subscriptions_amount;
return subinterval_in_ms;
}
async function watchSubscriptions() {
let subscriptions = await subscriptions_api.getAllSubscriptions();
if (!subscriptions) return;
const valid_subscriptions = subscriptions.filter(sub => !sub.paused);
let subscriptions_amount = valid_subscriptions.length;
let delay_interval = calculateSubcriptionRetrievalDelay(subscriptions_amount);
let current_delay = 0;
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
for (let i = 0; i < valid_subscriptions.length; i++) {
let sub = valid_subscriptions[i];
// don't check the sub if the last check for the same subscription has not completed
if (subscription_timeouts[sub.id]) {
logger.verbose(`Subscription: skipped checking ${sub.name} as the last check for ${sub.name} has not completed.`);
continue;
}
if (!sub.name) {
logger.verbose(`Subscription: skipped check for subscription with uid ${sub.id} as name has not been retrieved yet.`);
continue;
}
logger.verbose('Watching ' + sub.name + ' with delay interval of ' + delay_interval);
setTimeout(async () => {
const multiUserModeChanged = config_api.getConfigItem('ytdl_multi_user_mode') !== multiUserMode;
if (multiUserModeChanged) {
logger.verbose(`Skipping subscription ${sub.name} due to multi-user mode change.`);
return;
}
await subscriptions_api.getVideosForSub(sub, sub.user_uid);
subscription_timeouts[sub.id] = false;
}, current_delay);
subscription_timeouts[sub.id] = true;
current_delay += delay_interval;
const subscriptionsCheckInterval = config_api.getConfigItem('ytdl_subscriptions_check_interval');
if (current_delay >= subscriptionsCheckInterval * 1000) current_delay = 0;
}
}
function getOrigin() {
return url_domain.origin;
}
// gets a list of config items that are stored as an environment variable
function getEnvConfigItems() {
let config_items = [];
let config_item_keys = Object.keys(config_api.CONFIG_ITEMS);
for (let i = 0; i < config_item_keys.length; i++) {
let key = config_item_keys[i];
if (process['env'][key]) {
const config_item = generateEnvVarConfigItem(key);
config_items.push(config_item);
}
}
return config_items;
}
// gets value of a config item and stores it in an object
function generateEnvVarConfigItem(key) {
return {key: key, value: process['env'][key]};
}
// currently only works for single urls
async function getUrlInfos(url) {
let startDate = Date.now();
let result = [];
return new Promise(resolve => {
youtubedl.exec(url, ['--dump-json'], {maxBuffer: Infinity}, (err, output) => {
let new_date = Date.now();
let difference = (new_date - startDate)/1000;
logger.debug(`URL info retrieval delay: ${difference} seconds.`);
if (err) {
logger.error(`Error during retrieving formats for ${url}: ${err}`);
resolve(null);
}
let try_putput = null;
try {
try_putput = JSON.parse(output);
result = try_putput;
} catch(e) {
logger.error(`Failed to retrieve available formats for url: ${url}`);
}
resolve(result);
});
});
}
// youtube-dl functions
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();
}
});
});
}
app.use(function(req, res, next) {
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization");
res.header("Access-Control-Allow-Origin", getOrigin());
if (req.method === 'OPTIONS') {
res.sendStatus(200);
} else {
next();
}
});
app.use(function(req, res, next) {
if (!req.path.includes('/api/')) {
next();
} else if (req.query.apiKey === admin_token) {
next();
} else if (req.query.apiKey && config_api.getConfigItem('ytdl_use_api_key') && req.query.apiKey === config_api.getConfigItem('ytdl_api_key')) {
next();
} else if (req.path.includes('/api/stream/') || req.path.includes('/api/thumbnail/')) {
next();
} else {
logger.verbose(`Rejecting request - invalid API use for endpoint: ${req.path}. API key received: ${req.query.apiKey}`);
req.socket.end();
}
});
app.use(compression());
const optionalJwt = async function (req, res, next) {
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
if (multiUserMode && ((req.body && req.body.uuid) || (req.query && req.query.uuid)) && (req.path.includes('/api/getFile') ||
req.path.includes('/api/stream') ||
req.path.includes('/api/getPlaylist') ||
req.path.includes('/api/downloadFileFromServer'))) {
// check if shared video
const using_body = req.body && req.body.uuid;
const uuid = using_body ? req.body.uuid : req.query.uuid;
const uid = using_body ? req.body.uid : req.query.uid;
const playlist_id = using_body ? req.body.playlist_id : req.query.playlist_id;
const file = !playlist_id ? auth_api.getUserVideo(uuid, uid, true) : await db_api.getPlaylist(playlist_id, uuid, true);
if (file) {
req.can_watch = true;
return next();
} else {
res.sendStatus(401);
return;
}
} else if (multiUserMode && !(req.path.includes('/api/auth/register') && !(req.path.includes('/api/config')) && !req.query.jwt)) { // registration should get passed through
if (!req.query.jwt) {
res.sendStatus(401);
return;
}
return auth_api.passport.authenticate('jwt', { session: false })(req, res, next);
}
return next();
};
app.get('/api/config', function(req, res) {
let config_file = config_api.getConfigFile();
res.send({
config_file: config_file,
success: !!config_file
});
});
app.post('/api/setConfig', optionalJwt, function(req, res) {
let new_config_file = req.body.new_config_file;
if (new_config_file && new_config_file['YoutubeDLMaterial']) {
let success = config_api.setConfigFile(new_config_file);
loadConfigValues(); // reloads config values that exist as variables
res.send({
success: success
});
} else {
logger.error('Tried to save invalid config file!')
res.sendStatus(400);
}
});
app.get('/api/versionInfo', (req, res) => {
res.send({version_info: version_info});
});
app.post('/api/restartServer', optionalJwt, (req, res) => {
// delayed by a little bit so that the client gets a response
setTimeout(() => {restartServer()}, 100);
res.send({success: true});
});
app.get('/api/getDBInfo', optionalJwt, async (req, res) => {
const db_info = await db_api.getDBStats();
res.send({db_info: db_info});
});
app.post('/api/transferDB', optionalJwt, async (req, res) => {
const local_to_remote = req.body.local_to_remote;
let success = null;
let error = '';
if (local_to_remote === config_api.getConfigItem('ytdl_use_local_db')) {
success = await db_api.transferDB(local_to_remote);
if (!success) error = 'Unknown error';
else config_api.setConfigItem('ytdl_use_local_db', !local_to_remote);
} else {
success = false;
error = `Failed to transfer DB as it cannot transition into its current status: ${local_to_remote ? 'MongoDB' : 'Local DB'}`;
logger.error(error);
}
res.send({success: success, error: error});
});
app.post('/api/testConnectionString', optionalJwt, async (req, res) => {
const connection_string = req.body.connection_string;
let success = null;
let error = '';
success = await db_api.connectToDB(0, true, connection_string);
if (!success) error = 'Connection string failed.';
res.send({success: success, error: error});
});
app.post('/api/downloadFile', optionalJwt, async function(req, res) {
req.setTimeout(0); // remove timeout in case of long videos
const url = req.body.url;
const type = req.body.type;
const user_uid = req.isAuthenticated() ? req.user.uid : null;
const options = {
customArgs: req.body.customArgs,
additionalArgs: req.body.additionalArgs,
customOutput: req.body.customOutput,
selectedHeight: req.body.selectedHeight,
customQualityConfiguration: req.body.customQualityConfiguration,
youtubeUsername: req.body.youtubeUsername,
youtubePassword: req.body.youtubePassword,
ui_uid: req.body.ui_uid,
cropFileSettings: req.body.cropFileSettings
};
const download = await downloader_api.createDownload(url, type, options, user_uid);
if (download) {
res.send({download: download});
} else {
res.sendStatus(500);
}
});
app.post('/api/killAllDownloads', optionalJwt, async function(req, res) {
const result_obj = await killAllDownloads();
res.send(result_obj);
});
app.post('/api/generateArgs', optionalJwt, async function(req, res) {
const url = req.body.url;
const type = req.body.type;
const user_uid = req.isAuthenticated() ? req.user.uid : null;
const options = {
customArgs: req.body.customArgs,
additionalArgs: req.body.additionalArgs,
customOutput: req.body.customOutput,
selectedHeight: req.body.selectedHeight,
customQualityConfiguration: req.body.customQualityConfiguration,
youtubeUsername: req.body.youtubeUsername,
youtubePassword: req.body.youtubePassword,
ui_uid: req.body.ui_uid,
cropFileSettings: req.body.cropFileSettings
};
const args = await downloader_api.generateArgs(url, type, options, user_uid, true);
res.send({args: args});
});
// gets all download mp3s
app.get('/api/getMp3s', optionalJwt, async function(req, res) {
// TODO: simplify
let mp3s = await db_api.getRecords('files', {isAudio: true});
let playlists = await db_api.getRecords('playlists');
const is_authenticated = req.isAuthenticated();
if (is_authenticated) {
// get user audio files/playlists
auth_api.passport.authenticate('jwt')
mp3s = await db_api.getRecords('files', {user_uid: req.user.uid, isAudio: true});
playlists = await db_api.getRecords('playlists', {user_uid: req.user.uid}); // TODO: remove?
}
mp3s = JSON.parse(JSON.stringify(mp3s));
res.send({
mp3s: mp3s,
playlists: playlists
});
});
// gets all download mp4s
app.get('/api/getMp4s', optionalJwt, async function(req, res) {
let mp4s = await db_api.getRecords('files', {isAudio: false});
let playlists = await db_api.getRecords('playlists');
const is_authenticated = req.isAuthenticated();
if (is_authenticated) {
// get user videos/playlists
auth_api.passport.authenticate('jwt')
mp4s = await db_api.getRecords('files', {user_uid: req.user.uid, isAudio: false});
playlists = await db_api.getRecords('playlists', {user_uid: req.user.uid}); // TODO: remove?
}
mp4s = JSON.parse(JSON.stringify(mp4s));
res.send({
mp4s: mp4s,
playlists: playlists
});
});
app.post('/api/getFile', optionalJwt, async function (req, res) {
const uid = req.body.uid;
const uuid = req.body.uuid;
let file = await db_api.getRecord('files', {uid: uid});
if (uuid && !file['sharingEnabled']) file = null;
// check if chat exists for twitch videos
if (file && file['url'].includes('twitch.tv')) file['chat_exists'] = fs.existsSync(file['path'].substring(0, file['path'].length - 4) + '.twitch_chat.json');
if (file) {
res.send({
success: true,
file: file
});
} else {
res.send({
success: false
});
}
});
app.post('/api/getAllFiles', optionalJwt, async function (req, res) {
// these are returned
let files = null;
let playlists = null;
let sort = req.body.sort;
let range = req.body.range;
let text_search = req.body.text_search;
let file_type_filter = req.body.file_type_filter;
const uuid = req.isAuthenticated() ? req.user.uid : null;
const filter_obj = {user_uid: uuid};
const regex = true;
if (text_search) {
if (regex) {
filter_obj['title'] = {$regex: `.*${text_search}.*`, $options: 'i'};
} else {
filter_obj['$text'] = { $search: utils.createEdgeNGrams(text_search) };
}
}
if (file_type_filter === 'audio_only') filter_obj['isAudio'] = true;
else if (file_type_filter === 'video_only') filter_obj['isAudio'] = false;
files = await db_api.getRecords('files', filter_obj, false, sort, range, text_search);
let file_count = await db_api.getRecords('files', filter_obj, true);
playlists = await db_api.getRecords('playlists', {user_uid: uuid});
const categories = await categories_api.getCategoriesAsPlaylists(files);
if (categories) {
playlists = playlists.concat(categories);
}
files = JSON.parse(JSON.stringify(files));
res.send({
files: files,
file_count: file_count,
playlists: playlists
});
});
app.post('/api/checkConcurrentStream', async (req, res) => {
const uid = req.body.uid;
const DEAD_SERVER_THRESHOLD = 10;
if (concurrentStreams[uid] && Date.now()/1000 - concurrentStreams[uid]['unix_timestamp'] > DEAD_SERVER_THRESHOLD) {
logger.verbose( `Killing dead stream on ${uid}`);
delete concurrentStreams[uid];
}
res.send({stream: concurrentStreams[uid]})
});
app.post('/api/updateConcurrentStream', optionalJwt, async (req, res) => {
const uid = req.body.uid;
const playback_timestamp = req.body.playback_timestamp;
const unix_timestamp = req.body.unix_timestamp;
const playing = req.body.playing;
concurrentStreams[uid] = {
playback_timestamp: playback_timestamp,
unix_timestamp: unix_timestamp,
playing: playing
}
res.send({stream: concurrentStreams[uid]})
});
app.post('/api/getFullTwitchChat', optionalJwt, async (req, res) => {
var id = req.body.id;
var type = req.body.type;
var uuid = req.body.uuid;
var sub = req.body.sub;
var user_uid = null;
if (req.isAuthenticated()) user_uid = req.user.uid;
const chat_file = await twitch_api.getTwitchChatByFileID(id, type, user_uid, uuid, sub);
res.send({
chat: chat_file
});
});
app.post('/api/downloadTwitchChatByVODID', optionalJwt, async (req, res) => {
var id = req.body.id;
var type = req.body.type;
var vodId = req.body.vodId;
var uuid = req.body.uuid;
var sub = req.body.sub;
var user_uid = null;
if (req.isAuthenticated()) user_uid = req.user.uid;
// check if file already exists. if so, send that instead
const file_exists_check = await twitch_api.getTwitchChatByFileID(id, type, user_uid, uuid, sub);
if (file_exists_check) {
res.send({chat: file_exists_check});
return;
}
const full_chat = await twitch_api.downloadTwitchChatByVODID(vodId, id, type, user_uid, sub);
res.send({
chat: full_chat
});
});
// video sharing
app.post('/api/enableSharing', optionalJwt, async (req, res) => {
var uid = req.body.uid;
var is_playlist = req.body.is_playlist;
let success = false;
// multi-user mode
if (req.isAuthenticated()) {
// if multi user mode, use this method instead
success = auth_api.changeSharingMode(req.user.uid, uid, is_playlist, true);
res.send({success: success});
return;
}
// single-user mode
try {
success = true;
if (!is_playlist) {
await db_api.updateRecord('files', {uid: uid}, {sharingEnabled: true})
} else if (is_playlist) {
await db_api.updateRecord(`playlists`, {id: uid}, {sharingEnabled: true});
} else if (false) {
// TODO: Implement.
} else {
// error
success = false;
}
} catch(err) {
logger.error(err);
success = false;
}
res.send({
success: success
});
});
app.post('/api/disableSharing', optionalJwt, async function(req, res) {
var type = req.body.type;
var uid = req.body.uid;
var is_playlist = req.body.is_playlist;
let success = null;
try {
success = true;
if (!is_playlist && type !== 'subscription') {
await db_api.updateRecord('files', {uid: uid}, {sharingEnabled: false})
} else if (is_playlist) {
await db_api.updateRecord(`playlists`, {id: uid}, {sharingEnabled: false});
} else if (type === 'subscription') {
// TODO: Implement. Main blocker right now is subscription videos are not stored in the DB, they are searched for every
// time they are requested from the subscription directory.
} else {
// error
success = false;
}
} catch(err) {
success = false;
}
res.send({
success: success
});
});
app.post('/api/incrementViewCount', optionalJwt, async (req, res) => {
let file_uid = req.body.file_uid;
let sub_id = req.body.sub_id;
let uuid = req.body.uuid;
if (!uuid && req.isAuthenticated()) {
uuid = req.user.uid;
}
const file_obj = await db_api.getVideo(file_uid, uuid, sub_id);
const current_view_count = file_obj && file_obj['local_view_count'] ? file_obj['local_view_count'] : 0;
const new_view_count = current_view_count + 1;
await db_api.setVideoProperty(file_uid, {local_view_count: new_view_count}, uuid, sub_id);
res.send({
success: true
});
});
// categories
app.post('/api/getAllCategories', optionalJwt, async (req, res) => {
const categories = await db_api.getRecords('categories');
res.send({categories: categories});
});
app.post('/api/createCategory', optionalJwt, async (req, res) => {
const name = req.body.name;
const new_category = {
name: name,
uid: uuid(),
rules: [],
custom_output: ''
};
await db_api.insertRecordIntoTable('categories', new_category);
res.send({
new_category: new_category,
success: !!new_category
});
});
app.post('/api/deleteCategory', optionalJwt, async (req, res) => {
const category_uid = req.body.category_uid;
await db_api.removeRecord('categories', {uid: category_uid});
res.send({
success: true
});
});
app.post('/api/updateCategory', optionalJwt, async (req, res) => {
const category = req.body.category;
await db_api.updateRecord('categories', {uid: category.uid}, category)
res.send({success: true});
});
app.post('/api/updateCategories', optionalJwt, async (req, res) => {
const categories = req.body.categories;
await db_api.removeAllRecords('categories');
await db_api.insertRecordsIntoTable('categories', categories);
res.send({success: true});
});
// subscriptions
app.post('/api/subscribe', optionalJwt, async (req, res) => {
let name = req.body.name;
let url = req.body.url;
let maxQuality = req.body.maxQuality;
let timerange = req.body.timerange;
let audioOnly = req.body.audioOnly;
let customArgs = req.body.customArgs;
let customOutput = req.body.customFileOutput;
let user_uid = req.isAuthenticated() ? req.user.uid : null;
const new_sub = {
name: name,
url: url,
maxQuality: maxQuality,
id: uuid(),
user_uid: user_uid,
type: audioOnly ? 'audio' : 'video'
};
// adds timerange if it exists, otherwise all videos will be downloaded
if (timerange) {
new_sub.timerange = timerange;
}
if (customArgs && customArgs !== '') {
new_sub.custom_args = customArgs;
}
if (customOutput && customOutput !== '') {
new_sub.custom_output = customOutput;
}
const result_obj = await subscriptions_api.subscribe(new_sub, user_uid);
if (result_obj.success) {
res.send({
new_sub: new_sub
});
} else {
res.send({
new_sub: null,
error: result_obj.error
})
}
});
app.post('/api/unsubscribe', optionalJwt, async (req, res) => {
let deleteMode = req.body.deleteMode
let sub = req.body.sub;
let user_uid = req.isAuthenticated() ? req.user.uid : null;
let result_obj = subscriptions_api.unsubscribe(sub, deleteMode, user_uid);
if (result_obj.success) {
res.send({
success: result_obj.success
});
} else {
res.send({
success: false,
error: result_obj.error
});
}
});
app.post('/api/deleteSubscriptionFile', optionalJwt, async (req, res) => {
let deleteForever = req.body.deleteForever;
let file = req.body.file;
let file_uid = req.body.file_uid;
let sub = req.body.sub;
let user_uid = req.isAuthenticated() ? req.user.uid : null;
let success = await subscriptions_api.deleteSubscriptionFile(sub, file, deleteForever, file_uid, user_uid);
if (success) {
res.send({
success: success
});
} else {
res.sendStatus(500);
}
});
app.post('/api/getSubscription', optionalJwt, async (req, res) => {
let subID = req.body.id;
let subName = req.body.name; // if included, subID is optional
// get sub from db
let subscription = null;
if (subID) {
subscription = await subscriptions_api.getSubscription(subID)
} else if (subName) {
subscription = await subscriptions_api.getSubscriptionByName(subName)
}
if (!subscription) {
// failed to get subscription from db, send 400 error
res.sendStatus(400);
return;
}
subscription = JSON.parse(JSON.stringify(subscription));
// get sub videos
if (subscription.name && !subscription.streamingOnly) {
var parsed_files = await db_api.getRecords('files', {sub_id: subscription.id}); // subscription.videos;
subscription['videos'] = parsed_files;
// loop through files for extra processing
for (let i = 0; i < parsed_files.length; i++) {
const file = parsed_files[i];
// check if chat exists for twitch videos
if (file && file['url'].includes('twitch.tv')) file['chat_exists'] = fs.existsSync(file['path'].substring(0, file['path'].length - 4) + '.twitch_chat.json');
}
res.send({
subscription: subscription,
files: parsed_files
});
} else if (subscription.name && subscription.streamingOnly) {
// return list of videos
let parsed_files = [];
if (subscription.videos) {
for (let i = 0; i < subscription.videos.length; i++) {
const video = subscription.videos[i];
parsed_files.push(new utils.File(video.title, video.title, video.thumbnail, false, video.duration, video.url, video.uploader, video.size, null, null, video.upload_date, video.view_count, video.height, video.abr));
}
}
res.send({
subscription: subscription,
files: parsed_files
});
} else {
res.sendStatus(500);
}
});
app.post('/api/downloadVideosForSubscription', optionalJwt, async (req, res) => {
let subID = req.body.subID;
let user_uid = req.isAuthenticated() ? req.user.uid : null;
let sub = subscriptions_api.getSubscription(subID, user_uid);
subscriptions_api.getVideosForSub(sub, user_uid);
res.send({
success: true
});
});
app.post('/api/updateSubscription', optionalJwt, async (req, res) => {
let updated_sub = req.body.subscription;
let user_uid = req.isAuthenticated() ? req.user.uid : null;
let success = subscriptions_api.updateSubscription(updated_sub, user_uid);
res.send({
success: success
});
});
app.post('/api/getSubscriptions', optionalJwt, async (req, res) => {
let user_uid = req.isAuthenticated() ? req.user.uid : null;
// get subs from api
let subscriptions = await subscriptions_api.getSubscriptions(user_uid);
res.send({
subscriptions: subscriptions
});
});
app.post('/api/createPlaylist', optionalJwt, async (req, res) => {
let playlistName = req.body.playlistName;
let uids = req.body.uids;
let type = req.body.type;
const new_playlist = await db_api.createPlaylist(playlistName, uids, type, req.isAuthenticated() ? req.user.uid : null);
res.send({
new_playlist: new_playlist,
success: !!new_playlist // always going to be true
})
});
app.post('/api/getPlaylist', optionalJwt, async (req, res) => {
let playlist_id = req.body.playlist_id;
let uuid = req.body.uuid ? req.body.uuid : (req.user && req.user.uid ? req.user.uid : null);
let include_file_metadata = req.body.include_file_metadata;
const playlist = await db_api.getPlaylist(playlist_id, uuid);
const file_objs = [];
if (playlist && include_file_metadata) {
for (let i = 0; i < playlist['uids'].length; i++) {
const uid = playlist['uids'][i];
const file_obj = await db_api.getVideo(uid, uuid);
if (file_obj) file_objs.push(file_obj);
// TODO: remove file from playlist if could not be found
}
}
res.send({
playlist: playlist,
file_objs: file_objs,
type: playlist && playlist.type,
success: !!playlist
});
});
app.post('/api/getPlaylists', optionalJwt, async (req, res) => {
const uuid = req.isAuthenticated() ? req.user.uid : null;
const include_categories = req.body.include_categories;
let playlists = await db_api.getRecords('playlists', {user_uid: uuid});
if (include_categories) {
const categories = await categories_api.getCategoriesAsPlaylists(files);
if (categories) {
playlists = playlists.concat(categories);
}
}
res.send({
playlists: playlists
});
});
app.post('/api/addFileToPlaylist', optionalJwt, async (req, res) => {
let playlist_id = req.body.playlist_id;
let file_uid = req.body.file_uid;
const playlist = await db_api.getRecord('playlists', {id: playlist_id});
playlist.uids.push(file_uid);
let success = await db_api.updatePlaylist(playlist);
res.send({
success: success
});
});
app.post('/api/updatePlaylist', optionalJwt, async (req, res) => {
let playlist = req.body.playlist;
let success = await db_api.updatePlaylist(playlist, req.user && req.user.uid);
res.send({
success: success
});
});
app.post('/api/deletePlaylist', optionalJwt, async (req, res) => {
let playlistID = req.body.playlist_id;
let success = null;
try {
// removes playlist from playlists
await db_api.removeRecord('playlists', {id: playlistID})
success = true;
} catch(e) {
success = false;
}
res.send({
success: success
})
});
// deletes non-subscription files
app.post('/api/deleteFile', optionalJwt, async (req, res) => {
const uid = req.body.uid;
const blacklistMode = req.body.blacklistMode;
const uuid = req.isAuthenticated() ? req.user.uid : null;
let wasDeleted = false;
wasDeleted = await db_api.deleteFile(uid, uuid, blacklistMode);
res.send(wasDeleted);
});
app.post('/api/downloadFileFromServer', optionalJwt, async (req, res) => {
let uid = req.body.uid;
let uuid = req.body.uuid;
let playlist_id = req.body.playlist_id;
let sub_id = req.body.sub_id;
let file_path_to_download = null;
if (!uuid && req.user) uuid = req.user.uid;
let zip_file_generated = false;
if (playlist_id) {
zip_file_generated = true;
const playlist_files_to_download = [];
const playlist = await db_api.getPlaylist(playlist_id, uuid);
for (let i = 0; i < playlist['uids'].length; i++) {
const playlist_file_uid = playlist['uids'][i];
const file_obj = await db_api.getVideo(playlist_file_uid, uuid);
playlist_files_to_download.push(file_obj);
}
// generate zip
file_path_to_download = await utils.createContainerZipFile(playlist, playlist_files_to_download);
} else if (sub_id && !uid) {
zip_file_generated = true;
const sub_files_to_download = [];
const sub = subscriptions_api.getSubscription(sub_id, uuid);
for (let i = 0; i < sub['videos'].length; i++) {
const sub_file = sub['videos'][i];
sub_files_to_download.push(sub_file);
}
// generate zip
file_path_to_download = await utils.createContainerZipFile(sub, sub_files_to_download);
} else {
const file_obj = await db_api.getVideo(uid, uuid, sub_id)
file_path_to_download = file_obj.path;
}
if (!path.isAbsolute(file_path_to_download)) file_path_to_download = path.join(__dirname, file_path_to_download);
res.sendFile(file_path_to_download, function (err) {
if (err) {
logger.error(err);
} else if (zip_file_generated) {
try {
// delete generated zip file
fs.unlinkSync(file_path_to_download);
} catch(e) {
logger.error(`Failed to remove file after sending to client: ${file_path_to_download}`);
}
}
});
});
app.post('/api/downloadArchive', optionalJwt, async (req, res) => {
let sub = req.body.sub;
let archive_dir = sub.archive;
let full_archive_path = path.join(archive_dir, 'archive.txt');
if (await fs.pathExists(full_archive_path)) {
res.sendFile(full_archive_path);
} else {
res.sendStatus(404);
}
});
var upload_multer = multer({ dest: __dirname + '/appdata/' });
app.post('/api/uploadCookies', upload_multer.single('cookies'), async (req, res) => {
const new_path = path.join(__dirname, 'appdata', 'cookies.txt');
if (await fs.pathExists(req.file.path)) {
await fs.rename(req.file.path, new_path);
} else {
res.sendStatus(500);
return;
}
if (await fs.pathExists(new_path)) {
res.send({success: true});
} else {
res.sendStatus(500);
}
});
// Updater API calls
app.get('/api/updaterStatus', optionalJwt, async (req, res) => {
let status = updaterStatus;
if (status) {
res.send(updaterStatus);
} else {
res.sendStatus(404);
}
});
app.post('/api/updateServer', optionalJwt, async (req, res) => {
let tag = req.body.tag;
updateServer(tag);
res.send({
success: true
});
});
// API Key API calls
app.post('/api/generateNewAPIKey', optionalJwt, function (req, res) {
const new_api_key = uuid();
config_api.setConfigItem('ytdl_api_key', new_api_key);
res.send({new_api_key: new_api_key});
});
// Streaming API calls
app.get('/api/stream', optionalJwt, async (req, res) => {
const type = req.query.type;
const uuid = req.query.uuid ? req.query.uuid : (req.user ? req.user.uid : null);
const sub_id = req.query.sub_id;
const mimetype = type === 'audio' ? 'audio/mp3' : 'video/mp4';
var head;
let uid = decodeURIComponent(req.query.uid);
let file_path = null;
let file_obj = null;
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
if (!multiUserMode || req.isAuthenticated() || req.can_watch) {
file_obj = await db_api.getVideo(uid, uuid, sub_id);
if (file_obj) file_path = file_obj['path'];
else file_path = null;
}
if (!fs.existsSync(file_path)) {
logger.error(`File ${file_path} could not be found! UID: ${uid}, ID: ${file_obj.id}`);
}
const stat = fs.statSync(file_path);
const fileSize = stat.size;
const range = req.headers.range;
if (range) {
const parts = range.replace(/bytes=/, "").split("-")
const start = parseInt(parts[0], 10)
const end = parts[1]
? parseInt(parts[1], 10)
: fileSize-1
const chunksize = (end-start)+1
const file = fs.createReadStream(file_path, {start, end})
if (config_api.descriptors[uid]) config_api.descriptors[uid].push(file);
else config_api.descriptors[uid] = [file];
file.on('close', function() {
let index = config_api.descriptors[uid].indexOf(file);
config_api.descriptors[uid].splice(index, 1);
logger.debug('Successfully closed stream and removed file reference.');
});
head = {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': mimetype,
}
res.writeHead(206, head);
file.pipe(res);
} else {
head = {
'Content-Length': fileSize,
'Content-Type': mimetype,
}
res.writeHead(200, head)
fs.createReadStream(file_path).pipe(res)
}
});
app.get('/api/thumbnail/:path', optionalJwt, async (req, res) => {
let file_path = decodeURIComponent(req.params.path);
if (fs.existsSync(file_path)) path.isAbsolute(file_path) ? res.sendFile(file_path) : res.sendFile(path.join(__dirname, file_path));
else res.sendStatus(404);
});
// Downloads management
app.post('/api/downloads', optionalJwt, async (req, res) => {
const user_uid = req.isAuthenticated() ? req.user.uid : null;
const uids = req.body.uids;
let downloads = await db_api.getRecords('download_queue', {user_uid: user_uid});
if (uids) downloads = downloads.filter(download => uids.includes(download['uid']));
res.send({downloads: downloads});
});
app.post('/api/download', optionalJwt, async (req, res) => {
const download_uid = req.body.download_uid;
const download = await db_api.getRecord('download_queue', {uid: download_uid});
if (download) {
res.send({download: download});
} else {
res.send({download: null});
}
});
app.post('/api/clearFinishedDownloads', optionalJwt, async (req, res) => {
const user_uid = req.isAuthenticated() ? req.user.uid : null;
const success = db_api.removeAllRecords('download_queue', {finished: true, user_uid: user_uid});
res.send({success: success});
});
app.post('/api/clearDownload', optionalJwt, async (req, res) => {
const download_uid = req.body.download_uid;
const success = await downloader_api.clearDownload(download_uid);
res.send({success: success});
});
app.post('/api/pauseDownload', optionalJwt, async (req, res) => {
const download_uid = req.body.download_uid;
const success = await downloader_api.pauseDownload(download_uid);
res.send({success: success});
});
app.post('/api/pauseAllDownloads', optionalJwt, async (req, res) => {
const user_uid = req.isAuthenticated() ? req.user.uid : null;
let success = true;
const all_running_downloads = await db_api.getRecords('download_queue', {paused: false, finished: false, user_uid: user_uid});
for (let i = 0; i < all_running_downloads.length; i++) {
success &= await downloader_api.pauseDownload(all_running_downloads[i]['uid']);
}
res.send({success: success});
});
app.post('/api/resumeDownload', optionalJwt, async (req, res) => {
const download_uid = req.body.download_uid;
const success = await downloader_api.resumeDownload(download_uid);
res.send({success: success});
});
app.post('/api/resumeAllDownloads', optionalJwt, async (req, res) => {
const user_uid = req.isAuthenticated() ? req.user.uid : null;
let success = true;
const all_paused_downloads = await db_api.getRecords('download_queue', {paused: true, user_uid: user_uid, error: null});
for (let i = 0; i < all_paused_downloads.length; i++) {
success &= await downloader_api.resumeDownload(all_paused_downloads[i]['uid']);
}
res.send({success: success});
});
app.post('/api/restartDownload', optionalJwt, async (req, res) => {
const download_uid = req.body.download_uid;
const success = await downloader_api.restartDownload(download_uid);
res.send({success: success});
});
app.post('/api/cancelDownload', optionalJwt, async (req, res) => {
const download_uid = req.body.download_uid;
const success = await downloader_api.cancelDownload(download_uid);
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/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});
});
// logs management
app.post('/api/logs', optionalJwt, async function(req, res) {
let logs = null;
let lines = req.body.lines;
const logs_path = path.join('appdata', 'logs', 'combined.log')
if (await fs.pathExists(logs_path)) {
if (lines) logs = await read_last_lines.read(logs_path, lines);
else logs = await fs.readFile(logs_path, 'utf8');
}
else
logger.error(`Failed to find logs file at the expected location: ${logs_path}`)
res.send({
logs: logs,
success: !!logs
});
});
app.post('/api/clearAllLogs', optionalJwt, async function(req, res) {
const logs_path = path.join('appdata', 'logs', 'combined.log');
const logs_err_path = path.join('appdata', 'logs', 'error.log');
let success = false;
try {
await Promise.all([
fs.writeFile(logs_path, ''),
fs.writeFile(logs_err_path, '')
])
success = true;
} catch(e) {
logger.error(e);
}
res.send({
success: success
});
});
app.post('/api/getFileFormats', optionalJwt, async (req, res) => {
let url = req.body.url;
let result = await getUrlInfos(url);
res.send({
result: result,
success: !!result
})
});
// user authentication
app.post('/api/auth/register'
, optionalJwt
, auth_api.registerUser);
app.post('/api/auth/login'
, auth_api.passport.authenticate(['local', 'ldapauth'], {})
, auth_api.generateJWT
, auth_api.returnAuthResponse
);
app.post('/api/auth/jwtAuth'
, auth_api.passport.authenticate('jwt', { session: false })
, auth_api.passport.authorize('jwt')
, auth_api.generateJWT
, auth_api.returnAuthResponse
);
app.post('/api/auth/changePassword', optionalJwt, async (req, res) => {
let user_uid = req.body.user_uid;
let password = req.body.new_password;
let success = await auth_api.changeUserPassword(user_uid, password);
res.send({success: success});
});
app.post('/api/auth/adminExists', async (req, res) => {
let exists = await auth_api.adminExists();
res.send({exists: exists});
});
// user management
app.post('/api/getUsers', optionalJwt, async (req, res) => {
let users = await db_api.getRecords('users');
res.send({users: users});
});
app.post('/api/getRoles', optionalJwt, async (req, res) => {
let roles = await db_api.getRecords('roles');
res.send({roles: roles});
});
app.post('/api/updateUser', optionalJwt, async (req, res) => {
let change_obj = req.body.change_object;
try {
if (change_obj.name) {
await db_api.updateRecord('users', {uid: change_obj.uid}, {name: change_obj.name});
}
if (change_obj.role) {
await db_api.updateRecord('users', {uid: change_obj.uid}, {role: change_obj.role});
}
res.send({success: true});
} catch (err) {
logger.error(err);
res.send({success: false});
}
});
app.post('/api/deleteUser', optionalJwt, async (req, res) => {
let uid = req.body.uid;
try {
let success = false;
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const user_folder = path.join(__dirname, usersFileFolder, uid);
const user_db_obj = await db_api.getRecord('users', {uid: uid});
if (user_db_obj) {
// user exists, let's delete
await fs.remove(user_folder);
await db_api.removeRecord('users', {uid: uid});
success = true;
} else {
logger.error(`Could not find user with uid ${uid}`);
}
res.send({success: success});
} catch (err) {
logger.error(err);
res.send({success: false});
}
});
app.post('/api/changeUserPermissions', optionalJwt, async (req, res) => {
const user_uid = req.body.user_uid;
const permission = req.body.permission;
const new_value = req.body.new_value;
if (!permission || !new_value) {
res.sendStatus(400);
return;
}
const success = await auth_api.changeUserPermissions(user_uid, permission, new_value);
res.send({success: success});
});
app.post('/api/changeRolePermissions', optionalJwt, async (req, res) => {
const role = req.body.role;
const permission = req.body.permission;
const new_value = req.body.new_value;
if (!permission || !new_value) {
res.sendStatus(400);
return;
}
const success = await auth_api.changeRolePermissions(role, permission, new_value);
res.send({success: success});
});
app.use(function(req, res, next) {
//if the request is not html then move along
var accept = req.accepts('html', 'json', 'xml');
if (accept !== 'html') {
return next();
}
// if the request has a '.' assume that it's for a file, move along
var ext = path.extname(req.path);
if (ext !== '') {
return next();
}
let index_path = path.join(__dirname, 'public', 'index.html');
fs.createReadStream(index_path).pipe(res);
});
let public_dir = path.join(__dirname, 'public');
app.use(express.static(public_dir));