const { uuid } = require('uuidv4'); var fs = require('fs-extra'); var { promisify } = require('util'); var auth_api = require('./authentication/auth'); var winston = require('winston'); var path = require('path'); var youtubedl = require('youtube-dl'); var ffmpeg = require('fluent-ffmpeg'); var compression = require('compression'); var glob = require("glob") var multer = require('multer'); var express = require("express"); var bodyParser = require("body-parser"); var archiver = require('archiver'); var unzipper = require('unzipper'); var db_api = require('./db'); var utils = require('./utils') var mergeFiles = require('merge-files'); const low = require('lowdb') var ProgressBar = require('progress'); const NodeID3 = require('node-id3') const downloader = require('youtube-dl/lib/downloader') const fetch = require('node-fetch'); var URL = require('url').URL; const shortid = require('shortid') const url_api = require('url'); var config_api = require('./config.js'); var subscriptions_api = require('./subscriptions') var categories_api = require('./categories'); var twitch_api = require('./twitch'); const CONSTS = require('./consts') const { spawn } = require('child_process') const read_last_lines = require('read-last-lines'); var ps = require('ps-node'); const is_windows = process.platform === 'win32'; var app = express(); // database setup const FileSync = require('lowdb/adapters/FileSync'); const config = require('./config.js'); 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 // console format const defaultFormat = winston.format.printf(({ level, message, label, timestamp }) => { return `${timestamp} ${level.toUpperCase()}: ${message}`; }); const logger = winston.createLogger({ level: 'info', format: winston.format.combine(winston.format.timestamp(), defaultFormat), defaultMeta: {}, transports: [ // // - Write to all logs with level `info` and below to `combined.log` // - Write all logs error (and below) to `error.log`. // new winston.transports.File({ filename: 'appdata/logs/error.log', level: 'error' }), new winston.transports.File({ filename: 'appdata/logs/combined.log' }), new winston.transports.Console({level: !debugMode ? 'info' : 'debug', name: 'console'}) ] }); config_api.initialize(logger); auth_api.initialize(db, users_db, logger); db_api.initialize(db, users_db, logger); subscriptions_api.initialize(db, users_db, logger, db_api); categories_api.initialize(db, users_db, logger, db_api); // 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 var frontendUrl = null; var backendUrl = null; var backendPort = null; var basePath = null; var audioFolderPath = null; var videoFolderPath = null; var downloadOnlyMode = null; var useDefaultDownloadingAgent = null; var customDownloadingAgent = null; var allowSubscriptions = null; var subscriptionsCheckInterval = null; var archivePath = path.join(__dirname, 'appdata', 'archives'); // other needed values var url_domain = null; var updaterStatus = null; var timestamp_server_start = Date.now(); 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 = {}; // 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(); } var downloads = {}; app.use(bodyParser.urlencoded({ extended: false })); app.use(bodyParser.json()); // use passport app.use(auth_api.passport.initialize()); // actual functions async function checkMigrations() { // 3.5->3.6 migration const files_to_db_migration_complete = true; // migration phased out! previous code: db.get('files_to_db_migration_complete').value(); if (!files_to_db_migration_complete) { logger.info('Beginning migration: 3.5->3.6+') const success = await runFilesToDBMigration() if (success) { logger.info('3.5->3.6+ migration complete!'); } else { logger.error('Migration failed: 3.5->3.6+'); } } // 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 addMetadataPropertyToDB('view_count'); success = success && await addMetadataPropertyToDB('description'); success = success && await addMetadataPropertyToDB('height'); success = success && await addMetadataPropertyToDB('abr'); if (success) { logger.info('4.1->4.2+ migration complete!'); } else { logger.error('Migration failed: 4.1->4.2+'); } } return true; } async function runFilesToDBMigration() { try { let mp3s = await getMp3s(); let mp4s = await getMp4s(); for (let i = 0; i < mp3s.length; i++) { let file_obj = mp3s[i]; const file_already_in_db = db.get('files.audio').find({id: file_obj.id}).value(); if (!file_already_in_db) { logger.verbose(`Migrating file ${file_obj.id}`); await db_api.registerFileDB(file_obj.id + '.mp3', 'audio'); } } for (let i = 0; i < mp4s.length; i++) { let file_obj = mp4s[i]; const file_already_in_db = db.get('files.video').find({id: file_obj.id}).value(); if (!file_already_in_db) { logger.verbose(`Migrating file ${file_obj.id}`); await db_api.registerFileDB(file_obj.id + '.mp4', 'video'); } } // sets migration to complete db.set('files_to_db_migration_complete', true).write(); return true; } catch(err) { logger.error(err); return false; } } 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 addMetadataPropertyToDB(property_key) { try { const dirs_to_check = db_api.getFileDirectoriesAndDBs(); for (const dir_to_check of dirs_to_check) { // recursively get all files in dir's path const files = await utils.getDownloadedFilesByType(dir_to_check.basePath, dir_to_check.type, true); for (const file of files) { if (file[property_key]) { dir_to_check.dbPath.find({id: file.id}).assign({[property_key]: file[property_key]}).write(); } } } // sets migration to complete db.set('simplified_db_migration_complete', true).write(); return true; } catch(err) { logger.error(err); return false; } } 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 => { 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 type = entry.type; // 'Directory' or 'File' var size = entry.size; var is_dir = fileName.substring(fileName.length-1, fileName.length) === '/' if (!is_dir && fileName.includes('youtubedl-material/public/')) { // get public folder files var 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 var 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); try { 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(); // 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 = subscriptions_api.getAllSubscriptions(); subscriptions_api.updateSubscriptionPropertyMultiple(subscriptions, {downloading: false}); // runs initially, then runs every ${subscriptionCheckInterval} seconds watchSubscriptions(); setInterval(() => { watchSubscriptions(); }, subscriptionsCheckInterval * 1000); } db_api.importUnregisteredFiles(); // load in previous downloads downloads = db.get('downloads').value(); // 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'); audioFolderPath = config_api.getConfigItem('ytdl_audio_folder_path'); videoFolderPath = config_api.getConfigItem('ytdl_video_folder_path'); downloadOnlyMode = config_api.getConfigItem('ytdl_download_only_mode'); useDefaultDownloadingAgent = config_api.getConfigItem('ytdl_use_default_downloading_agent'); customDownloadingAgent = config_api.getConfigItem('ytdl_custom_downloading_agent'); allowSubscriptions = config_api.getConfigItem('ytdl_allow_subscriptions'); subscriptionsCheckInterval = config_api.getConfigItem('ytdl_subscriptions_check_interval'); 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 let interval_in_ms = subscriptionsCheckInterval * 1000; const subinterval_in_ms = interval_in_ms/subscriptions_amount; return subinterval_in_ms; } async function watchSubscriptions() { let subscriptions = 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; 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]}; } function getThumbnailMp3(name) { var obj = utils.getJSONMp3(name, audioFolderPath); var thumbnailLink = obj.thumbnail; return thumbnailLink; } function getThumbnailMp4(name) { var obj = utils.getJSONMp4(name, videoFolderPath); var thumbnailLink = obj.thumbnail; return thumbnailLink; } function getFileSizeMp3(name) { var jsonPath = audioFolderPath+name+".mp3.info.json"; if (fs.existsSync(jsonPath)) var obj = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); else var obj = 0; return obj.filesize; } function getFileSizeMp4(name) { var jsonPath = videoFolderPath+name+".info.json"; var filesize = 0; if (fs.existsSync(jsonPath)) { var obj = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); var format = obj.format.substring(0,3); for (i = 0; i < obj.formats.length; i++) { if (obj.formats[i].format_id == format) { filesize = obj.formats[i].filesize; } } } return filesize; } function getAmountDownloadedMp3(name) { var partPath = audioFolderPath+name+".mp3.part"; if (fs.existsSync(partPath)) { const stats = fs.statSync(partPath); const fileSizeInBytes = stats.size; return fileSizeInBytes; } else return 0; } function getAmountDownloadedMp4(name) { var format = getVideoFormatID(name); var partPath = videoFolderPath+name+".f"+format+".mp4.part"; if (fs.existsSync(partPath)) { const stats = fs.statSync(partPath); const fileSizeInBytes = stats.size; return fileSizeInBytes; } else return 0; } function getVideoFormatID(name) { var jsonPath = videoFolderPath+name+".info.json"; if (fs.existsSync(jsonPath)) { var obj = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); var format = obj.format.substring(0,3); return format; } } // TODO: add to db_api and support multi-user mode async function deleteFile(uid, uuid = null, blacklistMode = false) { const file_obj = await db_api.getVideo(uid, uuid); const type = file_obj.isAudio ? 'audio' : 'video'; const folderPath = path.dirname(file_obj.path); const ext = type === 'audio' ? 'mp3' : 'mp4'; const name = file_obj.id; const filePathNoExtension = utils.removeFileExtension(file_obj.path); var jsonPath = `${file_obj.path}.info.json`; var altJSONPath = `${filePathNoExtension}.info.json`; var thumbnailPath = `${filePathNoExtension}.webp`; var altThumbnailPath = `${filePathNoExtension}.jpg`; jsonPath = path.join(__dirname, jsonPath); altJSONPath = path.join(__dirname, altJSONPath); let jsonExists = await fs.pathExists(jsonPath); let thumbnailExists = await fs.pathExists(thumbnailPath); if (!jsonExists) { if (await fs.pathExists(altJSONPath)) { jsonExists = true; jsonPath = altJSONPath; } } if (!thumbnailExists) { if (await fs.pathExists(altThumbnailPath)) { thumbnailExists = true; thumbnailPath = altThumbnailPath; } } let fileExists = await fs.pathExists(file_obj.path); if (config_api.descriptors[name]) { try { for (let i = 0; i < config_api.descriptors[name].length; i++) { config_api.descriptors[name][i].destroy(); } } catch(e) { } } let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive'); if (useYoutubeDLArchive) { const archive_path = path.join(archivePath, `archive_${type}.txt`); // get ID from JSON var jsonobj = await (type === 'audio' ? utils.getJSONMp3(name, folderPath) : utils.getJSONMp4(name, folderPath)); let id = null; if (jsonobj) id = jsonobj.id; // use subscriptions API to remove video from the archive file, and write it to the blacklist if (await fs.pathExists(archive_path)) { const line = id ? await subscriptions_api.removeIDFromArchive(archive_path, id) : null; if (blacklistMode && line) await writeToBlacklist(type, line); } else { logger.info('Could not find archive file for audio files. Creating...'); await fs.close(await fs.open(archive_path, 'w')); } } if (jsonExists) await fs.unlink(jsonPath); if (thumbnailExists) await fs.unlink(thumbnailPath); if (fileExists) { await fs.unlink(file_obj.path); if (await fs.pathExists(jsonPath) || await fs.pathExists(file_obj.path)) { return false; } else { return true; } } else { // TODO: tell user that the file didn't exist return true; } } /** * @param {'audio' | 'video'} type * @param {string[]} fileNames */ async function getAudioOrVideoInfos(type, fileNames) { let result = await Promise.all(fileNames.map(async fileName => { let fileLocation = videoFolderPath+fileName; if (type === 'audio') { fileLocation += '.mp3.info.json'; } else if (type === 'video') { fileLocation += '.info.json'; } if (await fs.pathExists(fileLocation)) { let data = await fs.readFile(fileLocation); try { return JSON.parse(data); } catch (e) { let suffix; if (type === 'audio') { suffix += '.mp3'; } else if (type === 'video') { suffix += '.mp4'; } logger.error(`Could not find info for file ${fileName}${suffix}`); } } return null; })); return result.filter(data => data != null); } // downloads async function downloadFileByURL_exec(url, type, options, sessionID = null) { return new Promise(async resolve => { var date = Date.now(); // audio / video specific vars var is_audio = type === 'audio'; var ext = is_audio ? '.mp3' : '.mp4'; var fileFolderPath = type === 'audio' ? audioFolderPath : videoFolderPath; let category = null; // prepend with user if needed let multiUserMode = null; if (options.user) { let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); const user_path = path.join(usersFileFolder, options.user, type); fs.ensureDirSync(user_path); fileFolderPath = user_path + path.sep; multiUserMode = { user: options.user, file_path: fileFolderPath } options.customFileFolderPath = fileFolderPath; } options.downloading_method = 'exec'; let downloadConfig = await generateArgs(url, type, options); // adds download to download helper const download_uid = uuid(); const session = sessionID ? sessionID : 'undeclared'; if (!downloads[session]) downloads[session] = {}; downloads[session][download_uid] = { uid: download_uid, ui_uid: options.ui_uid, downloading: true, complete: false, url: url, type: type, percent_complete: 0, is_playlist: url.includes('playlist'), timestamp_start: Date.now(), filesize: null }; const download = downloads[session][download_uid]; updateDownloads(); let download_checker = null; // get video info prior to download let info = await getVideoInfoByURL(url, downloadConfig, download); if (!info && url.includes('youtu')) { resolve(false); return; } else if (info) { // check if it fits into a category. If so, then get info again using new downloadConfig if (!Array.isArray(info) || config_api.getConfigItem('ytdl_allow_playlist_categorization')) category = await categories_api.categorize(info); // set custom output if the category has one and re-retrieve info so the download manager has the right file name if (category && category['custom_output']) { options.customOutput = category['custom_output']; options.noRelativePath = true; downloadConfig = await generateArgs(url, type, options); info = await getVideoInfoByURL(url, downloadConfig, download); } // store info in download for future use if (Array.isArray(info)) { download['fileNames'] = []; for (let info_obj of info) download['fileNames'].push(info_obj['_filename']); } else { download['_filename'] = info['_filename']; } download['filesize'] = utils.getExpectedFileSize(info); download_checker = setInterval(() => checkDownloadPercent(download), 1000); } // download file youtubedl.exec(url, downloadConfig, {maxBuffer: Infinity}, async function(err, output) { if (download_checker) clearInterval(download_checker); // stops the download checker from running as the download finished (or errored) download['downloading'] = false; download['timestamp_end'] = Date.now(); var file_uid = null; let new_date = Date.now(); let difference = (new_date - date)/1000; logger.debug(`${is_audio ? 'Audio' : 'Video'} download delay: ${difference} seconds.`); if (err) { logger.error(err.stderr); download['error'] = err.stderr; updateDownloads(); resolve(false); return; } else if (output) { if (output.length === 0 || output[0].length === 0) { download['error'] = 'No output. Check if video already exists in your archive.'; logger.warn(`No output received for video download, check if it exists in your archive.`) updateDownloads(); resolve(false); return; } var file_names = []; for (let i = 0; i < output.length; i++) { let output_json = null; try { output_json = JSON.parse(output[i]); } catch(e) { output_json = null; } if (!output_json) { continue; } // get filepath with no extension const filepath_no_extension = utils.removeFileExtension(output_json['_filename']); var full_file_path = filepath_no_extension + ext; var file_name = filepath_no_extension.substring(fileFolderPath.length, filepath_no_extension.length); if (type === 'video' && url.includes('twitch.tv/videos/') && url.split('twitch.tv/videos/').length > 1 && config.getConfigItem('ytdl_use_twitch_api') && config.getConfigItem('ytdl_twitch_auto_download_chat')) { let vodId = url.split('twitch.tv/videos/')[1]; vodId = vodId.split('?')[0]; twitch_api.downloadTwitchChatByVODID(vodId, file_name, type, options.user); } // renames file if necessary due to bug if (!fs.existsSync(output_json['_filename'] && fs.existsSync(output_json['_filename'] + '.webm'))) { try { fs.renameSync(output_json['_filename'] + '.webm', output_json['_filename']); logger.info('Renamed ' + file_name + '.webm to ' + file_name); } catch(e) { } } if (type === 'audio') { let tags = { title: output_json['title'], artist: output_json['artist'] ? output_json['artist'] : output_json['uploader'] } let success = NodeID3.write(tags, utils.removeFileExtension(output_json['_filename']) + '.mp3'); if (!success) logger.error('Failed to apply ID3 tag to audio file ' + output_json['_filename']); } const file_path = options.noRelativePath ? path.basename(full_file_path) : full_file_path.substring(fileFolderPath.length, full_file_path.length); const customPath = options.noRelativePath ? path.dirname(full_file_path).split(path.sep).pop() : null; if (options.cropFileSettings) { await cropFile(full_file_path, options.cropFileSettings.cropFileStart, options.cropFileSettings.cropFileEnd, ext); } // registers file in DB file_uid = db_api.registerFileDB(file_path, type, multiUserMode, null, customPath, category, options.cropFileSettings); if (file_name) file_names.push(file_name); } let is_playlist = file_names.length > 1; if (options.merged_string !== null && options.merged_string !== undefined) { let current_merged_archive = fs.readFileSync(path.join(fileFolderPath, `merged_${type}.txt`), 'utf8'); let diff = current_merged_archive.replace(options.merged_string, ''); const archive_path = options.user ? path.join(fileFolderPath, 'archives', `archive_${type}.txt`) : path.join(archivePath, `archive_${type}.txt`); fs.appendFileSync(archive_path, diff); } download['complete'] = true; download['fileNames'] = is_playlist ? file_names : [full_file_path] updateDownloads(); var videopathEncoded = encodeURIComponent(file_names[0]); resolve({ [(type === 'audio') ? 'audiopathEncoded' : 'videopathEncoded']: videopathEncoded, file_names: is_playlist ? file_names : null, uid: file_uid }); } }); }); } async function downloadFileByURL_normal(url, type, options, sessionID = null) { return new Promise(async resolve => { var date = Date.now(); var file_uid = null; const is_audio = type === 'audio'; const ext = is_audio ? '.mp3' : '.mp4'; var fileFolderPath = is_audio ? audioFolderPath : videoFolderPath; if (is_audio && url.includes('youtu')) { options.skip_audio_args = true; } // prepend with user if needed let multiUserMode = null; if (options.user) { let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); const user_path = path.join(usersFileFolder, options.user, type); fs.ensureDirSync(user_path); fileFolderPath = user_path + path.sep; multiUserMode = { user: options.user, file_path: fileFolderPath } options.customFileFolderPath = fileFolderPath; } options.downloading_method = 'normal'; const downloadConfig = await generateArgs(url, type, options); // adds download to download helper const download_uid = uuid(); const session = sessionID ? sessionID : 'undeclared'; if (!downloads[session]) downloads[session] = {}; downloads[session][download_uid] = { uid: download_uid, ui_uid: options.ui_uid, downloading: true, complete: false, url: url, type: type, percent_complete: 0, is_playlist: url.includes('playlist'), timestamp_start: Date.now() }; const download = downloads[session][download_uid]; updateDownloads(); const video = youtubedl(url, // Optional arguments passed to youtube-dl. downloadConfig, // Additional options can be given for calling `child_process.execFile()`. { cwd: __dirname }); let video_info = null; let file_size = 0; // Will be called when the download starts. video.on('info', function(info) { video_info = info; file_size = video_info.size; const json_path = utils.removeFileExtension(video_info._filename) + '.info.json'; fs.ensureFileSync(json_path); fs.writeJSONSync(json_path, video_info); video.pipe(fs.createWriteStream(video_info._filename, { flags: 'w' })) }); // Will be called if download was already completed and there is nothing more to download. video.on('complete', function complete(info) { 'use strict' logger.info('file ' + info._filename + ' already downloaded.') }) let download_pos = 0; video.on('data', function data(chunk) { download_pos += chunk.length // `size` should not be 0 here. if (file_size) { let percent = (download_pos / file_size * 100).toFixed(2) download['percent_complete'] = percent; } }); video.on('end', async function() { let new_date = Date.now(); let difference = (new_date - date)/1000; logger.debug(`Video download delay: ${difference} seconds.`); download['timestamp_end'] = Date.now(); download['fileNames'] = [utils.removeFileExtension(video_info._filename) + ext]; download['complete'] = true; updateDownloads(); // audio-only cleanup if (is_audio) { // filename fix video_info['_filename'] = utils.removeFileExtension(video_info['_filename']) + '.mp3'; // ID3 tagging let tags = { title: video_info['title'], artist: video_info['artist'] ? video_info['artist'] : video_info['uploader'] } let success = NodeID3.write(tags, video_info._filename); if (!success) logger.error('Failed to apply ID3 tag to audio file ' + video_info._filename); const possible_webm_path = utils.removeFileExtension(video_info['_filename']) + '.webm'; const possible_mp4_path = utils.removeFileExtension(video_info['_filename']) + '.mp4'; // check if audio file is webm if (fs.existsSync(possible_webm_path)) await convertFileToMp3(possible_webm_path, video_info['_filename']); else if (fs.existsSync(possible_mp4_path)) await convertFileToMp3(possible_mp4_path, video_info['_filename']); } // registers file in DB const base_file_name = video_info._filename.substring(fileFolderPath.length, video_info._filename.length); file_uid = db_api.registerFileDB(base_file_name, type, multiUserMode); if (options.merged_string !== null && options.merged_string !== undefined) { let current_merged_archive = fs.readFileSync(path.join(fileFolderPath, `merged_${type}.txt`), 'utf8'); let diff = current_merged_archive.replace(options.merged_string, ''); const archive_path = options.user ? path.join(fileFolderPath, 'archives', `archive_${type}.txt`) : path.join(archivePath, `archive_${type}.txt`); fs.appendFileSync(archive_path, diff); } videopathEncoded = encodeURIComponent(utils.removeFileExtension(base_file_name)); resolve({ [is_audio ? 'audiopathEncoded' : 'videopathEncoded']: videopathEncoded, file_names: /*is_playlist ? file_names :*/ null, // playlist support is not ready uid: file_uid }); }); video.on('error', function error(err) { logger.error(err); download[error] = err; updateDownloads(); resolve(false); }); }); } async function generateArgs(url, type, options) { var videopath = config_api.getConfigItem('ytdl_default_file_output') ? config_api.getConfigItem('ytdl_default_file_output') : '%(title)s'; var globalArgs = config_api.getConfigItem('ytdl_custom_args'); let useCookies = config_api.getConfigItem('ytdl_use_cookies'); var is_audio = type === 'audio'; var fileFolderPath = is_audio ? audioFolderPath : videoFolderPath; if (options.customFileFolderPath) fileFolderPath = options.customFileFolderPath; var customArgs = options.customArgs; var customOutput = options.customOutput; var customQualityConfiguration = options.customQualityConfiguration; // video-specific args var selectedHeight = options.selectedHeight; // audio-specific args var maxBitrate = options.maxBitrate; var youtubeUsername = options.youtubeUsername; var youtubePassword = options.youtubePassword; let downloadConfig = null; let qualityPath = (is_audio && !options.skip_audio_args) ? ['-f', 'bestaudio'] : ['-f', 'bestvideo+bestaudio', '--merge-output-format', 'mp4']; const is_youtube = url.includes('youtu'); if (!is_audio && !is_youtube) { // tiktok videos fail when using the default format qualityPath = null; } else if (!is_audio && !is_youtube && (url.includes('reddit') || url.includes('pornhub'))) { qualityPath = ['-f', 'bestvideo+bestaudio'] } if (customArgs) { downloadConfig = customArgs.split(',,'); } else { if (customQualityConfiguration) { qualityPath = ['-f', customQualityConfiguration]; } else if (selectedHeight && selectedHeight !== '' && !is_audio) { qualityPath = ['-f', `'(mp4)[height=${selectedHeight}'`]; } else if (maxBitrate && is_audio) { qualityPath = ['--audio-quality', maxBitrate] } if (customOutput) { customOutput = options.noRelativePath ? customOutput : path.join(fileFolderPath, customOutput); downloadConfig = ['-o', `${customOutput}.%(ext)s`, '--write-info-json', '--print-json']; } else { downloadConfig = ['-o', path.join(fileFolderPath, videopath + (is_audio ? '.%(ext)s' : '.mp4')), '--write-info-json', '--print-json']; } if (qualityPath && options.downloading_method === 'exec') downloadConfig.push(...qualityPath); if (is_audio && !options.skip_audio_args) { downloadConfig.push('-x'); downloadConfig.push('--audio-format', 'mp3'); } if (youtubeUsername && youtubePassword) { downloadConfig.push('--username', youtubeUsername, '--password', youtubePassword); } if (useCookies) { if (await fs.pathExists(path.join(__dirname, 'appdata', 'cookies.txt'))) { downloadConfig.push('--cookies', path.join('appdata', 'cookies.txt')); } else { logger.warn('Cookies file could not be found. You can either upload one, or disable \'use cookies\' in the Advanced tab in the settings.'); } } if (!useDefaultDownloadingAgent && customDownloadingAgent) { downloadConfig.splice(0, 0, '--external-downloader', customDownloadingAgent); } let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive'); if (useYoutubeDLArchive) { const archive_folder = options.user ? path.join(fileFolderPath, 'archives') : archivePath; const archive_path = path.join(archive_folder, `archive_${type}.txt`); await fs.ensureDir(archive_folder); // create archive file if it doesn't exist if (!(await fs.pathExists(archive_path))) { await fs.close(await fs.open(archive_path, 'w')); } let blacklist_path = options.user ? path.join(fileFolderPath, 'archives', `blacklist_${type}.txt`) : path.join(archivePath, `blacklist_${type}.txt`); // create blacklist file if it doesn't exist if (!(await fs.pathExists(blacklist_path))) { await fs.close(await fs.open(blacklist_path, 'w')); } let merged_path = path.join(fileFolderPath, `merged_${type}.txt`); await fs.ensureFile(merged_path); // merges blacklist and regular archive let inputPathList = [archive_path, blacklist_path]; let status = await mergeFiles(inputPathList, merged_path); options.merged_string = await fs.readFile(merged_path, "utf8"); downloadConfig.push('--download-archive', merged_path); } if (config_api.getConfigItem('ytdl_include_thumbnail')) { downloadConfig.push('--write-thumbnail'); } if (globalArgs && globalArgs !== '') { // adds global args if (downloadConfig.indexOf('-o') !== -1 && globalArgs.split(',,').indexOf('-o') !== -1) { // if global args has an output, replce the original output with that of global args const original_output_index = downloadConfig.indexOf('-o'); downloadConfig.splice(original_output_index, 2); } downloadConfig = downloadConfig.concat(globalArgs.split(',,')); } } // filter out incompatible args downloadConfig = filterArgs(downloadConfig, is_audio); logger.verbose(`youtube-dl args being used: ${downloadConfig.join(',')}`); return downloadConfig; } async function getVideoInfoByURL(url, args = [], download = null) { return new Promise(resolve => { // remove bad args const new_args = [...args]; const archiveArgIndex = new_args.indexOf('--download-archive'); if (archiveArgIndex !== -1) { new_args.splice(archiveArgIndex, 2); } // actually get info youtubedl.getInfo(url, new_args, (err, output) => { if (output) { resolve(output); } else { logger.error(`Error while retrieving info on video with URL ${url} with the following message: ${err}`); if (err.stderr) { logger.error(`${err.stderr}`) } if (download) { download['error'] = `Failed pre-check for video info: ${err}`; updateDownloads(); } resolve(null); } }); }); } function filterArgs(args, isAudio) { const video_only_args = ['--add-metadata', '--embed-subs', '--xattrs']; const audio_only_args = ['-x', '--extract-audio', '--embed-thumbnail']; const args_to_remove = isAudio ? video_only_args : audio_only_args; return args.filter(x => !args_to_remove.includes(x)); } // currently only works for single urls async function getUrlInfos(urls) { let startDate = Date.now(); let result = []; return new Promise(resolve => { youtubedl.exec(urls.join(' '), ['--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 parsing: ${err}`); resolve(null); } let try_putput = null; try { try_putput = JSON.parse(output); result = try_putput; } catch(e) { // probably multiple urls logger.error('failed to parse for urls starting with ' + urls[0]); // logger.info(output); } resolve(result); }); }); } // ffmpeg helper functions async function convertFileToMp3(input_file, output_file) { logger.verbose(`Converting ${input_file} to ${output_file}...`); return new Promise(resolve => { ffmpeg(input_file).noVideo().toFormat('mp3') .on('end', () => { logger.verbose(`Conversion for '${output_file}' complete.`); fs.unlinkSync(input_file) resolve(true); }) .on('error', (err) => { logger.error('Failed to convert audio file to the correct format.'); logger.error(err); resolve(false); }).save(output_file); }); } async function cropFile(file_path, start, end, ext) { return new Promise(resolve => { const temp_file_path = `${file_path}.cropped${ext}`; let base_ffmpeg_call = ffmpeg(file_path); if (start) { base_ffmpeg_call = base_ffmpeg_call.seekOutput(start); } if (end) { base_ffmpeg_call = base_ffmpeg_call.duration(end - start); } base_ffmpeg_call .on('end', () => { logger.verbose(`Cropping for '${file_path}' complete.`); fs.unlinkSync(file_path); fs.moveSync(temp_file_path, file_path); resolve(true); }) .on('error', (err, test, test2) => { logger.error(`Failed to crop ${file_path}.`); logger.error(err); resolve(false); }).save(temp_file_path); }); } // archive helper functions async function writeToBlacklist(type, line) { let blacklistPath = path.join(archivePath, (type === 'audio') ? 'blacklist_audio.txt' : 'blacklist_video.txt'); // adds newline to the beginning of the line line.replace('\n', ''); line.replace('\r', ''); line = '\n' + line; await fs.appendFile(blacklistPath, line); } // download management functions function updateDownloads() { db.assign({downloads: downloads}).write(); } function checkDownloadPercent(download) { /* This is more of an art than a science, we're just selecting files that start with the file name, thus capturing the parts being downloaded in files named like so: '