nitpin
Version:
NNTP Client Library
1,030 lines (793 loc) • 20.3 kB
JavaScript
var Blast = __Protoblast,
Fn = Blast.Bound.Function,
SlowBuffer = require('buffer').SlowBuffer,
NzbFile = require('./nzb_file'),
parsexml = require('xml2js').parseString,
request = require('request'),
libpath = require('path'),
Yencer = require('yencer'),
mkdirp = require('mkdirp'),
http = require('http'),
fs = require('graceful-fs'),
NzbDocument;
/**
* The NzbDocument class: a wrapper for the actual .nzb XML file
*
* @author Jelle De Loecker <jelle@kipdola.be>
* @since 0.1.1
* @version 0.1.3
*
* @param {String} filelink
* @param {Nitpin} server
*/
NzbDocument = Fn.inherits('Informer', function NzbDocument(filelink, server) {
var that = this;
if (filelink && typeof filelink == 'object') {
server = filelink;
filelink = null;
}
// The link to the nzb
this.filelink = filelink;
// The name of this nzb
this.name = libpath.basename(filelink, '.nzb');
// Create a slug
this.slug = Blast.Bound.String.slug(this.name);
// Optional server link
this.server = server;
// The XML data
this.xml = null;
// Has parchive been asked?
this.parmode = false;
// Pars in this nzb
this.pars = {
main: false,
others: []
};
// All the files in this nzb
this.files = {};
// All the (main) rars in this nzb
this.rars = {
main: false,
others: []
};
// Rar basename count
this.rar_bases = {};
// Has a parse been queued?
this._parsed = false;
if (this.filelink) {
this.getNZB(this.filelink, function result(err) {
if (err) {
that.emit('error', err);
}
});
}
// Has this been marked as broken?
this.markedAsBroken = false;
// Total amount of broken segments
this.brokenSegmentCount = 0;
// There is only 1 yenqueue per server instance
this.yenqueue = server.yenqueue;
// Function queue for getting the segments
this.filequeue = new Blast.Classes.FunctionQueue();
// Filequeue limit is tricky: we want to download the segments ASAP,
// but also not overload the process and maintain a sorted download.
this.filequeue.limit = 50;
// Sort the queue on every check
this.filequeue.sort = true;
// Start the queue
this.filequeue.start();
// @todo: make queue parameters configurable
});
/**
* Total download size of all the available files
*
* @author Jelle De Loecker <jelle@kipdola.be>
* @since 0.1.4
* @version 0.1.4
*/
NzbDocument.setProperty(function downloadsize() {
var count = 0,
key;
for (key in this.files) {
count += this.files[key].downloadsize;
}
return count;
});
/**
* Par file count
*
* @author Jelle De Loecker <jelle@kipdola.be>
* @since 0.1.4
* @version 0.1.4
*/
NzbDocument.setProperty(function parcount() {
var count = 0;
if (this.pars.main) {
count++;
}
count += this.pars.others.length;
return count;
});
/**
* Maximum allowed of broken segments for par to be able to repair them
*
* @author Jelle De Loecker <jelle@kipdola.be>
* @since 0.1.4
* @version 0.1.4
*/
NzbDocument.setProperty(function fixableSegmentCount() {
var arr,
count;
if (this._fixableSegmentCount) {
return this._fixableSegmentCount;
}
arr = [];
if (this.pars.main) {
arr.push(this.pars.main);
}
if (this.pars.others.length) {
arr = arr.concat(this.pars.others);
}
if (arr.length == 0) {
return 0;
}
count = 0;
arr.forEach(function eachPar(par) {
var extract,
nr;
extract = /\+(\d+)\.par2/.exec(par.name);
if (extract && extract[1]) {
nr = Number(extract[1]);
if (nr) {
count += nr;
}
}
});
this.debug('Counted fixable segments:', count);
this._fixableSegmentCount = count;
return count;
});
/**
* Debug method
*
* @author Jelle De Loecker <jelle@kipdola.be>
* @since 0.1.3
* @version 0.1.3
*/
NzbDocument.setMethod(function debug() {
if (!this.server.debugMode) {
return false;
}
return this.server.debug('__debug__', 'NZBDOCUMENT', arguments);
});
/**
* Abort the download
*
* @author Jelle De Loecker <jelle@kipdola.be>
* @since 0.1.5
* @version 0.1.5
*/
NzbDocument.setMethod(function abort() {
var key;
for (key in this.files) {
this.files[key].abort();
}
this.filequeue.destroy();
this.yenqueue.destroy();
});
/**
* Mark as broken
*
* @author Jelle De Loecker <jelle@kipdola.be>
* @since 0.1.4
* @version 0.1.4
*/
NzbDocument.setMethod(function markBroken() {
var file,
key;
// Only mark this as broken once
if (this.markedAsBroken) {
return;
}
if (this.brokenSegmentCount > this.fixableSegmentCount) {
this.debug('Marking as broken - Not enough fixable segments for', this.name);
} else {
this.debug('Marking as broken', this.name);
}
this.markedAsBroken = true;
// Stop the downloads
this.abort();
for (key in this.files) {
file = this.files[key];
if (file && file.abort) {
file.abort();
}
}
this.debug('All files have been aborted for NZB', this.name);
// Emit the broken event
this.emitOnce('broken');
});
/**
* Get and parse an NZB file
*
* @author Jelle De Loecker <jelle@kipdola.be>
* @since 0.1.1
* @version 0.1.1
*
* @param {String} path The path or url
*/
NzbDocument.setMethod(function getNZB(path, callback) {
var that = this;
this.downloadNZB(path, function gotFile(err, data) {
if (err) {
return callback(err);
}
that.parse();
callback();
});
});
/**
* Prepare a temporary folder
*
* @author Jelle De Loecker <jelle@kipdola.be>
* @since 0.1.1
* @version 0.1.3
*
* @param {Function} callback
*/
NzbDocument.setMethod(function getTemp(suffix, callback) {
var that = this,
path;
if (typeof suffix == 'function') {
callback = suffix;
suffix = null;
}
if (that.temppath && suffix == null) {
return callback(null, that.temppath);
}
// Create a unique-ish id
if (!this.tempid) {
this.tempid = Date.now() + '-' + ~~(Math.random()*10e5);
}
if (!this.temppath) {
this.temppath = libpath.resolve(this.server.tempdir, this.slug);
}
if (suffix) {
path = libpath.resolve(this.temppath, suffix);
} else {
path = this.temppath;
}
mkdirp(path, function(err) {
return callback(err, path);
});
});
/**
* Get an NZB file
*
* @author Jelle De Loecker <jelle@kipdola.be>
* @since 0.1.1
* @version 0.1.4
*
* @param {String} path The path or url
*/
NzbDocument.setMethod(function downloadNZB(path, callback) {
var that = this,
attempts,
options;
// Make sure the callback is only called once
callback = Blast.Bound.Function.regulate(callback);
if (!Blast.Bound.String.startsWith(path, 'http')) {
return fs.readFile(path, function gotFile(err, data) {
if (err) {
return callback(err);
}
that.xml = data.toString();
that.parse();
callback(null, data);
});
}
attempts = 0;
Fn.series(function makeRequest(nextStep) {
var err,
req;
that.debug('Downloading NZB file from', path);
request({url: path, gzip: true}, function gotResponse(err, res, xml) {
if (err || res.statusCode > 499 && attempts < 5) {
attempts++;
return makeRequest(nextStep);
}
if (err) {
return nextStep(err);
}
// @todo: handle non-xml responses
// console.log(JSON.stringify(xml) + ' -- ' + res.statusCode)
that.xml = xml;
that.parse();
nextStep();
});
}, function done(err) {
if (err) {
return callback(err);
}
callback(null, that.xml);
});
});
/**
* Parse the XML
*
* @author Jelle De Loecker <jelle@kipdola.be>
* @since 0.1.1
* @version 0.1.4
*/
NzbDocument.setMethod(function parse() {
var that = this,
base_count = 0,
base_name,
entries,
files;
if (this._parsed) {
return;
}
this._parsed = true;
files = {};
parsexml(this.xml, function parsed(err, data) {
var i;
if (err) {
that.emit('error', err);
return;
}
entries = data.nzb.file;
entries.forEach(function eachFile(entry) {
var pieceCount,
filename,
segments,
subject,
pieces,
split,
file,
data,
temp,
seg,
i;
subject = entry['$'].subject;
// Get the piece info (##/##)
pieces = /\((\d*)\/(\d+)\)/.exec(subject);
filename = Blast.Bound.String.after(subject, '"');
filename = Blast.Bound.String.before(filename, '"').trim();
// If no valid filename was found, skip this entry
if (!filename) {
return;
}
// Is this a split file?
split = /\.(\d\d\d)$/.exec(filename);
if (split) {
split = Number(split[1]);
filename = filename.slice(0, -4);
}
segments = [];
for (i = 0; i < entry.segments[0].segment.length; i++) {
seg = entry.segments[0].segment[i];
segments.push({
id: seg['_'],
bytes: Number(seg['$'].bytes),
number: Number(seg['$'].number),
from_cache: false
});
}
if (pieces) {
pieceCount = Number(pieces[2]);
} else {
pieceCount = segments.length;
}
data = {
date: new Date(Number(entry['$'].date) * 1000),
poster: entry['$'].poster,
subject: subject,
pieces: pieceCount,
filename: filename,
groups: Blast.Bound.Object.extract(entry.groups, '$..group.0'),
yenc: null, // yenc is null by default (unknown)
split: split
};
// If yenc is in the subject, it definitely is a yenced file
// If it's not, it still could be
if (subject.toLowerCase().indexOf('yenc') > -1) {
data.yenc = true;
}
if (split == null) {
data.segments = segments;
files[filename] = data;
} else {
if (!files[filename]) {
data.splitsegments = [];
files[filename] = data;
} else {
files[filename].pieces += data.pieces;
}
files[filename].splitsegments[split] = segments
}
});
for (filename in files) {
if (files[filename].splitsegments) {
segments = [];
for (i = 0; i < files[filename].splitsegments.length; i++) {
segments = segments.concat(files[filename].splitsegments[i]);
}
files[filename].segments = segments;
}
}
that.files = {};
// Now create the NzbFile instances
for (filename in files) {
that.files[filename] = new NzbFile(that, files[filename]);
}
// Now assign the rar files
// Only the ones with the highest base count are added
for (filename in that.rar_bases) {
if (that.rar_bases[filename] > base_count) {
base_count = that.rar_bases[filename];
base_name = filename;
}
}
for (filename in that.files) {
file = that.files[filename];
if (!file.isRar) {
continue;
}
if (file.rar_base_name != base_name) {
continue;
}
that.rars.others.push(file);
}
// Order the other rars
Blast.Bound.Array.sortByPath(that.rars.others, 1, 'suborder');
// If no main rar file was found, use the first other
if (!that.rars.main && that.rars.others.length) {
that.rars.main = that.rars.others.shift();
}
// Emit the parsed event
that.emit('parsed');
});
});
/**
* Go over every found file in the nzb
*
* @author Jelle De Loecker <jelle@kipdola.be>
* @since 0.1.1
* @version 0.1.1
*
* @param {Function} fnc
*/
NzbDocument.setMethod(function _eachFile(fnc, done) {
var filename;
this.after('parsed', function afterFetched() {
for (filename in this.files) {
fnc(this.files[filename], filename);
}
if (done) {
done();
}
});
});
/**
* Go over every found file in the nzb, for external use
*
* @author Jelle De Loecker <jelle@kipdola.be>
* @since 0.1.1
* @version 0.1.1
*
* @param {Function} fnc
*/
NzbDocument.setMethod(function eachFile(fnc, done) {
var that = this;
this.after('triaged', function afterFetched() {
that._eachFile(fnc, done);
});
});
/**
* A child file is ready for paring
*
* @author Jelle De Loecker <jelle@kipdola.be>
* @since 0.1.1
* @version 0.1.4
*
* @param {NzbFile} brokenFile
* @param {Object} brokenSegment
*/
NzbDocument.setMethod(function readyForPar(brokenFile, brokenSegment) {
var that = this,
gotFiles = false,
gotPar = false,
files = [],
fixer,
doPar;
this.emitOnce('needsRepair', brokenFile);
if (!this.markedAsBroken) {
this.debug('File requests PAR repair:', brokenFile.name);
}
this.brokenSegmentCount++;
if (this.brokenSegmentCount > this.fixableSegmentCount) {
return this.markBroken();
}
// If we're already parring, do nothing
if (this.parmode) {
return;
}
// Indicate parchiving-mode has been enabled
this.parmode = true;
// Create a repair folder
this.getTemp('repair', function gotPath(err, path) {
var parfiles,
filetasks,
tasks,
file,
key;
if (err) {
that.debug('Error creating repair folder:', err);
throw err;
} else {
that.debug('Created repair folder at', path);
}
parfiles = [];
if (that.pars.main) {
parfiles.push(that.pars.main);
}
if (that.pars.others && that.pars.others.length) {
parfiles = parfiles.concat(that.pars.others);
}
if (!parfiles.length) {
that.debug('No PAR files were found, unable to repair NZB', that.name);
return that.markBroken();
} else {
that.debug('Fetching', parfiles.length, 'PAR files for NZB', that.name);
}
// Download all the parfiles
tasks = parfiles.map(function eachPar(parfile) {
return function getParfile(next) {
var writestream,
parstream,
parpath;
// Construct the path where the par file should be downloaded
parpath = libpath.resolve(path, parfile.name);
// Store that full path on the file object
parfile.parpath = parpath;
// Create the writestream
writestream = fs.createWriteStream(parpath)
// Create a stream to the parfile, give it high priority
parstream = parfile.stream(99999);
parstream.pipe(writestream);
// Wait for the data to be written to the file
writestream.on('finish', function() {
that.debug('Parfile has finished writing to', parpath);
next();
});
};
});
for (key in that.files) {
file = that.files[key];
if (file.parchive) {
continue;
}
files.push(file);
}
filetasks = files.map(function eachFile(file) {
return function getFile(next) {
var filestream = file.stream(88888, true),
writestream = fs.createWriteStream(path + '/' + file.name);
filestream.pipe(writestream);
// Store the path to the repair directory
file.repairdir = path;
// Wait for the data to be written to the file
writestream.on('finish', next);
};
});
// Get all the par files
Fn.parallel(tasks, function finishedGettingParfiles(err) {
that.debug('All PAR files have been downloaded');
gotPar = true;
doPar();
});
// Get all the files, including the broken ones
Fn.parallel(filetasks, function finishedGettingFiles(err) {
that.debug('All regular files have been downloaded for PAR repairing');
gotFiles = true;
doPar();
});
});
doPar = function doPar() {
// Do nothing if it's already started
if (fixer) {
return;
}
// Wait for the par files
if (!gotPar) {
return that.debug('Still waiting for PAR files before repairing');
}
// Wait for all the files
if (!gotFiles) {
return that.debug('Still waiting on the regular files before repairing');
}
// Create the repair instance
fixer = that.server.repair(that.pars.main.parpath);
that.debug('Created PAR fixer:', that.pars.main.parpath);
fixer.on('progress', function gotProgress(progress) {
that.emit('par_progress', progress);
});
fixer.on('debug', function gotDebug(msg) {
that.emit('debug', msg);
});
fixer.on('finished', function onFinish(repaired) {
// File can't be repaired
if (!repaired) {
that.debug('PAR fixer FAILED:', that.pars.main.parpath);
return that.markBroken();
} else {
that.debug('PAR fixed:', that.pars.main.parpath);
that.emit('repaired');
}
// Tell the files they've been repaired
files.forEach(function eachFile(file) {
// Store the full repaired path
file.repaired = libpath.resolve(file.repairdir, file.name);
// Emit the repaired event, but only for corrupted files
if (file.corrupted) {
file.emit('repaired', file.repaired);
}
// Emit a likewise event, that just says a verified file is available
file.emit('verified', file.repaired);
});
});
};
});
/**
* Download the contents of the file
*
* @author Jelle De Loecker <jelle@kipdola.be>
* @since 0.1.1
* @version 0.1.1
*/
NzbDocument.setMethod(function download(callback) {
var that = this,
tasks = [],
totalCorrupted = 0,
files = {};
// Go over every file in this NZB
this.eachFile(function gotFile(file, filename) {
// Do not download parchives (yet)
if (file.parchive) {
return;
}
tasks.push(function getFile(next) {
that.downloadFile(file, function gotFile(err, buffer, corrupted) {
// Total corrupted segments
totalCorrupted += corrupted;
files[filename] = {
corrupted: corrupted,
buffer: buffer
};
next();
});
});
}, function done() {
Fn.parallel(tasks, function tasksDone(err) {
var temppath;
Fn.series(function getTemp(next) {
that.getTemp(function gotTemp(err, response) {
temppath = response;
next();
});
}, function getParchives(next) {
var partasks,
parfiles;
// Don't download parchives if they're not needed
if (!totalCorrupted) {
return next();
}
partasks = [];
if (!that.pars.main) {
return callback(new Error('Incomplete file and no parchives found'));
}
parfiles = [that.pars.main].concat(that.pars.others);
parfiles.forEach(function eachPar(entry) {
partasks.push(function getPar(next) {
that.downloadFile(entry, function gotPar(err, buffer, corrupted) {
files[entry.filename] = {
corrupted: corrupted,
buffer: buffer
};
next();
});
});
});
Fn.parallel(partasks, function gotPars() {
next();
});
}, function done(err) {
var writeTasks = [],
filename;
Blast.Bound.Object.each(files, function(file, filename) {
writeTasks.push(function (nextwrite) {
fs.writeFile(temppath + '/' + filename, files[filename].buffer, function written(err) {
nextwrite(err);
});
});
});
Fn.parallel(writeTasks, function done(err) {
callback(err);
});
});
});
});
});
/**
* Abort the download
*
* @author Jelle De Loecker <jelle@kipdola.be>
* @since 0.1.4
* @version 0.1.4
*/
NzbDocument.setMethod(function abort() {
var key;
for (key in this.files) {
this.files[key].abort();
}
this.emit('aborted');
});
/**
* Download a specific file from inside an nzb and callback with the buffer
*
* @author Jelle De Loecker <jelle@kipdola.be>
* @since 0.1.1
* @version 0.1.4
*
* @param {Object} file
*/
NzbDocument.setMethod(function downloadFile(file, callback) {
var that = this,
tasks = [],
intact,
corrupted;
// We assume the files are intact
intact = true;
// Amount of missing segments
corrupted = 0;
// Prepare the fetching of all the file segments
file.segments.forEach(function eachSegment(segment, index) {
tasks.push(function getSegment(nextSegment) {
that.server.getBody(file.groups, segment.id, function gotSegment(err, body) {
var yfile = new Yencer.YencFile(segment);
if (err) {
// Segment failed to download
that.emit('missed_segment', segment, 0);
intact = false;
yfile.intact = false;
// Create a buffer, use the expected article size
yfile.buffer = new SlowBuffer(yfile.articlesize);
corrupted++;
} else {
// Segment was downloaded OK
that.emit('got_segment', segment, body.length);
yfile.decodePiece(body);
if (!yfile.intact) {
intact = false;
corrupted++;
}
}
nextSegment(null, yfile);
});
});
});
// Get all the segments in parallel
Fn.parallel(tasks, function done(err, yfiles) {
var buffers = [],
buffer,
yfile;
for (var i = 0; i < yfiles.length; i++) {
yfile = yfiles[i];
buffers.push(yfile.buffer);
}
buffer = Buffer.concat(buffers);
callback(null, buffer, corrupted);
});
});
module.exports = NzbDocument;