src/TorrentLibrary.js
/**
* module for exploring directories
* @see {@link https://nspragg.github.io/filehound/}
*/
import FileHound from 'filehound';
/**
* Access method from module fs (node) with constants
* @see {@link https://nodejs.org/api/fs.html#fs_fs_access_path_mode_callback}
* @see {@link https://nodejs.org/api/fs.html#fs_fs_constants_1}
*/
import {
access,
constants as FsConstants,
} from 'fs';
/**
* Basename and normalize methods from module path (node)
* @see {@link https://nodejs.org/api/path.html#path_path_basename_path_ext}
* @see {@link https://nodejs.org/api/path.html#path_path_normalize_path}
*/
import { basename, normalize } from 'path';
/**
* uniq and difference methods from Lodash
* @see {@link https://lodash.com/docs/4.17.4#uniq}
* @see {@link https://lodash.com/docs/4.17.4#difference}
* @see {@link https://lodash.com/docs/4.17.4#partition}
* @see {@link https://lodash.com/docs/4.17.4#cloneDeep}
*/
import { uniq, difference, partition, cloneDeep } from 'lodash';
/**
* A promise object provided by the bluebird promise library.
* @external {Promise} http://bluebirdjs.com/docs/api-reference
*/
import PromiseLib from 'bluebird';
/**
* List of video file extensions
* @see {@link https://github.com/sindresorhus/video-extensions}
*/
import videosExtension from 'video-extensions';
/**
* Default Parser for media files name
* @type {customParsingFunction}
* @external {nameParser} https://github.com/clement-escolano/parse-torrent-title
*/
import { parse as nameParser } from 'parse-torrent-title';
/**
* @external {EventEmitter} https://nodejs.org/api/events.html#events_class_eventemitter
*/
import EventEmitter from 'events';
/**
* Filter Properties for filterMovies function
*/
import { filterMoviesByProperties, filterTvSeriesByProperties }
from './filters/filterProperties';
/**
* check if an object has these properties and they are not undefined
* @param {Object} obj The object
* @param {Array} properties The properties array
* @return {boolean} The result
*/
function checkProperties(obj, properties) {
return properties.every(x => x in obj && obj[x]);
}
/**
* rejected promise when someone doesn't provide
* @return {Promise} The rejected promise
*/
function missingParam() {
return new PromiseLib(((resolve, reject) => {
reject(new Error('Missing parameter'));
}));
}
/**
* Bluebird seems to have an issue with fs.access - Workaround function
* @private
* @param {string} path a path
* @returns {Promise} an Promise object resolved or rejected
* @see {@link https://github.com/petkaantonov/bluebird/issues/1442}
*/
function promisifiedAccess(path) {
return new PromiseLib(((resolve, reject) => {
access(path, FsConstants.F_OK | FsConstants.R_OK, (err) => {
if (err) reject(err);
resolve();
});
}));
}
/**
* Class representing the TorrentLibrary
* @extends {EventEmitter}
*/
export default class TorrentLibrary extends EventEmitter {
/**
* constant for movie category
* @since 0.0.0
* @type {string}
* @static
*/
static get MOVIES_TYPE() {
return 'MOVIES';
}
/**
* constant for tv series category
* @type {string}
* @since 0.0.0
* @static
*/
static get TV_SERIES_TYPE() {
return 'TV_SERIES';
}
/**
* Create a TorrentLibrary
* @since 1.0.4
* @param {Object} [config] - the config object
* @param {(String)} [config.defaultPath=process.cwd()] - the default path
* @param {(String[])} [config.paths=[]] - the paths where we are looking the media files
* @param {(Map<string,string>)} [config.allFilesWithCategory=new Map()] - Mapping filepath => category
* @param {(Set<TPN_Extended>)} [config.movies=new Set()] - the movies files
* @param {(Map<string, Set<TPN_Extended>>)} [config.series=new Map()] - the serie files
* @param {customParsingFunction} [parser=nameParser] - The parsing function to be used with this lib ;
* default is function parse from parse-torrent-title package
*/
constructor(
{
defaultPath = process.cwd()
/* istanbul ignore next: tired of writing tests */,
paths = [] /* istanbul ignore next: tired of writing tests */,
allFilesWithCategory = new Map()
/* istanbul ignore next: tired of writing tests */,
movies = new Set() /* istanbul ignore next: tired of writing tests */,
series = new Map() /* istanbul ignore next: tired of writing tests */,
} = {} /* istanbul ignore next: tired of writing tests */,
parser = nameParser /* istanbul ignore next: tired of writing tests */,
) {
super();
/**
* The parsing function to be used with this lib
* @since 1.4.0
* @type {customParsingFunction}
*/
this.parser = parser;
/**
* just an easy way to scan the current directory path, if not other paths provided
* @type {string}
* @since 0.0.0
*/
this.defaultPath = defaultPath;
/**
* the paths where we are looking the media files
* @type {String[]}
* @since 0.0.0
* @example
* // after have added some paths ...
* [ "D:\somePath", "D:\anotherPath" ]
*/
this.paths = paths;
/**
* The variable where we store all kind of media files found in paths
* @type {StoreVar}
* @since 0.0.0
*/
this.stores = new Map([
[TorrentLibrary.MOVIES_TYPE, movies],
[TorrentLibrary.TV_SERIES_TYPE, series],
]);
/**
* Mapping filepath => category
* @type {Map<string,string>}
* @since 0.0.0
* @example
* { "D:\somePath\Captain Russia The Summer Soldier (2014) 1080p BrRip x264.MKV" => TorrentLibrary.MOVIES_TYPE }
*/
this.categoryForFile = allFilesWithCategory;
/**
* Private method for adding new files
* @private
* @returns {Promise} an resolved or reject promise
* @param {string[]} files An array of filePath
*/
this.addNewFiles = function addNewFiles(files) {
return new PromiseLib((resolve, reject) => {
try {
// find the new files to be added
const alreadyFoundFiles = [...this.categoryForFile.keys()];
const newFiles = difference(files, alreadyFoundFiles);
// temp var for new files before adding them to stores var
const moviesSet = new Set();
const tvSeriesSet = new Set();
// get previous result of stores var
let newMovies = this.allMovies;
const newTvSeries = this.allTvSeries;
// process each file
for (const file of newFiles) {
// get data from nameParser lib
// what we need is only the basename, not the full path
const jsonFile = this.parser(basename(file));
// extend this object in order to be used by this library
Object.assign(jsonFile, { filePath: file });
// find out which type of this file
// if it has not undefined properties (season and episode) => TV_SERIES , otherwise MOVIE
const fileCategory =
(checkProperties(jsonFile, ['season', 'episode']))
? TorrentLibrary.TV_SERIES_TYPE : TorrentLibrary.MOVIES_TYPE;
// add it in found files
this.categoryForFile.set(file, fileCategory);
// also in temp var
if (fileCategory !== TorrentLibrary.TV_SERIES_TYPE) {
moviesSet.add(jsonFile);
} else {
tvSeriesSet.add(jsonFile);
}
}
// add the movies into newMovies
newMovies = new Set([...newMovies, ...moviesSet]);
// add the tv series into newTvSeries
// First step : find all the series not in newTvSeries and add them to newTvSeries
difference(
uniq([...tvSeriesSet].map(tvSeries => tvSeries.title)),
...newTvSeries.keys(),
).forEach((tvSeriesToInsert) => {
newTvSeries.set(tvSeriesToInsert, new Set());
});
// Second step : add the new files into the correct tvSeries Set
uniq([...tvSeriesSet].map(tvSeries => tvSeries.title))
.forEach((tvSerie) => {
// get the current set for this tvSerie
const currentTvSerie = newTvSeries.get(tvSerie);
// find all the episodes in the new one for this serie
const episodes = [...tvSeriesSet]
.filter(episode => episode.title === tvSerie);
// add them and updates newTvSeries
newTvSeries.set(
tvSerie,
new Set([...currentTvSerie, ...episodes]),
);
});
// updates the stores var
this.stores.set(TorrentLibrary.MOVIES_TYPE, newMovies);
this.stores.set(TorrentLibrary.TV_SERIES_TYPE, newTvSeries);
resolve();
} catch (err) {
reject(err);
}
}).bind(true);
};
}
/**
* Provides the array of files extensions considered to be media extensions
* @return {string[]} array of files extensions
* @since 0.0.0
* @example
* // Returns [..., 'webm', 'wmv']
* TorrentLibrary.listVideosExtension()
*/
static listVideosExtension() {
return videosExtension;
}
/**
* Add the path(s) to be analyzed by the library if they exist and are readable
* @param {...string} paths - A or more path(s)
* @since 0.0.0
* @example
* // return resolved Promise "All paths were added!"
* TorrentLibraryInstance.addNewPath("C:\Users\jy95\Desktop\New folder","C:\Users\jy95\Desktop\New folder2");
* @return {Promise} On success the promise will be resolved with "All paths were added!"<br>
* On error the promise will be rejected with an Error object "Missing parameter" if the argument is missing<br>
* or an Error object from fs <br>
* @emits Events#missing_parameter
* @emits Events#error_in_function
* @emits Events#addNewPath
*/
addNewPath(...paths) {
// the user should provide us at lest a path
if (paths.length === 0) {
this.emit('missing_parameter', {
functionName: 'addNewPath',
});
return missingParam();
}
return new PromiseLib(((resolve, reject) => {
PromiseLib.map(paths, path => promisifiedAccess(path)).then(() => {
// keep only unique paths
// use normalize for cross platform's code
this.paths = uniq([...this.paths, ...paths.map(normalize)]);
this.emit('addNewPath', { paths: this.paths });
resolve('All paths were added!');
}).catch((e) => {
this.emit('error_in_function', {
functionName: 'addNewPath',
error: e.message,
});
reject(e);
});
})).bind(this);
}
/**
* Tell us if the user has provided us paths
* @since 0.0.0
* @returns {boolean} Has user provided us paths ?
* @example
* TorrentLibraryInstance.addNewPath("C:\Users\jy95\Desktop\New folder","C:\Users\jy95\Desktop\New folder2");
* TorrentLibraryInstance.hasPathsProvidedByUser() // TRUE
*/
hasPathsProvidedByUser() {
return this.paths.length !== 0;
}
/**
* Scans the paths in search for new files to be added inside this lib
* @since 0.0.0
* @return {Promise} On success the promise will be resolved with "Scanning completed"<br>
* On error the promise will be rejected with an Error object from sub modules<br>
* @emits Events#scan
* @emits Events#error_in_function
*/
scan() {
const foundFiles = FileHound.create()
.paths((this.paths.length === 0) ? this.defaultPath : this.paths)
.ext(videosExtension)
.find();
return new PromiseLib((resolve, reject) => {
foundFiles
.then(files => this.addNewFiles(files)).then(() => {
this.emit('scan', { files: foundFiles });
resolve('Scanning completed');
}).catch((err) => {
this.emit('error_in_function', {
functionName: 'scan',
error: err.message,
});
reject(err);
});
}).bind(this);
}
/**
* Removes files stored in this library
* @param {...string} files An array of filePath (for example the keys of allFilesWithCategory)
* @since 1.0.3
* @return {Promise} an resolved or rejected promise<br>
* On success, the resolve will contain an message and the removed filePaths<br>
* On error the promise will be rejected with an Error object from sub modules<br>
* @example
* // with multiples files
* TorrentLibraryInstance.removeOldFiles(
* "D:\somePath\Captain Russia The Summer Soldier (2014) 1080p BrRip x264.MKV",
* "D:\\workspaceNodeJs\\torrent-files-library\\test\\folder1\\The.Blacklist.S04E21.FRENCH.WEBRip.XviD.avi"
* )
* @emits Events#removeOldFiles
* @emits Events#error_in_function
*/
removeOldFiles(...files) {
return new PromiseLib((resolve, reject) => {
try {
// get the data to handle this case
// in the first group, we got all the tv series files and in the second, the movies
const processData = partition(files, file =>
this.categoryForFile.get(file) === TorrentLibrary.TV_SERIES_TYPE);
// for movies, just an easy removal
this.stores.set(
TorrentLibrary.MOVIES_TYPE,
new Set([...this.allMovies]
.filter(movie => !(processData[1].includes(movie.filePath)))),
);
// for the tv-series, a bit more complicated
// first step : find the unique tv series of these files
const tvSeriesShows = uniq(processData[0]
.map(file => this.parser(basename(file)).title));
// second step : foreach each series in tvSeriesShows
const newTvSeriesMap = this.allTvSeries;
for (const serie of tvSeriesShows) {
// get the set for this serie
const filteredSet = new Set([...newTvSeriesMap.get(serie)]
.filter(episode =>
!(processData[0].includes(episode.filePath))));
// if the filtered set is empty => no more episodes for this series
if (filteredSet.size === 0) {
newTvSeriesMap.delete(serie);
} else newTvSeriesMap.set(serie, filteredSet);
}
// save the updated map
this.stores.set(TorrentLibrary.TV_SERIES_TYPE, newTvSeriesMap);
// remove the mapping
files.forEach((file) => {
this.categoryForFile.delete(file);
});
this.emit('removeOldFiles', { files });
resolve({
message: 'The files have been deleted from the library',
files,
});
} catch (err) {
this.emit('error_in_function', {
functionName: 'removeOldFiles',
error: err.message,
});
reject(err);
}
}).bind(this);
}
/**
* Getter for all found movies
* @since 0.0.0
* @type {Set<TPN_Extended>}
* @example
* // an JSON stringified example of this method
* [
* {
* "year":2012,
* "source":"dvdrip",
* "codec":"xvid",
* "group":"-www.zone-telechargement.ws.avi",
* "container":"avi",
* "language":"truefrench",
* "title":"Bad Ass",
* "filePath":"D:\\workspaceNodeJs\\torrent-files-library\\test\\folder1\\Bad.Ass.2012.LiMiTED.TRUEFRENCH.DVDRiP.XviD-www.zone-telechargement.ws.avi"
* }
* ]
*/
get allMovies() {
return cloneDeep(this.stores.get(TorrentLibrary.MOVIES_TYPE));
}
/**
* Getter for all found tv-series
* @since 0.0.0
* @type {Map<string, Set<TPN_Extended>>}
* @example
* // an JSON stringified example of this method
* {
* "The Blacklist":[
* {
* "season":4,
* "episode":21,
* "source":"webrip",
* "codec":"xvid",
* "container":"avi",
* "language":"french",
* "title":"The Blacklist",
* "filePath":"D:\\workspaceNodeJs\\torrent-files-library\\test\\folder1\\The.Blacklist.S04E21.FRENCH.WEBRip.XviD.avi"
* },
* {
* "season":4,
* "episode":14,
* "source":"webrip",
* "codec":"xvid",
* "container":"avi",
* "language":"french",
* "title":"The Blacklist",
* "filePath":"D:\\workspaceNodeJs\\torrent-files-library\\test\\folder2\\The.Blacklist.S04E14.FRENCH.WEBRip.XviD.avi"
* }
* ]
* }
*/
get allTvSeries() {
return cloneDeep(this.stores.get(TorrentLibrary.TV_SERIES_TYPE));
}
/**
* Getter for the mapping between filepaths and category
* @type {Map<string,string>}
* @since 0.0.0
* @example
* { "D:\somePath\Captain Russia The Summer Soldier (2014) 1080p BrRip x264.MKV" => TorrentLibrary.MOVIES_TYPE }
*/
get allFilesWithCategory() {
return cloneDeep(this.categoryForFile);
}
/**
* Returns an JSON stringified of the current state
* @since 1.0.3
* @see {@link https://github.com/jy95/torrent-files-library/blob/master/test/fixtures/example.json}
* @return {string} json - the JSON stringified
*/
toJSON() {
const tvSeries = this.allTvSeries;
return `{
"paths":${JSON.stringify([...this.paths])},
"allFilesWithCategory":${JSON.stringify([...this.allFilesWithCategory])},
"movies":${JSON.stringify([...this.allMovies])},
"tv-series":${JSON.stringify([...tvSeries].map(serie =>
// serie[0] contains the title and [1] the wrong JSON ; let fix it
[serie[0], [...tvSeries.get(serie[0])]]))}
}`;
}
/**
* Creates an instance of TorrentLibrary
* @param {Object} [json] - the JSON object of toJSON() string
* @param {(String[])} json.paths - the paths where we are looking the media files
* @param {(Array.<Array.<String,String>>)} json.allFilesWithCategory - Mapping filepath => category
* @param {(TPN_Extended[])} json.movies - the movies files
* @param {(Array.<Array.<String,TPN_Extended[]>>)} json.tv-series - the serie files
* @param {customParsingFunction} [parser=nameParser] - The custom parser you want to use
* @see {@link https://github.com/jy95/torrent-files-library/tree/master/tests/fixtures/example.json} for an param example
* @since 1.2.0
* @return {TorrentLibrary} an TorrentLibrary instance
* @example
* // creates an new instance from another one
* const createdInstance = TorrentLibrary.createFromJSON(
* JSON.parse(libInstance.toJSON()),
* );
* @example
* // As explained there : https://github.com/clement-escolano/parse-torrent-title#regular-expressions
* // If you want an extra field to be populated
* const ptt = require("parse-torrent-title");
* ptt.addHandler("part", /Part[. ]([0-9])/i, { type: "integer" });
* // creates an new instance from another one; with custom parser
* const createdInstance = TorrentLibrary.createFromJSON(
* JSON.parse(libInstance.toJSON()),
* ptt.parse
* );
*/
static createFromJSON(json, parser = nameParser) {
let config = json;
// transform the param
/* istanbul ignore else */
if (json.allFilesWithCategory) {
config.allFilesWithCategory = new Map(json.allFilesWithCategory);
}
/* istanbul ignore else */
if (json.movies) {
config.movies = new Set(json.movies);
}
/* istanbul ignore else */
if (json['tv-series']) {
let createdMap = new Map();
for (let [serieTitle, setSerie] of json['tv-series']) {
createdMap.set(serieTitle, new Set(setSerie));
}
config.series = createdMap;
}
return new TorrentLibrary(config, parser);
}
/**
* Filter the movies based on search parameters
* @param {searchParameters} searchParameters - search parameters.
* @return {Set<TPN>} the filtered movie set
* @since 1.3.0
*/
filterMovies(searchParameters = {
// boolean properties
extended: undefined,
unrated: undefined,
proper: undefined,
repack: undefined,
convert: undefined,
hardcoded: undefined,
retail: undefined,
remastered: undefined,
// number properties
season: undefined,
episode: undefined,
year: undefined,
// string properties
title: undefined,
resolution: undefined,
codec: undefined,
audio: undefined,
group: undefined,
region: undefined,
container: undefined,
language: undefined,
source: undefined,
// new properties
additionalProperties: [],
}) {
// apply params based on types
return filterMoviesByProperties(searchParameters, this.allMovies);
}
/**
* Filter the tv-series based on search parameters
* @param {searchParameters} searchParameters - search parameters.
* @return {(Map<string, Set<TPN>>)} the filtered movie set
* @since 1.5.0
*/
filterTvSeries(searchParameters = {
// boolean properties
extended: undefined,
unrated: undefined,
proper: undefined,
repack: undefined,
convert: undefined,
hardcoded: undefined,
retail: undefined,
remastered: undefined,
// number properties
season: undefined,
episode: undefined,
year: undefined,
// string properties
title: undefined,
resolution: undefined,
codec: undefined,
audio: undefined,
group: undefined,
region: undefined,
container: undefined,
language: undefined,
source: undefined,
// new properties
additionalProperties: [],
}) {
return filterTvSeriesByProperties(searchParameters, this.allTvSeries);
}
}