const fs = require ( 'fs-extra' ) ;
const { uuid } = require ( 'uuidv4' ) ;
const path = require ( 'path' ) ;
const mergeFiles = require ( 'merge-files' ) ;
const NodeID3 = require ( 'node-id3' )
const glob = require ( 'glob' )
const Mutex = require ( 'async-mutex' ) . Mutex ;
const youtubedl = require ( 'youtube-dl' ) ;
const logger = require ( './logger' ) ;
const config _api = require ( './config' ) ;
const twitch _api = require ( './twitch' ) ;
const categories _api = require ( './categories' ) ;
const utils = require ( './utils' ) ;
let db _api = null ;
const mutex = new Mutex ( ) ;
let should _check _downloads = true ;
const archivePath = path . join ( _ _dirname , 'appdata' , 'archives' ) ;
function setDB ( input _db _api ) { db _api = input _db _api }
exports . initialize = ( input _db _api ) => {
setDB ( input _db _api ) ;
categories _api . initialize ( db _api ) ;
setupDownloads ( ) ;
}
exports . createDownload = async ( url , type , options ) => {
return await mutex . runExclusive ( async ( ) => {
const download = {
url : url ,
type : type ,
title : '' ,
options : options ,
uid : uuid ( ) ,
step _index : 0 ,
paused : false ,
finished _step : true ,
error : null ,
percent _complete : null ,
finished : false ,
timestamp _start : Date . now ( )
} ;
await db _api . insertRecordIntoTable ( 'download_queue' , download ) ;
should _check _downloads = true ;
return download ;
} ) ;
}
exports . pauseDownload = async ( download _uid ) => {
const download = await db _api . getRecord ( 'download_queue' , { uid : download _uid } ) ;
if ( download [ 'paused' ] ) {
logger . warn ( ` Download ${ download _uid } is already paused! ` ) ;
return false ;
} else if ( download [ 'finished' ] ) {
logger . info ( ` Download ${ download _uid } could not be paused before completing. ` ) ;
return false ;
}
return await db _api . updateRecord ( 'download_queue' , { uid : download _uid } , { paused : true } ) ;
}
exports . resumeDownload = async ( download _uid ) => {
return await mutex . runExclusive ( async ( ) => {
const download = await db _api . getRecord ( 'download_queue' , { uid : download _uid } ) ;
if ( ! download [ 'paused' ] ) {
logger . warn ( ` Download ${ download _uid } is not paused! ` ) ;
return false ;
}
const success = db _api . updateRecord ( 'download_queue' , { uid : download _uid } , { paused : false } ) ;
should _check _downloads = true ;
return success ;
} )
}
exports . restartDownload = async ( download _uid ) => {
const download = await db _api . getRecord ( 'download_queue' , { uid : download _uid } ) ;
await exports . clearDownload ( download _uid ) ;
const success = ! ! ( await exports . createDownload ( download [ 'url' ] , download [ 'type' ] , download [ 'options' ] ) ) ;
should _check _downloads = true ;
return success ;
}
exports . cancelDownload = async ( download _uid ) => {
const download = await db _api . getRecord ( 'download_queue' , { uid : download _uid } ) ;
if ( download [ 'cancelled' ] ) {
logger . warn ( ` Download ${ download _uid } is already cancelled! ` ) ;
return false ;
} else if ( download [ 'finished' ] ) {
logger . info ( ` Download ${ download _uid } could not be cancelled before completing. ` ) ;
return false ;
}
return await db _api . updateRecord ( 'download_queue' , { uid : download _uid } , { cancelled : true } ) ;
}
exports . clearDownload = async ( download _uid ) => {
return await db _api . removeRecord ( 'download_queue' , { uid : download _uid } ) ;
}
// questions
// how do we want to manage queued downloads that errored in any step? do we set the index back and finished_step to true or let the manager do it?
async function setupDownloads ( ) {
await fixDownloadState ( ) ;
setInterval ( checkDownloads , 1000 ) ;
}
async function fixDownloadState ( ) {
const downloads = await db _api . getRecords ( 'download_queue' ) ;
downloads . sort ( ( download1 , download2 ) => download1 . timestamp _start - download2 . timestamp _start ) ;
const running _downloads = downloads . filter ( download => ! download [ 'finished_step' ] ) ;
for ( let i = 0 ; i < running _downloads . length ; i ++ ) {
const running _download = running _downloads [ i ] ;
const update _obj = { finished _step : true , paused : true } ;
if ( running _download [ 'step_index' ] > 0 ) {
update _obj [ 'step_index' ] = running _download [ 'step_index' ] - 1 ;
}
await db _api . updateRecord ( 'download_queue' , { uid : running _download [ 'uid' ] } , update _obj ) ;
}
}
async function checkDownloads ( ) {
if ( ! should _check _downloads ) return ;
const downloads = await db _api . getRecords ( 'download_queue' ) ;
downloads . sort ( ( download1 , download2 ) => download1 . timestamp _start - download2 . timestamp _start ) ;
await mutex . runExclusive ( async ( ) => {
// avoid checking downloads unnecessarily, but double check that should_check_downloads is still true
const running _downloads = downloads . filter ( download => ! download [ 'paused' ] && ! download [ 'finished' ] ) ;
if ( running _downloads . length === 0 ) {
should _check _downloads = false ;
logger . verbose ( 'Disabling checking downloads as none are available.' ) ;
}
return ;
} ) ;
const waiting _downloads = downloads . filter ( download => ! download [ 'paused' ] && download [ 'finished_step' ] ) ;
for ( let i = 0 ; i < waiting _downloads . length ; i ++ ) {
const running _download = waiting _downloads [ i ] ;
if ( i === 5 /*config_api.getConfigItem('ytdl_max_concurrent_downloads')*/ ) break ;
if ( running _download [ 'finished_step' ] && ! running _download [ 'finished' ] ) {
// move to next step
if ( running _download [ 'step_index' ] === 0 ) {
collectInfo ( running _download [ 'uid' ] ) ;
} else if ( running _download [ 'step_index' ] === 1 ) {
downloadQueuedFile ( running _download [ 'uid' ] ) ;
}
}
}
}
async function collectInfo ( download _uid ) {
const download = await db _api . getRecord ( 'download_queue' , { uid : download _uid } ) ;
if ( download [ 'paused' ] ) {
return ;
}
logger . verbose ( ` Collecting info for download ${ download _uid } ` ) ;
await db _api . updateRecord ( 'download_queue' , { uid : download _uid } , { step _index : 1 , finished _step : false } ) ;
const url = download [ 'url' ] ;
const type = download [ 'type' ] ;
const options = download [ 'options' ] ;
if ( options . user && ! options . customFileFolderPath ) {
let usersFileFolder = config _api . getConfigItem ( 'ytdl_users_base_path' ) ;
const user _path = path . join ( usersFileFolder , options . user , type ) ;
options . customFileFolderPath = user _path + path . sep ;
}
let args = await generateArgs ( url , type , options ) ;
// get video info prior to download
let info = await getVideoInfoByURL ( url , args , download _uid ) ;
if ( ! info ) {
// info failed, record error and pause download
const error = 'Failed to get info, see server logs for specific error.' ;
await db _api . updateRecord ( 'download_queue' , { uid : download _uid } , { error : error , paused : true } ) ;
return ;
}
let category = null ;
// check if it fits into a category. If so, then get info again using new args
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 ;
args = await generateArgs ( url , type , options ) ;
info = await getVideoInfoByURL ( url , args , download _uid ) ;
}
// setup info required to calculate download progress
const expected _file _size = utils . getExpectedFileSize ( info ) ;
const files _to _check _for _progress = [ ] ;
// store info in download for future use
if ( Array . isArray ( info ) ) {
for ( let info _obj of info ) files _to _check _for _progress . push ( utils . removeFileExtension ( info _obj [ '_filename' ] ) ) ;
} else {
files _to _check _for _progress . push ( utils . removeFileExtension ( info [ '_filename' ] ) ) ;
}
const playlist _title = Array . isArray ( info ) ? info [ 0 ] [ 'playlist_title' ] || info [ 0 ] [ 'playlist' ] : null ;
await db _api . updateRecord ( 'download_queue' , { uid : download _uid } , { args : args ,
finished _step : true ,
options : options ,
files _to _check _for _progress : files _to _check _for _progress ,
expected _file _size : expected _file _size ,
title : playlist _title ? playlist _title : info [ 'title' ]
} ) ;
}
async function downloadQueuedFile ( download _uid ) {
const download = await db _api . getRecord ( 'download_queue' , { uid : download _uid } ) ;
if ( download [ 'paused' ] ) {
return ;
}
logger . verbose ( ` Downloading ${ download _uid } ` ) ;
return new Promise ( async resolve => {
const audioFolderPath = config _api . getConfigItem ( 'ytdl_audio_folder_path' ) ;
const videoFolderPath = config _api . getConfigItem ( 'ytdl_video_folder_path' ) ;
await db _api . updateRecord ( 'download_queue' , { uid : download _uid } , { step _index : 2 , finished _step : false } ) ;
const url = download [ 'url' ] ;
const type = download [ 'type' ] ;
const options = download [ 'options' ] ;
const args = download [ 'args' ] ;
const category = download [ 'category' ] ;
let fileFolderPath = type === 'audio' ? audioFolderPath : videoFolderPath ; // TODO: fix
if ( options . customFileFolderPath ) {
fileFolderPath = options . customFileFolderPath ;
}
fs . ensureDirSync ( fileFolderPath ) ;
const start _time = Date . now ( ) ;
const download _checker = setInterval ( ( ) => checkDownloadPercent ( download [ 'uid' ] ) , 1000 ) ;
// download file
youtubedl . exec ( url , args , { maxBuffer : Infinity } , async function ( err , output ) {
const file _objs = [ ] ;
let end _time = Date . now ( ) ;
let difference = ( end _time - start _time ) / 1000 ;
logger . debug ( ` ${ type === 'audio' ? 'Audio' : 'Video' } download delay: ${ difference } seconds. ` ) ;
clearInterval ( download _checker ) ;
if ( err ) {
logger . error ( err . stderr ) ;
resolve ( false ) ;
return ;
} else if ( output ) {
if ( output . length === 0 || output [ 0 ] . length === 0 ) {
// ERROR!
logger . warn ( ` No output received for video download, check if it exists in your archive. ` )
resolve ( false ) ;
return ;
}
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' ] ) ;
const ext = type === 'audio' ? '.mp3' : '.mp4' ;
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 _api . getConfigItem ( 'ytdl_use_twitch_api' ) && config _api . 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 ) ;
file _objs . push ( file _obj ) ;
}
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 ) ;
}
let container = null ;
if ( file _objs . length > 1 ) {
// create playlist
const playlist _name = file _objs . map ( file _obj => file _obj . title ) . join ( ', ' ) ;
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.' ) ;
}
const file _uids = file _objs . map ( file _obj => file _obj . uid ) ;
await db _api . updateRecord ( 'download_queue' , { uid : download _uid } , { finished _step : true , finished : true , step _index : 3 , percent _complete : 100 , file _uids : file _uids , container : container } ) ;
resolve ( ) ;
}
} ) ;
} ) ;
}
// helper functions
async function generateArgs ( url , type , options ) {
const audioFolderPath = config _api . getConfigItem ( 'ytdl_audio_folder_path' ) ;
const videoFolderPath = config _api . getConfigItem ( 'ytdl_video_folder_path' ) ;
const videopath = config _api . getConfigItem ( 'ytdl_default_file_output' ) ? config _api . getConfigItem ( 'ytdl_default_file_output' ) : '%(title)s' ;
const globalArgs = config _api . getConfigItem ( 'ytdl_custom_args' ) ;
const useCookies = config _api . getConfigItem ( 'ytdl_use_cookies' ) ;
const is _audio = type === 'audio' ;
let fileFolderPath = is _audio ? audioFolderPath : videoFolderPath ;
if ( options . customFileFolderPath ) fileFolderPath = options . customFileFolderPath ;
const customArgs = options . customArgs ;
let customOutput = options . customOutput ;
const customQualityConfiguration = options . customQualityConfiguration ;
// video-specific args
const selectedHeight = options . selectedHeight ;
// audio-specific args
const maxBitrate = options . maxBitrate ;
const youtubeUsername = options . youtubeUsername ;
const 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 ( 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 ) 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.' ) ;
}
}
const useDefaultDownloadingAgent = config _api . getConfigItem ( 'ytdl_use_default_downloading_agent' ) ;
const customDownloadingAgent = config _api . getConfigItem ( 'ytdl_custom_downloading_agent' ) ;
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 ) ;
await fs . ensureFile ( archive _path ) ;
let blacklist _path = options . user ? path . join ( fileFolderPath , 'archives' , ` blacklist_ ${ type } .txt ` ) : path . join ( archivePath , ` blacklist_ ${ type } .txt ` ) ;
await fs . ensureFile ( blacklist _path ) ;
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 ] ;
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 _uid = 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 ) ;
}
new _args . push ( '--dump-json' ) ;
youtubedl . exec ( url , new _args , { maxBuffer : Infinity } , async ( err , output ) => {
if ( output ) {
let outputs = [ ] ;
try {
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 ;
}
outputs . push ( output _json ) ;
}
resolve ( outputs . length === 1 ? outputs [ 0 ] : outputs ) ;
} catch ( e ) {
logger . error ( ` Error while retrieving info on video with URL ${ url } with the following message: output JSON could not be parsed. Output JSON: ${ output } ` ) ;
if ( download _uid ) {
const error = 'Failed to get info, see server logs for specific error.' ;
await db _api . updateRecord ( 'download_queue' , { uid : download _uid } , { error : error , paused : true } ) ;
}
resolve ( null ) ;
}
} 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 _uid ) {
const error = 'Failed to get info, see server logs for specific error.' ;
await db _api . updateRecord ( 'download_queue' , { uid : download _uid } , { error : error , paused : true } ) ;
}
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 ) ) ;
}
async function checkDownloadPercent ( download _uid ) {
/ *
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 download = await db _api . getRecord ( 'download_queue' , { uid : download _uid } ) ;
const files _to _check _for _progress = download [ 'files_to_check_for_progress' ] ;
const resulting _file _size = download [ 'expected_file_size' ] ;
if ( ! resulting _file _size ) return ;
let sum _size = 0 ;
glob ( ` { ${ files _to _check _for _progress . join ( ',' ) } , }* ` , async ( err , files ) => {
files . forEach ( async file => {
try {
const file _stats = fs . statSync ( file ) ;
if ( file _stats && file _stats . size ) {
sum _size += file _stats . size ;
}
} catch ( e ) {
}
} ) ;
const percent _complete = ( sum _size / resulting _file _size * 100 ) . toFixed ( 2 ) ;
await db _api . updateRecord ( 'download_queue' , { uid : download _uid } , { percent _complete : percent _complete } ) ;
} ) ;
}