diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c6d8b91 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +# editorconfig.org +root = true + +[*.js] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true \ No newline at end of file diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..632057f --- /dev/null +++ b/.eslintrc @@ -0,0 +1,10 @@ +{ + "env": { + "browser": true + }, + "globals": { + }, + "rules": { + "quotes": [2, "single"] + } +} diff --git a/.jscsrc b/.jscsrc new file mode 100644 index 0000000..cbb41b1 --- /dev/null +++ b/.jscsrc @@ -0,0 +1,4 @@ +{ + "preset": "google", + "maximumLineLength": null +} diff --git a/README.md b/README.md index 7dfa9b1..29ad77b 100755 --- a/README.md +++ b/README.md @@ -3,18 +3,15 @@ capturebate-node capturebate-node lets you follow and archive your favorite models' shows on chaturbate.com -Requirements -========== -(Debian 7, minimum) +### Requirements -[RTMPDump(ksv)](https://github.com/BurntSushi/rtmpdump-ksv) used to capture the streams. +[RTMPDump(ksv)](https://github.com/sstativa/rtmpdump-ksv) used to capture the streams. [Node.js](https://nodejs.org/download/) used to run capturebate-node, hence the name. [ffmpeg](https://www.ffmpeg.org/download.html) compiled with support for `libmp3lame` & `libspeex` audio for converting the output files. -Setup -=========== +### Setup Install requirements, run `npm install` in the same folder as main.js is. @@ -24,21 +21,17 @@ Be mindful when capturing many streams at once to have plenty of space on disk a Before you can start capturing streams you first need to [follow](https://i.imgur.com/o9QyAVC.png) the models you want on the site, once you've done this you're ready to start capturebate-node by running `node main.js` -Running & Output -=========== +### Running & Output -To start capturing streams you need to run `node main.js` I reccomend you do this in [screen](https://www.gnu.org/software/screen/) as that'll keep running if you lose connection to the machine or otherwise close your shell. +To start capturing streams you need to run `node main.js` I recommend you do this in [screen](https://www.gnu.org/software/screen/) as that'll keep running if you lose connection to the machine or otherwise close your shell. Standard output should look something this when recording streams: [2015-05-16T00:19:02] capturebate-node started [2015-05-16T00:19:08] eeeveee is now online, starting rtmpdump process -Encoding -=========== - -Once you've captured some streams you're going to need to convert the audio to have them play nice in most media players. This is where ffmpeg comes in, there is no need to convert the video so this doesn't take too long. To convert individual files do `ffmpeg -i input.flv -vcodec copy -acodec libmp3lame output.mp4` this will convert the speex audio to mp3 and change the container to mp4 (stream is h264) +### Converting video files -If you want to batch convert your captured streams run `find ./ -name '*.flv' -execdir mkdir converted_bates \;; for file in *.flv; do ffmpeg -i "$file" -vcodec copy -acodec libmp3lame "converted_bates/${file%.flv}.mp4"; done` from the directory you capture to. +There is a simple script to convert `.flv` files. Just edit `convert.yml` file and set proper values for `srcDirectory` (should be the same with `completeDirectory` of `config.yml`) and `dstDirectory`, and run `node convert.js` in separate console window. -If you don't want to do any conversion you can install the [speex audio codec](http://speex.org/downloads/) which is a huge pain in the ass to get working correctly under linux/VLC. +> Note for Windows users: You should copy `ffmpeg.exe` file into the same directory as `main.js` is. diff --git a/config.yml b/config.yml index 3a5ae38..43c26dd 100755 --- a/config.yml +++ b/config.yml @@ -1,5 +1,10 @@ username: testingallthethings # Your Chaturbate username password: 2YY84bKlOC79KNQ # Your Chaturbate password, I promise not to do anything bad with it -captureDirectory: captures/ # Where you want the captures to be saved to -modelScanInterval: 10 # In seconds, how often capturebate-node checks for newly-online models +captureDirectory: captures # Where you want the captures to be saved to +completeDirectory: complete # Where you want the "finalized" captures to be moved to as soon as model stops streaming +modelScanInterval: 30 # In seconds, how often capturebate-node checks for newly-online models debug: false +rtmpDebug: false +minFileSizeMb: 10 +dateFormat: YYYYMMDD-HHmmss +createModelDirectory: false diff --git a/convert.js b/convert.js new file mode 100644 index 0000000..35f541a --- /dev/null +++ b/convert.js @@ -0,0 +1,208 @@ +'use strict'; + +var Promise = require('bluebird'); +var fs = Promise.promisifyAll(require('fs')); +var yaml = require('js-yaml'); +var colors = require('colors'); +var childProcess = require('child_process'); +var mkdirp = require('mkdirp'); +var mkdirpAsync = Promise.promisify(mkdirp); +var path = require('path'); +var moment = require('moment'); +var Queue = require('promise-queue'); +var filewalker = require('filewalker'); +var JSONStream = require('JSONStream'); + +var config = yaml.safeLoad(fs.readFileSync('convert.yml', 'utf8')); + +var srcDirectory = path.resolve(config.srcDirectory || './complete'); +var dstDirectory = path.resolve(config.dstDirectory || './converted'); +var dirScanInterval = config.dirScanInterval || 300; +var maxConcur = config.maxConcur || 1; + +Queue.configure(Promise.Promise); + +var queue = new Queue(maxConcur, Infinity); + +function getCurrentDateTime() { + return moment().format('MM/DD/YYYY - HH:mm:ss'); +} + +function printMsg(msg) { + console.log(colors.gray(`[${getCurrentDateTime()}]`), msg); +} + +function printErrorMsg(msg) { + console.log(colors.gray(`[${getCurrentDateTime()}]`), colors.red('[ERROR]'), msg); +} + +function getFiles() { + let files = []; + + return new Promise((resolve, reject) => { + filewalker(srcDirectory, { maxPending: 1, matchRegExp: /(\.ts|\.flv)$/ }) + .on('file', (p, stats) => { + // select only "not hidden" files and not empty files (>10KBytes) + if (!p.match(/(^\.|\/\.)/) && stats.size > 10240) { + // push path relative to srcDirectory + files.push(p); + } + }) + .on('done', () => { + resolve(files); + }) + .walk(); + }); +} + +function getAudioCodec(srcFile) { + return new Promise((resolve, reject) => { + let audioCodec = ''; + let spawnArguments = [ + '-v', 'error', + '-select_streams', 'a:0', + '-show_streams', + '-print_format', 'json', + srcFile + ]; + + let ffprobeProcess = childProcess.spawn('ffprobe', spawnArguments); + + ffprobeProcess.stdout.pipe(JSONStream.parse('streams.0')).on('data', data => { + audioCodec = data.codec_name; + }); + + ffprobeProcess.on('close', code => { + if (code !== 0) { + reject(`Failed to get audio codec from ${srcFile}`); + } else { + resolve(audioCodec); + } + }); + }).timeout(5000); // 5 seconds +} + +function getSpawnArguments(srcFile, dstFile) { + return getAudioCodec(srcFile) + .then(audioCodec => (audioCodec === 'aac') + ? [ // aac + '-i', srcFile, + '-y', + '-hide_banner', + '-loglevel', 'panic', + '-movflags', '+faststart', + '-c:v', 'copy', + '-c:a', 'copy', + '-bsf:a', 'aac_adtstoasc', + '-copyts', + '-start_at_zero', + dstFile + ] + : [ // speex or something else + '-i', srcFile, + '-y', + '-hide_banner', + '-loglevel', 'panic', + '-movflags', '+faststart', + '-c:v', 'copy', + '-c:a', 'aac', + '-b:a', '64k', + dstFile + ] + ); +} + +function convertFile(srcFile) { + let startTs = moment(); + let src = path.join(srcDirectory, srcFile); + + let dstPath = path.resolve(path.dirname(path.join(dstDirectory, srcFile))); + let dstFile = path.basename(srcFile, path.extname(srcFile)) + '.mp4'; + + let tempDst = path.join(dstPath, `~${dstFile}`); + let dst = path.join(dstPath, dstFile); + + return mkdirpAsync(dstPath) + .then(() => getSpawnArguments(src, tempDst)) + .then(spawnArguments => new Promise((resolve, reject) => { + printMsg(`Starting ${colors.green(srcFile)}...`); + // printMsg('ffmpeg ' + spawnArguments.join(' ')); + + let ffmpegProcess = childProcess.spawn('ffmpeg', spawnArguments); + + ffmpegProcess.on('close', code => { + if (code !== 0) { + reject(`Failed to convert ${srcFile}`); + } else { + let mtime; + + fs.statAsync(src) + .then(stats => { + // remember "modification time" of original file + mtime = Math.ceil(stats.mtime.getTime() / 1000); + }) + .then(() => config.deleteAfter ? fs.unlinkAsync(src) : fs.renameAsync(src, `${src}.bak`)) + .then(() => fs.renameAsync(tempDst, dst)) + .then(() => fs.utimesAsync(dst, mtime, mtime)) + .then(() => { + let duration = moment.duration(moment().diff(startTs)).asSeconds().toString(); + + printMsg(`Finished ${colors.green(srcFile)} after ${colors.magenta(duration)} s`); + + resolve(); + }) + .catch(err => { + reject(err.toString()); + }); + } + }); + })); +} + +function mainLoop() { + let startTs = moment().unix(); + + Promise + .try(() => getFiles()) + .then(files => new Promise((resolve, reject) => { + printMsg(files.length + ' file(s) to convert'); + + if (files.length === 0) { + resolve(); + } else { + files.forEach(file => { + queue + .add(() => convertFile(file)) + .then(() => { + if ((queue.getPendingLength() + queue.getQueueLength()) === 0) { + resolve(); + } + }) + .catch(err => { + printErrorMsg(err); + }); + }); + } + })) + .catch(err => { + if (err) { + printErrorMsg(err); + } + }) + .finally(() => { + let seconds = startTs - moment().unix() + dirScanInterval; + + if (seconds < 5) { + seconds = 5; + } + + printMsg(`Done, will scan the folder in ${seconds} seconds`); + + setTimeout(mainLoop, seconds * 1000); + }); +} + +mkdirp.sync(srcDirectory); +mkdirp.sync(dstDirectory); + +mainLoop(); diff --git a/convert.yml b/convert.yml new file mode 100644 index 0000000..233245b --- /dev/null +++ b/convert.yml @@ -0,0 +1,5 @@ +srcDirectory: complete # directory where you store your .ts or .flv files +dstDirectory: converted # directory where do you want to store your .mp4 files +dirScanInterval: 300 # in seconds, min: 5 seconds +deleteAfter: true # if you want to keep the original files then set to false +maxConcur: 2 # how many files to convert simultaneously \ No newline at end of file diff --git a/librtmp.dll b/librtmp.dll new file mode 100755 index 0000000..262d927 Binary files /dev/null and b/librtmp.dll differ diff --git a/main.js b/main.js index e3f9441..8537cb1 100755 --- a/main.js +++ b/main.js @@ -1,211 +1,303 @@ -// SN4T14 2015-05-13 -// License: WTFPL -// jshint node: true -'use strict'; -var Promise = require('bluebird'); -var yaml = require('js-yaml'); -var fs = Promise.promisifyAll(require('fs')); -var bhttp = require('bhttp'); -var cheerio = require('cheerio'); -var moment = require('moment'); -var childProcess = require('child_process'); -var mkdirp = require('mkdirp'); -var S = require('string'); -var errors = require('errors'); - -var config = yaml.safeLoad(fs.readFileSync('config.yml', 'utf8')); -config.captureDirectory = S(config.captureDirectory).stripRight('/').s; // Because people will inevitably be idiots and sometimes use a trailing slash and sometimes not - -var session = bhttp.session(); -var modelsCurrentlyCapturing = []; - -errors.create({ - name: "ModelOfflineError", - explanation: "Model appears offline, it's normal for this to happen occasionally, if this happens to models that you know are online, you should file an issue on GitHub." -}); - -mkdirp(config.captureDirectory, function (err) { - if (err) { - console.log(err); - } -}); - -var debugPrint = function (printString) { - if (config.debug) { - console.log("[" + getCurrentDateTime() + "]", printString); - } -}; - -var getCurrentDateTime = function() { - return moment().format("YYYY-MM-DDThhmmss"); // The only true way of writing out dates and times, ISO 8601 -}; - -var getFileSize = function (filename) { - return Promise.try(function() { - return fs.statsAsync(filename); - }).then(function (stats) { - return stats.size; - }); -}; - -var getCommandArguments = function (modelName) { - return Promise.try(function() { - return session.get("https://chaturbate.com/" + modelName + "/"); - }).then(function (response) { - var commandArguments = { - modelName: modelName, - username: config.username.toLowerCase(), // Username has to be in lowercase for authentication to work - captureDirectory: config.captureDirectory, - dateString: getCurrentDateTime() - }; - - var $ = cheerio.load(response.body); - - var scripts = $("script") - .map(function(){ - return $(this).text(); - }).get().join(""); - - var streamData = scripts.match(/EmbedViewerSwf\(([\s\S]+?)\);/); // "EmbedViewerSWF" is ChaturBate's shitty name for the stream data, all their code has really cryptic names for everything - - if (streamData !== null) { - commandArguments.streamServer = streamData - [1] - .split(",") - .map(function (line) { - return S(line.trim()).strip("'", '"').s; - }) - [2]; - } else { - throw new errors.ModelOfflineError(); - } - - commandArguments.passwordHash = scripts.match(/password: '([^']+)'/)[1].replace("\\u003D", "="); // As of 2015-05-15, this is a PBKDF2-SHA256 hash of the user's password, with the iteration count and salt generously provided. I could replace the empty line below with a line to make bhttp send this hash to my own server, where I'd be able to crack it at my leisure, but as you can see, that line is empty, you're welcome. :) - - return commandArguments; - }); -}; - -var capture = function (modelName) { - Promise.try(function() { - return getCommandArguments(modelName); - }).then(function (commandArguments) { - var filename = "./" + commandArguments.captureDirectory + "/Chaturbate_" + commandArguments.dateString + "_" + commandArguments.modelName + ".flv"; - - var spawnArguments = [ - "--live", - config.debug ? "" : "--quiet", - "--rtmp", - "rtmp://" + commandArguments.streamServer + "/live-edge", - "--pageUrl", - "http://chaturbate.com/" + commandArguments.modelName, - "--conn", - "S:" + commandArguments.username, - "--conn", - "S:" + commandArguments.modelName, - "--conn", - "S:2.645", // Apparently this is the flash version, fucked if I know why this is needed, this seems to be extracted from a file listed two lines above where the streamServer is grabbed, with "p" in the filename changed to ".", and the path and extension removed - "--conn", - "S:" + commandArguments.passwordHash, // "Hey guys, know what'd be a great idea? Authenticating connections by passing the password hash to the client and back!" - "--token", - "m9z#$dO0qe34Rxe@sMYxx", // 0x5f3759df - "--playpath", - "playpath", - "--flv", - filename - ]; - - var captureProcess = childProcess.spawn("rtmpdump", spawnArguments); - - captureProcess.on("close", function (code) { - console.log("[" + getCurrentDateTime() + "]", commandArguments.modelName, "stopped streaming."); - - var modelIndex = modelsCurrentlyCapturing.indexOf(modelName); - if(modelIndex !== -1) { - modelsCurrentlyCapturing.splice(modelIndex, 1); - } - - Promise.try(function() { - return getFileSize(filename); - }).then(function (fileSize) { - if (fileSize === 0) { - return fs.unlinkAsync(filename); - } - }); - }); - - captureProcess.stdout.on("data", function (data) { - console.log("[" + getCurrentDateTime() + "]", data.toString()); - }); - - captureProcess.stderr.on("data", function (data) { - console.log("[" + getCurrentDateTime() + "]", data.toString()); - }); - }).catch(errors.ModelOfflineError, function (e) { - console.log("[" + getCurrentDateTime() + "]", e.explanation); - - var modelIndex = modelsCurrentlyCapturing.indexOf(modelName); - if(modelIndex !== -1) { - modelsCurrentlyCapturing.splice(modelIndex, 1); - } - }); -}; - -var getLiveModels = function() { - return Promise.try(function() { - return session.get("https://chaturbate.com/followed-cams/"); - }).then(function (response) { - var $ = cheerio.load(response.body); - - return $("#main div.content ul.list").children("li") - .filter(function(){ - return $(this).find("div.details ul.sub-info li.cams").text() != "offline"; - }) - .map(function(){ - return $(this).find("div.title a").text().trim(); - }) - .get(); - }); -}; - -var chaturbateLogin = function() { - return Promise.try(function() { - return session.get("https://chaturbate.com/auth/login/"); - }).then(function (response) { - var $ = cheerio.load(response.body); - - var csrfToken = $("#main form[action='/auth/login/'] input[name='csrfmiddlewaretoken']").val(); - - return session.post("https://chaturbate.com/auth/login/", {username: config.username, password: config.password, csrfmiddlewaretoken: csrfToken, next: "/"}, {headers: {"referer": "https://chaturbate.com/auth/login/"}}); - }); -}; - -var mainLoop = function() { - Promise.try(function() { - debugPrint("Logging in to Chaturbate (it's normal for this to happen all the time)"); - - return chaturbateLogin(); - }).then(function (response) { - return getLiveModels(); - }).then(function (liveModels) { - debugPrint("Found these live followed models: " + liveModels.toString()); - - liveModels.forEach(function (liveModel) { - if (modelsCurrentlyCapturing.indexOf(liveModel) === -1) { - console.log("[" + getCurrentDateTime() + "]", liveModel, "is now online, starting rtmpdump process"); - - modelsCurrentlyCapturing.push(liveModel); - capture(liveModel); - } - }); - - }).then(function() { - debugPrint("Started capturing all models found, will search for new models in " + config.modelScanInterval); - - setTimeout(mainLoop, config.modelScanInterval); - }); -}; - -console.log("[" + getCurrentDateTime() + "]", "capturebate-node started"); // Lol lies this is the first thing it does that isn't a variable definition - -mainLoop(); +'use strict'; + +var Promise = require('bluebird'); +var fs = Promise.promisifyAll(require('fs')); +var mv = require('mv'); +var childProcess = require('child_process'); +var path = require('path'); +var bhttp = require('bhttp'); +var cheerio = require('cheerio'); +var colors = require('colors'); +var mkdirp = require('mkdirp'); +var moment = require('moment'); +var S = require('string'); +var yaml = require('js-yaml'); + +var session = bhttp.session(); + +var modelsCurrentlyCapturing = []; + +var config = yaml.safeLoad(fs.readFileSync('config.yml', 'utf8')); + +config.minFileSizeMb = config.minFileSizeMb || 0; +config.dateFormat = config.dateFormat || 'YYYYMMDD-HHmmss'; +config.createModelDirectory = !!config.createModelDirectory; + +var captureDirectory = path.resolve(config.captureDirectory || './captures'); +var completeDirectory = path.resolve(config.completeDirectory || './complete'); + +function mkdir(dir) { + mkdirp(dir, err => { + if (err) { + printErrorMsg(err); + process.exit(1); + } + }); +} + +function getCurrentDateTime() { + return moment().format('MM/DD/YYYY - HH:mm:ss'); +} + +function printMsg(msg) { + console.log(colors.gray(`[${getCurrentDateTime()}]`), msg); +} + +function printErrorMsg(msg) { + console.log(colors.gray(`[${getCurrentDateTime()}]`), colors.red('[ERROR]'), msg); +} + +function printDebugMsg(msg) { + if (config.debug && msg) { + console.log(colors.gray(`[${getCurrentDateTime()}]`), colors.yellow('[DEBUG]'), msg); + } +} + +function dumpModelsCurrentlyCapturing() { + modelsCurrentlyCapturing.forEach(m => { + printDebugMsg(colors.red(m.pid) + '\t' + m.checkAfter + '\t' + m.filename); + }); +} + +function login() { + return Promise + .try(() => session.get('https://chaturbate.com/auth/login/')) + .then(response => { + let $ = cheerio.load(response.body); + + let csrfToken = $('#main form[action="/auth/login/"] input[name="csrfmiddlewaretoken"]').val(); + + return session.post('https://chaturbate.com/auth/login/', { + username: config.username, + password: config.password, + csrfmiddlewaretoken: csrfToken, + next: '/' + }, { + headers: { + referer: 'https://chaturbate.com/auth/login/' + } + }); + }); +} + +function getFollowedCams() { + return Promise + .try(() => session.get('https://chaturbate.com/followed-cams/')) + .then(response => cheerio.load(response.body)); +} + +function getLiveModels() { + return getFollowedCams() + .then($ => { + // it the user is already logged in then we resolve immediately + if ($('#user_information a.username').text() === config.username) { + return $; + } + + printDebugMsg('Login is required'); + + // for simplicity of the code we make only one login attempt per cycle + return login().then(() => getFollowedCams()); + }) + .then($ => { + let liveModels = $('#main div.content ul.list') + .children('li') + .filter(function () { + return $(this).find('div.details ul.sub-info li.cams').text() !== 'offline'; + }) + .map(function () { + return $(this).find('div.title a').text().trim(); + }) + .get(); + + printDebugMsg('Found these live followed models: ' + liveModels.join(', ')); + + return liveModels; + }) + .timeout(15000, 'Failed to get live models'); +} + +function getCommandArguments(modelName) { + return Promise + .try(() => session.get(`https://chaturbate.com/${modelName}/`)) + .then(response => { + let $ = cheerio.load(response.body); + + let script = $('script') + .map(function () { + return $(this).text(); + }) + .get() + .join(''); + + let streamData = script.match(/EmbedViewerSwf\(([\s\S]+?)\);/); // "EmbedViewerSWF" is ChaturBate's shitty name for the stream data, all their code has really cryptic names for everything + + if (!streamData) { + throw new Error('streamData is unavailable'); + } + + let streamServer = streamData[1] + .split(',') + .map(line => S(line.trim()).strip('\'', '"'))[2]; + + if (!streamServer) { + throw new Error('streamServer is unavailable'); + } + + let passwordHash = script.match(/password: '([^']+)'/)[1].replace('\\u003D', '='); // As of 2015-05-15, this is a PBKDF2-SHA256 hash of the user's password, with the iteration count and salt generously provided. I could replace the empty line below with a line to make bhttp send this hash to my own server, where I'd be able to crack it at my leisure, but as you can see, that line is empty, you're welcome. :) + + if (!passwordHash) { + throw new Error('passwordHash is unavailable'); + } + + return { streamServer, passwordHash }; + }) + .timeout(15000); +} + +function createCaptureProcess(modelName) { + let model = modelsCurrentlyCapturing.find(m => m.modelName === modelName); + + if (!!model) { + printDebugMsg(colors.green(modelName) + ' is already capturing'); + return; // resolve immediately + } + + printMsg(colors.green(modelName) + ' is now online, starting rtmpdump process'); + + return Promise + .try(() => getCommandArguments(modelName)) + .then(commandArguments => { + let filename = modelName + '-' + moment().format(config.dateFormat) + '.flv'; + + let spawnArguments = [ + '--live', + config.rtmpDebug ? '' : '--quiet', + '--rtmp', 'rtmp://' + commandArguments.streamServer + '/live-edge', + '--pageUrl', 'http://chaturbate.com/' + modelName, + '--conn', 'S:' + config.username.toLowerCase(), + '--conn', 'S:' + modelName, + '--conn', 'S:2.645', // Apparently this is the flash version, fucked if I know why this is needed, this seems to be extracted from a file listed two lines above where the streamServer is grabbed, with "p" in the filename changed to ".", and the path and extension removed + '--conn', 'S:' + commandArguments.passwordHash, // "Hey guys, know what'd be a great idea? Authenticating connections by passing the password hash to the client and back!" + '--token', 'm9z#$dO0qe34Rxe@sMYxx', // 0x5f3759df + '--playpath', 'playpath', + '--flv', + path.join(captureDirectory, filename) + ]; + + let captureProcess = childProcess.spawn('rtmpdump', spawnArguments); + + captureProcess.stdout.on('data', data => { + printMsg(data.toString()); + }); + + captureProcess.stderr.on('data', data => { + printMsg(data.toString()); + }); + + captureProcess.on('close', code => { + printMsg(colors.green(modelName) + ' stopped streaming'); + + let stoppedModel = modelsCurrentlyCapturing.find(m => m.pid === captureProcess.pid); + + if (!!stoppedModel) { + let modelIndex = modelsCurrentlyCapturing.indexOf(stoppedModel); + + if (modelIndex !== -1) { + modelsCurrentlyCapturing.splice(modelIndex, 1); + } + } + + let srcFile = path.join(captureDirectory, filename); + let dstFile = config.createModelDirectory + ? path.join(completeDirectory, modelName, filename) + : path.join(completeDirectory, filename); + + fs.statAsync(srcFile) + .then(stats => { + if (stats.size <= (config.minFileSizeMb * 1048576)) { + fs.unlinkAsync(srcFile); + } else { + mv(srcFile, dstFile, { mkdirp: true }, err => { + if (err) { + printErrorMsg('[' + colors.green(modelName) + '] ' + err.toString()); + } + }); + } + }) + .catch(err => { + if (err.code !== 'ENOENT') { + printErrorMsg('[' + colors.green(modelName) + '] ' + err.toString()); + } + }); + }); + + if (!!captureProcess.pid) { + modelsCurrentlyCapturing.push({ + modelName, + filename, + captureProcess, + pid: captureProcess.pid, + checkAfter: moment().unix() + 60, // we are gonna check the process after 60 seconds + size: 0 + }); + } + }) + .catch(err => { + printErrorMsg('[' + colors.green(modelName) + '] ' + err.toString()); + }); +} + +function checkCaptureProcess(model) { + if (model.checkAfter > moment().unix()) { + // if this is not the time to check the process then we resolve immediately + return; + } + + return fs + .statAsync(path.join(captureDirectory, model.filename)) + .then(stats => { + // first time we check after 60 seconds then we check it every 10 minutes, + // if the size of the file has not changed over this time, we kill the process + if (stats.size - model.size > 0) { + printDebugMsg(colors.green(model.modelName) + ' is alive'); + + model.checkAfter = moment().unix() + 600; // 10 minutes + model.size = stats.size; + } else if (!!model.captureProcess) { + // we assume that onClose will do all clean up for us + printErrorMsg('[' + colors.green(model.modelName) + '] Process is dead'); + model.captureProcess.kill(); + } else { + // suppose here we should forcefully remove the model from modelsCurrentlyCapturing + // because her captureProcess is unset, but let's leave this as is + } + }) + .catch(err => { + if (err.code !== 'ENOENT') { + printErrorMsg('[' + colors.green(model.modelName) + '] ' + err.toString()); + } + }); +} + +function mainLoop() { + printDebugMsg('Start searching for new models'); + + Promise + .all(modelsCurrentlyCapturing.map(checkCaptureProcess)) + .then(() => getLiveModels()) + .then(liveModels => Promise.all(liveModels.map(createCaptureProcess))) + .catch(err => printErrorMsg(err)) + .finally(() => { + dumpModelsCurrentlyCapturing(); + + printMsg(`Done, will search for new models in ${config.modelScanInterval} second(s)`); + + setTimeout(mainLoop, config.modelScanInterval * 1000); + }); +} + +mkdir(captureDirectory); +mkdir(completeDirectory); + +login().then(() => mainLoop()); diff --git a/package.json b/package.json index d67ae7c..4ab839e 100755 --- a/package.json +++ b/package.json @@ -1,29 +1,33 @@ { - "name": "capturebate-node", - "version": "0.1.3", - "description": "CaptureBate rewrite in Node", - "main": "main.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "repository": { - "type": "git", - "url": "git://github.com/SN4T14/capturebate-node.git" - }, - "author": "SN4T14", - "license": "WTFPL", - "bugs": { - "url": "https://github.com/SN4T14/capturebate-node/issues" - }, - "homepage": "https://github.com/SN4T14/capturebate-node", - "dependencies": { - "bluebird": "^2.9.25", - "js-yaml": "^3.3.1", - "bhttp": "^1.2.1", - "cheerio": "^0.19.0", - "moment": "^2.10.3", - "mkdirp": "^0.5.1", - "string": "^3.1.1", - "errors": "^0.2.0" - } + "name": "capturebate-node", + "version": "0.2.1", + "description": "CaptureBate rewrite in Node", + "main": "main.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git://github.com/SN4T14/capturebate-node.git" + }, + "author": "SN4T14", + "license": "WTFPL", + "bugs": { + "url": "https://github.com/SN4T14/capturebate-node/issues" + }, + "homepage": "https://github.com/SN4T14/capturebate-node", + "dependencies": { + "JSONStream": "^1.3.1", + "bhttp": "^1.2.1", + "bluebird": "^2.9.25", + "cheerio": "^0.19.0", + "colors": "^1.1.2", + "filewalker": "^0.1.3", + "js-yaml": "^3.3.1", + "mkdirp": "^0.5.1", + "moment": "^2.10.3", + "mv": "^2.1.1", + "promise-queue": "^2.2.3", + "string": "^3.1.1" + } } diff --git a/rtmpdump.exe b/rtmpdump.exe new file mode 100644 index 0000000..e5a774c Binary files /dev/null and b/rtmpdump.exe differ