|
|
|
@ -5,7 +5,6 @@ const auth_api = require('./authentication/auth');
|
|
|
|
|
const winston = require('winston');
|
|
|
|
|
const path = require('path');
|
|
|
|
|
const compression = require('compression');
|
|
|
|
|
const glob = require("glob")
|
|
|
|
|
const multer = require('multer');
|
|
|
|
|
const express = require("express");
|
|
|
|
|
const bodyParser = require("body-parser");
|
|
|
|
@ -13,13 +12,10 @@ const archiver = require('archiver');
|
|
|
|
|
const unzipper = require('unzipper');
|
|
|
|
|
const db_api = require('./db');
|
|
|
|
|
const utils = require('./utils')
|
|
|
|
|
const mergeFiles = require('merge-files');
|
|
|
|
|
const low = require('lowdb')
|
|
|
|
|
const ProgressBar = require('progress');
|
|
|
|
|
const NodeID3 = require('node-id3')
|
|
|
|
|
const fetch = require('node-fetch');
|
|
|
|
|
const URL = require('url').URL;
|
|
|
|
|
const url_api = require('url');
|
|
|
|
|
const CONSTS = require('./consts')
|
|
|
|
|
const read_last_lines = require('read-last-lines');
|
|
|
|
|
const ps = require('ps-node');
|
|
|
|
@ -42,7 +38,6 @@ 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)
|
|
|
|
@ -105,17 +100,16 @@ users_db.defaults(
|
|
|
|
|
).write();
|
|
|
|
|
|
|
|
|
|
// config values
|
|
|
|
|
var backendPort = null;
|
|
|
|
|
var audioFolderPath = null;
|
|
|
|
|
var videoFolderPath = null;
|
|
|
|
|
var useDefaultDownloadingAgent = null;
|
|
|
|
|
var customDownloadingAgent = null;
|
|
|
|
|
var allowSubscriptions = null;
|
|
|
|
|
var archivePath = path.join(__dirname, 'appdata', 'archives');
|
|
|
|
|
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
|
|
|
|
|
var url_domain = null;
|
|
|
|
|
var updaterStatus = null;
|
|
|
|
|
let url_domain = null;
|
|
|
|
|
let updaterStatus = null;
|
|
|
|
|
|
|
|
|
|
const concurrentStreams = {};
|
|
|
|
|
|
|
|
|
@ -161,8 +155,6 @@ if (writeConfigMode) {
|
|
|
|
|
loadConfig();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var downloads = [];
|
|
|
|
|
|
|
|
|
|
app.use(bodyParser.urlencoded({ extended: false }));
|
|
|
|
|
app.use(bodyParser.json());
|
|
|
|
|
|
|
|
|
@ -172,16 +164,6 @@ 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();
|
|
|
|
@ -212,38 +194,6 @@ async function checkMigrations() {
|
|
|
|
|
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}`);
|
|
|
|
|
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}`);
|
|
|
|
|
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');
|
|
|
|
@ -517,9 +467,10 @@ async function getLatestVersion() {
|
|
|
|
|
|
|
|
|
|
async function killAllDownloads() {
|
|
|
|
|
const lookupAsync = promisify(ps.lookup);
|
|
|
|
|
let resultList = null;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await lookupAsync({
|
|
|
|
|
resultList = await lookupAsync({
|
|
|
|
|
command: 'youtube-dl'
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
@ -609,9 +560,6 @@ async function loadConfig() {
|
|
|
|
|
|
|
|
|
|
await db_api.importUnregisteredFiles();
|
|
|
|
|
|
|
|
|
|
// load in previous downloads
|
|
|
|
|
downloads = await db_api.getRecords('downloads');
|
|
|
|
|
|
|
|
|
|
// start the server here
|
|
|
|
|
startServer();
|
|
|
|
|
|
|
|
|
@ -621,9 +569,6 @@ async function loadConfig() {
|
|
|
|
|
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');
|
|
|
|
@ -726,420 +671,17 @@ function generateEnvVarConfigItem(key) {
|
|
|
|
|
return {key: key, value: process['env'][key]};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @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
|
|
|
|
|
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;
|
|
|
|
|
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';
|
|
|
|
|
let session_downloads = downloads.find(potential_session_downloads => potential_session_downloads['session_id'] === session);
|
|
|
|
|
if (!session_downloads) {
|
|
|
|
|
session_downloads = {session_id: session};
|
|
|
|
|
downloads.push(session_downloads);
|
|
|
|
|
}
|
|
|
|
|
session_downloads[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 = session_downloads[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_objs = [];
|
|
|
|
|
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']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (options.cropFileSettings) {
|
|
|
|
|
await utils.cropFile(full_file_path, options.cropFileSettings.cropFileStart, options.cropFileSettings.cropFileEnd, ext);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// registers file in DB
|
|
|
|
|
const file_obj = await db_api.registerFileDB2(full_file_path, type, options.user, category, null, options.cropFileSettings);
|
|
|
|
|
|
|
|
|
|
// TODO: remove the following line
|
|
|
|
|
if (file_name) file_names.push(file_name);
|
|
|
|
|
|
|
|
|
|
file_objs.push(file_obj);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
|
|
|
|
let container = null;
|
|
|
|
|
|
|
|
|
|
if (file_objs.length > 1) {
|
|
|
|
|
// create playlist
|
|
|
|
|
const playlist_name = file_objs.map(file_obj => file_obj.title).join(', ');
|
|
|
|
|
const duration = file_objs.reduce((a, b) => a + utils.durationStringToNumber(b.duration), 0);
|
|
|
|
|
container = await db_api.createPlaylist(playlist_name, file_objs.map(file_obj => file_obj.uid), type, options.user);
|
|
|
|
|
} else if (file_objs.length === 1) {
|
|
|
|
|
container = file_objs[0];
|
|
|
|
|
} else {
|
|
|
|
|
logger.error('Downloaded file failed to result in metadata object.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resolve({
|
|
|
|
|
file_uids: file_objs.map(file_obj => file_obj.uid),
|
|
|
|
|
container: container
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
// TODO: fix
|
|
|
|
|
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 (is_audio) {
|
|
|
|
|
qualityPath = ['--audio-quality', maxBitrate ? maxBitrate : '0']
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(',,'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rate_limit = config_api.getConfigItem('ytdl_download_rate_limit');
|
|
|
|
|
if (rate_limit && downloadConfig.indexOf('-r') === -1 && downloadConfig.indexOf('--limit-rate') === -1) {
|
|
|
|
|
downloadConfig.push('-r', rate_limit);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const default_downloader = utils.getCurrentDownloader() || config_api.getConfigItem('ytdl_default_downloader');
|
|
|
|
|
if (default_downloader === 'yt-dlp') {
|
|
|
|
|
downloadConfig.push('--no-clean-infojson');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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) {
|
|
|
|
|
async function getUrlInfos(url) {
|
|
|
|
|
let startDate = Date.now();
|
|
|
|
|
let result = [];
|
|
|
|
|
return new Promise(resolve => {
|
|
|
|
|
youtubedl.exec(urls.join(' '), ['--dump-json'], {maxBuffer: Infinity}, (err, output) => {
|
|
|
|
|
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 parsing: ${err}`);
|
|
|
|
|
logger.error(`Error during retrieving formats for ${url}: ${err}`);
|
|
|
|
|
resolve(null);
|
|
|
|
|
}
|
|
|
|
|
let try_putput = null;
|
|
|
|
@ -1147,54 +689,13 @@ async function getUrlInfos(urls) {
|
|
|
|
|
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);
|
|
|
|
|
logger.error(`Failed to retrieve available formats for url: ${url}`);
|
|
|
|
|
}
|
|
|
|
|
resolve(result);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// download management functions
|
|
|
|
|
|
|
|
|
|
async function updateDownloads() {
|
|
|
|
|
await db_api.removeAllRecords('downloads');
|
|
|
|
|
if (downloads.length !== 0) await db_api.insertRecordsIntoTable('downloads', downloads);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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: '<video title>.<format>.<ext>.part'.
|
|
|
|
|
|
|
|
|
|
Any file that starts with <video title> will be counted as part of the "bytes downloaded", which will
|
|
|
|
|
be divided by the "total expected bytes."
|
|
|
|
|
*/
|
|
|
|
|
const file_id = download['file_id'];
|
|
|
|
|
// assume it's a playlist for logic reasons
|
|
|
|
|
const fileNames = Array.isArray(download['fileNames']) ? download['fileNames']
|
|
|
|
|
: [path.format(path.parse(utils.removeFileExtension(download['_filename'])))];
|
|
|
|
|
const resulting_file_size = download['filesize'];
|
|
|
|
|
|
|
|
|
|
if (!resulting_file_size) return;
|
|
|
|
|
|
|
|
|
|
let sum_size = 0;
|
|
|
|
|
glob(`{${fileNames.join(',')}, }*`, (err, files) => {
|
|
|
|
|
files.forEach(file => {
|
|
|
|
|
try {
|
|
|
|
|
const file_stats = fs.statSync(file);
|
|
|
|
|
if (file_stats && file_stats.size) {
|
|
|
|
|
sum_size += file_stats.size;
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
download['percent_complete'] = (sum_size/resulting_file_size * 100).toFixed(2);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// youtube-dl functions
|
|
|
|
|
|
|
|
|
|
async function startYoutubeDL() {
|
|
|
|
@ -1538,11 +1039,10 @@ app.get('/api/getMp4s', optionalJwt, async function(req, res) {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post('/api/getFile', optionalJwt, async function (req, res) {
|
|
|
|
|
var uid = req.body.uid;
|
|
|
|
|
var type = req.body.type;
|
|
|
|
|
var uuid = req.body.uuid;
|
|
|
|
|
const uid = req.body.uid;
|
|
|
|
|
const uuid = req.body.uuid;
|
|
|
|
|
|
|
|
|
|
var file = await db_api.getRecord('files', {uid: uid});
|
|
|
|
|
let file = await db_api.getRecord('files', {uid: uid});
|
|
|
|
|
|
|
|
|
|
if (uuid && !file['sharingEnabled']) file = null;
|
|
|
|
|
|
|
|
|
@ -1672,8 +1172,7 @@ app.post('/api/enableSharing', optionalJwt, async (req, res) => {
|
|
|
|
|
} else if (is_playlist) {
|
|
|
|
|
await db_api.updateRecord(`playlists`, {id: uid}, {sharingEnabled: true});
|
|
|
|
|
} else if (false) {
|
|
|
|
|
// 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.
|
|
|
|
|
// TODO: Implement.
|
|
|
|
|
} else {
|
|
|
|
|
// error
|
|
|
|
|
success = false;
|
|
|
|
@ -1693,16 +1192,8 @@ 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;
|
|
|
|
|
|
|
|
|
|
// multi-user mode
|
|
|
|
|
if (req.isAuthenticated()) {
|
|
|
|
|
// if multi user mode, use this method instead
|
|
|
|
|
success = auth_api.changeSharingMode(req.user.uid, uid, is_playlist, false);
|
|
|
|
|
res.send({success: success});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// single-user mode
|
|
|
|
|
try {
|
|
|
|
|
success = true;
|
|
|
|
|
if (!is_playlist && type !== 'subscription') {
|
|
|
|
@ -1904,53 +1395,11 @@ app.post('/api/getSubscription', optionalJwt, async (req, res) => {
|
|
|
|
|
if (subscription.name && !subscription.streamingOnly) {
|
|
|
|
|
var parsed_files = await db_api.getRecords('files', {sub_id: subscription.id}); // subscription.videos;
|
|
|
|
|
subscription['videos'] = parsed_files;
|
|
|
|
|
if (!parsed_files) {
|
|
|
|
|
parsed_files = [];
|
|
|
|
|
let base_path = null;
|
|
|
|
|
if (user_uid)
|
|
|
|
|
base_path = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
|
|
|
|
|
else
|
|
|
|
|
base_path = config_api.getConfigItem('ytdl_subscriptions_base_path');
|
|
|
|
|
|
|
|
|
|
let appended_base_path = path.join(base_path, (subscription.isPlaylist ? 'playlists' : 'channels'), subscription.name, '/');
|
|
|
|
|
let files;
|
|
|
|
|
try {
|
|
|
|
|
files = await utils.recFindByExt(appended_base_path, 'mp4');
|
|
|
|
|
} catch(e) {
|
|
|
|
|
files = null;
|
|
|
|
|
logger.info('Failed to get folder for subscription: ' + subscription.name + ' at path ' + appended_base_path);
|
|
|
|
|
res.sendStatus(500);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
for (let i = 0; i < files.length; i++) {
|
|
|
|
|
let file = files[i];
|
|
|
|
|
var file_path = file.substring(appended_base_path.length, file.length);
|
|
|
|
|
var stats = fs.statSync(file);
|
|
|
|
|
|
|
|
|
|
var id = file_path.substring(0, file_path.length-4);
|
|
|
|
|
var jsonobj = utils.getJSONMp4(id, appended_base_path);
|
|
|
|
|
if (!jsonobj) continue;
|
|
|
|
|
var title = jsonobj.title;
|
|
|
|
|
|
|
|
|
|
var thumbnail = jsonobj.thumbnail;
|
|
|
|
|
var duration = jsonobj.duration;
|
|
|
|
|
var url = jsonobj.webpage_url;
|
|
|
|
|
var uploader = jsonobj.uploader;
|
|
|
|
|
var upload_date = jsonobj.upload_date;
|
|
|
|
|
upload_date = `${upload_date.substring(0, 4)}-${upload_date.substring(4, 6)}-${upload_date.substring(6, 8)}`;
|
|
|
|
|
var size = stats.size;
|
|
|
|
|
|
|
|
|
|
var isaudio = false;
|
|
|
|
|
var file_obj = new utils.File(id, title, thumbnail, isaudio, duration, url, uploader, size, file, upload_date, jsonobj.description, jsonobj.view_count, jsonobj.height, jsonobj.abr);
|
|
|
|
|
parsed_files.push(file_obj);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// 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');
|
|
|
|
|
}
|
|
|
|
|
// 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({
|
|
|
|
@ -2153,7 +1602,7 @@ app.post('/api/downloadFileFromServer', optionalJwt, async (req, res) => {
|
|
|
|
|
// delete generated zip file
|
|
|
|
|
fs.unlinkSync(file_path_to_download);
|
|
|
|
|
} catch(e) {
|
|
|
|
|
logger.error("Failed to remove file", file);
|
|
|
|
|
logger.error(`Failed to remove file after sending to client: ${file_path_to_download}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
@ -2230,10 +1679,8 @@ 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 ext = type === 'audio' ? '.mp3' : '.mp4';
|
|
|
|
|
const mimetype = type === 'audio' ? 'audio/mp3' : 'video/mp4';
|
|
|
|
|
var head;
|
|
|
|
|
let optionalParams = url_api.parse(req.url,true).query;
|
|
|
|
|
let uid = decodeURIComponent(req.query.uid);
|
|
|
|
|
|
|
|
|
|
let file_path = null;
|
|
|
|
@ -2293,6 +1740,7 @@ app.get('/api/thumbnail/:path', optionalJwt, async (req, res) => {
|
|
|
|
|
// Downloads management
|
|
|
|
|
|
|
|
|
|
app.get('/api/downloads', async (req, res) => {
|
|
|
|
|
const downloads = await db_api.getRecords('download_queue');
|
|
|
|
|
res.send({downloads: downloads});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
@ -2308,33 +1756,8 @@ app.get('/api/thumbnail/:path', optionalJwt, async (req, res) => {
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post('/api/clearDownloads', async (req, res) => {
|
|
|
|
|
let success = false;
|
|
|
|
|
var delete_all = req.body.delete_all;
|
|
|
|
|
if (!req.body.session_id) req.body.session_id = 'undeclared';
|
|
|
|
|
var session_id = req.body.session_id;
|
|
|
|
|
var download_id = req.body.download_id;
|
|
|
|
|
if (delete_all) {
|
|
|
|
|
// delete all downloads
|
|
|
|
|
downloads = [];
|
|
|
|
|
success = true;
|
|
|
|
|
} else if (download_id) {
|
|
|
|
|
// delete just 1 download
|
|
|
|
|
const session_downloads = downloads.find(session => session['session_id'] === session_id);
|
|
|
|
|
if (session_downloads && session_downloads[download_id]) {
|
|
|
|
|
delete session_downloads[download_id];
|
|
|
|
|
success = true;
|
|
|
|
|
} else if (!session_downloads) {
|
|
|
|
|
logger.error(`Session ${session_id} has no downloads.`)
|
|
|
|
|
} else if (!session_downloads[download_id]) {
|
|
|
|
|
logger.error(`Download '${download_id}' for session '${session_id}' could not be found`);
|
|
|
|
|
}
|
|
|
|
|
} else if (session_id) {
|
|
|
|
|
// delete a session's downloads
|
|
|
|
|
downloads = downloads.filter(session => session['session_id'] !== session_id);
|
|
|
|
|
}
|
|
|
|
|
updateDownloads();
|
|
|
|
|
res.send({success: success, downloads: downloads});
|
|
|
|
|
app.post('/api/clearFinishedDownloads', async (req, res) => {
|
|
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// logs management
|
|
|
|
@ -2342,7 +1765,7 @@ app.get('/api/thumbnail/:path', optionalJwt, async (req, res) => {
|
|
|
|
|
app.post('/api/logs', async function(req, res) {
|
|
|
|
|
let logs = null;
|
|
|
|
|
let lines = req.body.lines;
|
|
|
|
|
logs_path = path.join('appdata', 'logs', 'combined.log')
|
|
|
|
|
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');
|
|
|
|
@ -2357,8 +1780,8 @@ app.post('/api/logs', async function(req, res) {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post('/api/clearAllLogs', async function(req, res) {
|
|
|
|
|
logs_path = path.join('appdata', 'logs', 'combined.log');
|
|
|
|
|
logs_err_path = path.join('appdata', 'logs', 'error.log');
|
|
|
|
|
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([
|
|
|
|
@ -2375,18 +1798,9 @@ app.post('/api/clearAllLogs', async function(req, res) {
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post('/api/getVideoInfos', async (req, res) => {
|
|
|
|
|
let fileNames = req.body.fileNames;
|
|
|
|
|
let urlMode = !!req.body.urlMode;
|
|
|
|
|
let type = req.body.type;
|
|
|
|
|
let result = null;
|
|
|
|
|
if (!urlMode) {
|
|
|
|
|
if (type === 'audio' || type === 'video') {
|
|
|
|
|
result = await getAudioOrVideoInfos(type, fileNames);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
result = await getUrlInfos(fileNames);
|
|
|
|
|
}
|
|
|
|
|
app.post('/api/getFileFormats', async (req, res) => {
|
|
|
|
|
let url = req.body.url;
|
|
|
|
|
let result = await getUrlInfos(url);
|
|
|
|
|
res.send({
|
|
|
|
|
result: result,
|
|
|
|
|
success: !!result
|
|
|
|
@ -2461,7 +1875,7 @@ app.post('/api/deleteUser', optionalJwt, async (req, res) => {
|
|
|
|
|
} else {
|
|
|
|
|
logger.error(`Could not find user with uid ${uid}`);
|
|
|
|
|
}
|
|
|
|
|
res.send({success: true});
|
|
|
|
|
res.send({success: success});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logger.error(err);
|
|
|
|
|
res.send({success: false});
|
|
|
|
|