const FileSync = require ( 'lowdb/adapters/FileSync' )
var fs = require ( 'fs-extra' ) ;
const { uuid } = require ( 'uuidv4' ) ;
var path = require ( 'path' ) ;
var youtubedl = require ( 'youtube-dl' ) ;
const config _api = require ( './config' ) ;
const twitch _api = require ( './twitch' ) ;
var utils = require ( './utils' ) ;
const debugMode = process . env . YTDL _MODE === 'debug' ;
var logger = null ;
var db = null ;
var users _db = null ;
let db _api = null ;
function setDB ( input _db _api ) { db _api = input _db _api }
function setLogger ( input _logger ) { logger = input _logger ; }
function initialize ( input _db _api , input _logger ) {
setDB ( input _db _api ) ;
setLogger ( input _logger ) ;
}
async function subscribe ( sub , user _uid = null ) {
const result _obj = {
success : false ,
error : ''
} ;
return new Promise ( async resolve => {
// sub should just have url and name. here we will get isPlaylist and path
sub . isPlaylist = sub . url . includes ( 'playlist' ) ;
sub . videos = [ ] ;
let url _exists = ! ! ( await db _api . getRecord ( 'subscriptions' , { url : sub . url , user _uid : user _uid } ) ) ;
if ( ! sub . name && url _exists ) {
logger . error ( ` Sub with the same URL " ${ sub . url } " already exists -- please provide a custom name for this new subscription. ` ) ;
result _obj . error = 'Subcription with URL ' + sub . url + ' already exists! Custom name is required.' ;
resolve ( result _obj ) ;
return ;
}
sub [ 'user_uid' ] = user _uid ? user _uid : undefined ;
await db _api . insertRecordIntoTable ( 'subscriptions' , sub ) ;
let success = await getSubscriptionInfo ( sub , user _uid ) ;
if ( success ) {
getVideosForSub ( sub , user _uid ) ;
} else {
logger . error ( 'Subscribe: Failed to get subscription info. Subscribe failed.' )
} ;
result _obj . success = success ;
result _obj . sub = sub ;
resolve ( result _obj ) ;
} ) ;
}
async function getSubscriptionInfo ( sub , user _uid = null ) {
let basePath = null ;
if ( user _uid )
basePath = path . join ( config _api . getConfigItem ( 'ytdl_users_base_path' ) , user _uid , 'subscriptions' ) ;
else
basePath = config _api . getConfigItem ( 'ytdl_subscriptions_base_path' ) ;
// get videos
let downloadConfig = [ '--dump-json' , '--playlist-end' , '1' ] ;
let useCookies = config _api . getConfigItem ( 'ytdl_use_cookies' ) ;
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.' ) ;
}
}
return new Promise ( async resolve => {
youtubedl . exec ( sub . url , downloadConfig , { maxBuffer : Infinity } , async ( err , output ) => {
if ( debugMode ) {
logger . info ( 'Subscribe: got info for subscription ' + sub . id ) ;
}
if ( err ) {
logger . error ( err . stderr ) ;
resolve ( false ) ;
} else if ( output ) {
if ( output . length === 0 || ( output . length === 1 && output [ 0 ] === '' ) ) {
logger . verbose ( 'Could not get info for ' + sub . id ) ;
resolve ( false ) ;
}
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 ;
}
if ( ! sub . name ) {
if ( sub . isPlaylist ) {
sub . name = output _json . playlist _title ? output _json . playlist _title : output _json . playlist ;
} else {
sub . name = output _json . uploader ;
}
// if it's now valid, update
if ( sub . name ) {
await db _api . updateRecord ( 'subscriptions' , { id : sub . id } , { name : sub . name } ) ;
}
}
const useArchive = config _api . getConfigItem ( 'ytdl_use_youtubedl_archive' ) ;
if ( useArchive && ! sub . archive ) {
// must create the archive
const archive _dir = path . join ( _ _dirname , basePath , 'archives' , sub . name ) ;
const archive _path = path . join ( archive _dir , 'archive.txt' ) ;
// creates archive directory and text file if it doesn't exist
fs . ensureDirSync ( archive _dir ) ;
fs . ensureFileSync ( archive _path ) ;
// updates subscription
sub . archive = archive _dir ;
await db _api . updateRecord ( 'subscriptions' , { id : sub . id } , { archive : archive _dir } ) ;
}
// TODO: get even more info
resolve ( true ) ;
}
resolve ( false ) ;
}
} ) ;
} ) ;
}
async function unsubscribe ( sub , deleteMode , user _uid = null ) {
let basePath = null ;
if ( user _uid )
basePath = path . join ( config _api . getConfigItem ( 'ytdl_users_base_path' ) , user _uid , 'subscriptions' ) ;
else
basePath = config _api . getConfigItem ( 'ytdl_subscriptions_base_path' ) ;
let result _obj = { success : false , error : '' } ;
let id = sub . id ;
const sub _files = await db _api . getRecords ( 'files' , { sub _id : id } ) ;
for ( let i = 0 ; i < sub _files . length ; i ++ ) {
const sub _file = sub _files [ i ] ;
if ( config _api . descriptors [ sub _file [ 'uid' ] ] ) {
try {
for ( let i = 0 ; i < config _api . descriptors [ sub _file [ 'uid' ] ] . length ; i ++ ) {
config _api . descriptors [ sub _file [ 'uid' ] ] [ i ] . destroy ( ) ;
}
} catch ( e ) {
}
}
}
await db _api . removeRecord ( 'subscriptions' , { id : id } ) ;
await db _api . removeAllRecords ( 'files' , { sub _id : id } ) ;
// failed subs have no name, on unsubscribe they shouldn't error
if ( ! sub . name ) {
return ;
}
const appendedBasePath = getAppendedBasePath ( sub , basePath ) ;
if ( deleteMode && ( await fs . pathExists ( appendedBasePath ) ) ) {
if ( sub . archive && ( await fs . pathExists ( sub . archive ) ) ) {
const archive _file _path = path . join ( sub . archive , 'archive.txt' ) ;
// deletes archive if it exists
if ( await fs . pathExists ( archive _file _path ) ) {
await fs . unlink ( archive _file _path ) ;
}
await fs . rmdir ( sub . archive ) ;
}
await fs . remove ( appendedBasePath ) ;
}
}
async function deleteSubscriptionFile ( sub , file , deleteForever , file _uid = null , user _uid = null ) {
// TODO: combine this with deletefile
let basePath = null ;
basePath = user _uid ? path . join ( config _api . getConfigItem ( 'ytdl_users_base_path' ) , user _uid , 'subscriptions' )
: config _api . getConfigItem ( 'ytdl_subscriptions_base_path' ) ;
const useArchive = config _api . getConfigItem ( 'ytdl_use_youtubedl_archive' ) ;
const appendedBasePath = getAppendedBasePath ( sub , basePath ) ;
const name = file ;
let retrievedID = null ;
await db _api . removeRecord ( 'files' , { uid : file _uid } ) ;
let filePath = appendedBasePath ;
const ext = ( sub . type && sub . type === 'audio' ) ? '.mp3' : '.mp4'
var jsonPath = path . join ( _ _dirname , filePath , name + '.info.json' ) ;
var videoFilePath = path . join ( _ _dirname , filePath , name + ext ) ;
var imageFilePath = path . join ( _ _dirname , filePath , name + '.jpg' ) ;
var altImageFilePath = path . join ( _ _dirname , filePath , name + '.webp' ) ;
const [ jsonExists , videoFileExists , imageFileExists , altImageFileExists ] = await Promise . all ( [
fs . pathExists ( jsonPath ) ,
fs . pathExists ( videoFilePath ) ,
fs . pathExists ( imageFilePath ) ,
fs . pathExists ( altImageFilePath ) ,
] ) ;
if ( jsonExists ) {
retrievedID = JSON . parse ( await fs . readFile ( jsonPath , 'utf8' ) ) [ 'id' ] ;
await fs . unlink ( jsonPath ) ;
}
if ( imageFileExists ) {
await fs . unlink ( imageFilePath ) ;
}
if ( altImageFileExists ) {
await fs . unlink ( altImageFilePath ) ;
}
if ( videoFileExists ) {
await fs . unlink ( videoFilePath ) ;
if ( ( await fs . pathExists ( jsonPath ) ) || ( await fs . pathExists ( videoFilePath ) ) ) {
return false ;
} else {
// check if the user wants the video to be redownloaded (deleteForever === false)
if ( ! deleteForever && useArchive && sub . archive && retrievedID ) {
const archive _path = path . join ( sub . archive , 'archive.txt' )
// if archive exists, remove line with video ID
if ( await fs . pathExists ( archive _path ) ) {
utils . removeIDFromArchive ( archive _path , retrievedID ) ;
}
}
return true ;
}
} else {
// TODO: tell user that the file didn't exist
return true ;
}
}
async function getVideosForSub ( sub , user _uid = null ) {
const latest _sub _obj = await getSubscription ( sub . id ) ;
if ( ! latest _sub _obj || latest _sub _obj [ 'downloading' ] ) {
return false ;
}
updateSubscriptionProperty ( sub , { downloading : true } , user _uid ) ;
// get basePath
let basePath = null ;
if ( user _uid )
basePath = path . join ( config _api . getConfigItem ( 'ytdl_users_base_path' ) , user _uid , 'subscriptions' ) ;
else
basePath = config _api . getConfigItem ( 'ytdl_subscriptions_base_path' ) ;
let appendedBasePath = getAppendedBasePath ( sub , basePath ) ;
fs . ensureDirSync ( appendedBasePath ) ;
let multiUserMode = null ;
if ( user _uid ) {
multiUserMode = {
user : user _uid ,
file _path : appendedBasePath
}
}
const downloadConfig = await generateArgsForSubscription ( sub , user _uid ) ;
// get videos
logger . verbose ( ` Subscription: getting videos for subscription ${ sub . name } with args: ${ downloadConfig . join ( ',' ) } ` ) ;
return new Promise ( async resolve => {
const preimported _file _paths = [ ] ;
const PREIMPORT _INTERVAL = 5000 ;
const preregister _check = setInterval ( async ( ) => {
if ( sub . streamingOnly ) return ;
await db _api . preimportUnregisteredSubscriptionFile ( sub , appendedBasePath ) ;
} , PREIMPORT _INTERVAL ) ;
youtubedl . exec ( sub . url , downloadConfig , { maxBuffer : Infinity } , async function ( err , output ) {
// cleanup
updateSubscriptionProperty ( sub , { downloading : false } , user _uid ) ;
clearInterval ( preregister _check ) ;
logger . verbose ( 'Subscription: finished check for ' + sub . name ) ;
if ( err && ! output ) {
logger . error ( err . stderr ? err . stderr : err . message ) ;
if ( err . stderr . includes ( 'This video is unavailable' ) ) {
logger . info ( 'An error was encountered with at least one video, backup method will be used.' )
try {
const outputs = err . stdout . split ( /\r\n|\r|\n/ ) ;
for ( let i = 0 ; i < outputs . length ; i ++ ) {
const output = JSON . parse ( outputs [ i ] ) ;
await handleOutputJSON ( sub , output , i === 0 , multiUserMode )
if ( err . stderr . includes ( output [ 'id' ] ) && archive _path ) {
// we found a video that errored! add it to the archive to prevent future errors
if ( sub . archive ) {
archive _dir = sub . archive ;
archive _path = path . join ( archive _dir , 'archive.txt' )
fs . appendFileSync ( archive _path , output [ 'id' ] ) ;
}
}
}
} catch ( e ) {
logger . error ( 'Backup method failed. See error below:' ) ;
logger . error ( e ) ;
}
}
resolve ( false ) ;
} else if ( output ) {
if ( output . length === 0 || ( output . length === 1 && output [ 0 ] === '' ) ) {
logger . verbose ( 'No additional videos to download for ' + sub . name ) ;
resolve ( true ) ;
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 ;
}
const reset _videos = i === 0 ;
await handleOutputJSON ( sub , output _json , multiUserMode , preimported _file _paths , reset _videos ) ;
}
if ( config _api . getConfigItem ( 'ytdl_subscriptions_redownload_fresh_uploads' ) ) {
await setFreshUploads ( sub , user _uid ) ;
checkVideosForFreshUploads ( sub , user _uid ) ;
}
resolve ( true ) ;
}
} ) ;
} , err => {
logger . error ( err ) ;
updateSubscriptionProperty ( sub , { downloading : false } , user _uid ) ;
clearInterval ( preregister _check ) ;
} ) ;
}
async function generateArgsForSubscription ( sub , user _uid , redownload = false , desired _path = null ) {
// get basePath
let basePath = null ;
if ( user _uid )
basePath = path . join ( config _api . getConfigItem ( 'ytdl_users_base_path' ) , user _uid , 'subscriptions' ) ;
else
basePath = config _api . getConfigItem ( 'ytdl_subscriptions_base_path' ) ;
const useArchive = config _api . getConfigItem ( 'ytdl_use_youtubedl_archive' ) ;
let appendedBasePath = getAppendedBasePath ( sub , basePath ) ;
const file _output = config _api . getConfigItem ( 'ytdl_default_file_output' ) ? config _api . getConfigItem ( 'ytdl_default_file_output' ) : '%(title)s' ;
let fullOutput = ` ${ appendedBasePath } / ${ file _output } .%(ext)s ` ;
if ( desired _path ) {
fullOutput = ` ${ desired _path } .%(ext)s ` ;
} else if ( sub . custom _output ) {
fullOutput = ` ${ appendedBasePath } / ${ sub . custom _output } .%(ext)s ` ;
}
let downloadConfig = [ '-o' , fullOutput , ! redownload ? '-ciw' : '-ci' , '--write-info-json' , '--print-json' ] ;
let qualityPath = null ;
if ( sub . type && sub . type === 'audio' ) {
qualityPath = [ '-f' , 'bestaudio' ]
qualityPath . push ( '-x' ) ;
qualityPath . push ( '--audio-format' , 'mp3' ) ;
} else {
if ( ! sub . maxQuality || sub . maxQuality === 'best' ) qualityPath = [ '-f' , 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4' ] ;
else qualityPath = [ '-f' , ` bestvideo[height<= ${ sub . maxQuality } ]+bestaudio/best[height<= ${ sub . maxQuality } ] ` , '--merge-output-format' , 'mp4' ] ;
}
downloadConfig . push ( ... qualityPath )
if ( sub . custom _args ) {
customArgsArray = sub . custom _args . split ( ',,' ) ;
if ( customArgsArray . indexOf ( '-f' ) !== - 1 ) {
// if custom args has a custom quality, replce the original quality with that of custom args
const original _output _index = downloadConfig . indexOf ( '-f' ) ;
downloadConfig . splice ( original _output _index , 2 ) ;
}
downloadConfig . push ( ... customArgsArray ) ;
}
let archive _dir = null ;
let archive _path = null ;
if ( useArchive && ! redownload ) {
if ( sub . archive ) {
archive _dir = sub . archive ;
archive _path = path . join ( archive _dir , 'archive.txt' )
}
downloadConfig . push ( '--download-archive' , archive _path ) ;
}
// if streaming only mode, just get the list of videos
if ( sub . streamingOnly ) {
downloadConfig = [ '-f' , 'best' , '--dump-json' ] ;
}
if ( sub . timerange && ! redownload ) {
downloadConfig . push ( '--dateafter' , sub . timerange ) ;
}
let useCookies = config _api . getConfigItem ( 'ytdl_use_cookies' ) ;
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 ( config _api . getConfigItem ( 'ytdl_include_thumbnail' ) ) {
downloadConfig . push ( '--write-thumbnail' ) ;
}
const download _delay = config _api . getConfigItem ( 'ytdl_subscriptions_download_delay' ) ;
if ( download _delay && downloadConfig . indexOf ( '--sleep-interval' ) === - 1 ) {
if ( ! ( + download _delay ) ) {
logger . warn ( ` Invalid download delay of ${ download _delay } , please remember to use non-zero numbers. ` ) ;
} else {
downloadConfig . push ( '--sleep-interval' , + download _delay ) ;
}
}
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' ) ;
}
return downloadConfig ;
}
async function handleOutputJSON ( sub , output _json , multiUserMode = null , reset _videos = false ) {
// TODO: remove streaming only mode
if ( false && sub . streamingOnly ) {
if ( reset _videos ) {
sub _db . assign ( { videos : [ ] } ) . write ( ) ;
}
// remove unnecessary info
output _json . formats = null ;
// add to db
sub _db . get ( 'videos' ) . push ( output _json ) . write ( ) ;
} else {
const file _url = output _json [ 'webpage_url' ]
const file _exists = await db _api . getRecord ( 'files' , { url : file _url , sub _id : sub . id } ) ;
if ( file _exists ) {
// file already exists in DB, return early to avoid reseting the download date
return ;
}
await db _api . registerFileDB2 ( output _json [ '_filename' ] , sub . type , sub . user _uid , null , sub . id ) ;
const url = output _json [ 'webpage_url' ] ;
if ( sub . 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' ) ) {
const file _name = path . basename ( output _json [ '_filename' ] ) ;
const id = file _name . substring ( 0 , file _name . length - 4 ) ;
let vodId = url . split ( 'twitch.tv/videos/' ) [ 1 ] ;
vodId = vodId . split ( '?' ) [ 0 ] ;
twitch _api . downloadTwitchChatByVODID ( vodId , id , sub . type , multiUserMode . user , sub ) ;
}
}
}
async function getSubscriptions ( user _uid = null ) {
return await db _api . getRecords ( 'subscriptions' , { user _uid : user _uid } ) ;
}
async function getAllSubscriptions ( ) {
const all _subs = await db _api . getRecords ( 'subscriptions' ) ;
const multiUserMode = config _api . getConfigItem ( 'ytdl_multi_user_mode' ) ;
return all _subs . filter ( sub => ! ! ( sub . user _uid ) === ! ! multiUserMode ) ;
}
async function getSubscription ( subID ) {
return await db _api . getRecord ( 'subscriptions' , { id : subID } ) ;
}
async function getSubscriptionByName ( subName , user _uid = null ) {
return await db _api . getRecord ( 'subscriptions' , { name : subName , user _uid : user _uid } ) ;
}
async function updateSubscription ( sub , user _uid = null ) {
await db _api . updateRecord ( 'subscriptions' , { id : sub . id } , sub ) ;
return true ;
}
async function updateSubscriptionPropertyMultiple ( subs , assignment _obj ) {
subs . forEach ( async sub => {
await updateSubscriptionProperty ( sub , assignment _obj , sub . user _uid ) ;
} ) ;
}
async function updateSubscriptionProperty ( sub , assignment _obj , user _uid = null ) {
// TODO: combine with updateSubscription
await db _api . updateRecord ( 'subscriptions' , { id : sub . id } , assignment _obj ) ;
return true ;
}
async function setFreshUploads ( sub , user _uid ) {
const current _date = new Date ( ) . toISOString ( ) . split ( 'T' ) [ 0 ] . replace ( /-/g , '' ) ;
sub . videos . forEach ( async video => {
if ( current _date === video [ 'upload_date' ] . replace ( /-/g , '' ) ) {
// set upload as fresh
const video _uid = video [ 'uid' ] ;
await db _api . setVideoProperty ( video _uid , { 'fresh_upload' : true } , user _uid , sub [ 'id' ] ) ;
}
} ) ;
}
async function checkVideosForFreshUploads ( sub , user _uid ) {
const current _date = new Date ( ) . toISOString ( ) . split ( 'T' ) [ 0 ] . replace ( /-/g , '' ) ;
sub . videos . forEach ( async video => {
if ( video [ 'fresh_upload' ] && current _date > video [ 'upload_date' ] . replace ( /-/g , '' ) ) {
await checkVideoIfBetterExists ( video , sub , user _uid )
}
} ) ;
}
async function checkVideoIfBetterExists ( file _obj , sub , user _uid ) {
const new _path = file _obj [ 'path' ] . substring ( 0 , file _obj [ 'path' ] . length - 4 ) ;
const downloadConfig = await generateArgsForSubscription ( sub , user _uid , true , new _path ) ;
logger . verbose ( ` Checking if a better version of the fresh upload ${ file _obj [ 'id' ] } exists. ` ) ;
// simulate a download to verify that a better version exists
youtubedl . getInfo ( file _obj [ 'url' ] , downloadConfig , async ( err , output ) => {
if ( err ) {
// video is not available anymore for whatever reason
} else if ( output ) {
const metric _to _compare = sub . type === 'audio' ? 'abr' : 'height' ;
if ( output [ metric _to _compare ] > file _obj [ metric _to _compare ] ) {
// download new video as the simulated one is better
youtubedl . exec ( file _obj [ 'url' ] , downloadConfig , { maxBuffer : Infinity } , async ( err , output ) => {
if ( err ) {
logger . verbose ( ` Failed to download better version of video ${ file _obj [ 'id' ] } ` ) ;
} else if ( output ) {
logger . verbose ( ` Successfully upgraded video ${ file _obj [ 'id' ] } 's ${ metric _to _compare } from ${ file _obj [ metric _to _compare ] } to ${ output [ metric _to _compare ] } ` ) ;
await db _api . setVideoProperty ( file _obj [ 'uid' ] , { [ metric _to _compare ] : output [ metric _to _compare ] } , user _uid , sub [ 'id' ] ) ;
}
} ) ;
}
}
} ) ;
await db _api . setVideoProperty ( file _obj [ 'uid' ] , { 'fresh_upload' : false } , user _uid , sub [ 'id' ] ) ;
}
// helper functions
function getAppendedBasePath ( sub , base _path ) {
return path . join ( base _path , ( sub . isPlaylist ? 'playlists/' : 'channels/' ) , sub . name ) ;
}
module . exports = {
getSubscription : getSubscription ,
getSubscriptionByName : getSubscriptionByName ,
getSubscriptions : getSubscriptions ,
getAllSubscriptions : getAllSubscriptions ,
updateSubscription : updateSubscription ,
subscribe : subscribe ,
unsubscribe : unsubscribe ,
deleteSubscriptionFile : deleteSubscriptionFile ,
getVideosForSub : getVideosForSub ,
setLogger : setLogger ,
initialize : initialize ,
updateSubscriptionPropertyMultiple : updateSubscriptionPropertyMultiple
}