multi-downloader-nx_mirror/funi.js
2021-09-19 16:41:00 +02:00

791 lines
26 KiB
JavaScript

#!/usr/bin/env node
// modules build-in
const fs = require('fs');
const path = require('path');
// package json
const packageJson = require('./package.json');
// program name
console.log(`\n=== Funimation Downloader NX ${packageJson.version} ===\n`);
const api_host = 'https://prod-api-funimationnow.dadcdigital.com/api';
// modules extra
const shlp = require('sei-helper');
const { lookpath } = require('lookpath');
const m3u8 = require('m3u8-parsed');
const hlsDownload = require('hls-download');
// extra
const appYargs = require('./modules/module.app-args');
const yamlCfg = require('./modules/module.cfg-loader');
const vttConvert = require('./modules/module.vttconvert');
// new-cfg
const workingDir = process.pkg ? path.dirname(process.execPath) : __dirname;
const binCfgFile = path.join(workingDir, 'config', 'bin-path');
const dirCfgFile = path.join(workingDir, 'config', 'dir-path');
const cliCfgFile = path.join(workingDir, 'config', 'cli-defaults');
const tokenFile = path.join(workingDir, 'config', 'token');
// params
const cfg = yamlCfg.loadCfg(workingDir, binCfgFile, dirCfgFile, cliCfgFile);
let token = yamlCfg.loadFuniToken(tokenFile);
// cli
const argv = appYargs.appArgv(cfg.cli);
module.exports = {
argv,
cfg
};
// Import modules after argv has been exported
const getData = require('./modules/module.getdata.js');
const merger = require('./modules/module.merger');
const parseSelect = require('./modules/module.parseSelect');
// check page
if(!isNaN(parseInt(argv.p, 10)) && parseInt(argv.p, 10) > 0){
argv.p = parseInt(argv.p, 10);
}
else{
argv.p = 1;
}
// fn variables
let title = '',
showTitle = '',
fnEpNum = 0,
fnOutput = [],
season = 0,
tsDlPath = [],
stDlPath = [];
// select mode
if(argv.auth){
auth();
}
else if(argv.search){
searchShow();
}
else if(argv.s && !isNaN(parseInt(argv.s, 10)) && parseInt(argv.s, 10) > 0){
getShow();
}
else{
appYargs.showHelp();
process.exit();
}
// auth
async function auth(){
let authOpts = {};
authOpts.user = await shlp.question('[Q] LOGIN/EMAIL');
authOpts.pass = await shlp.question('[Q] PASSWORD ');
let authData = await getData({
baseUrl: api_host,
url: '/auth/login/',
useProxy: true,
auth: authOpts,
debug: argv.debug,
});
if(authData.ok){
authData = JSON.parse(authData.res.body);
if(authData.token){
console.log('[INFO] Authentication success, your token: %s%s\n', authData.token.slice(0,8),'*'.repeat(32));
yamlCfg.saveFuniToken(tokenFile, {'token': authData.token});
}
else if(authData.error){
console.log('[ERROR]%s\n', authData.error);
process.exit(1);
}
}
}
// search show
async function searchShow(){
let qs = {unique: true, limit: 100, q: argv.search, offset: (argv.p-1)*1000 };
let searchData = await getData({
baseUrl: api_host,
url: '/source/funimation/search/auto/',
querystring: qs,
token: token,
useToken: true,
useProxy: true,
debug: argv.debug,
});
if(!searchData.ok){return;}
searchData = JSON.parse(searchData.res.body);
if(searchData.detail){
console.log(`[ERROR] ${searchData.detail}`);
return;
}
if(searchData.items && searchData.items.hits){
let shows = searchData.items.hits;
console.log('[INFO] Search Results:');
for(let ssn in shows){
console.log(`[#${shows[ssn].id}] ${shows[ssn].title}` + (shows[ssn].tx_date?` (${shows[ssn].tx_date})`:''));
}
}
console.log('[INFO] Total shows found: %s\n',searchData.count);
}
// get show
async function getShow(){
// show main data
let showData = await getData({
baseUrl: api_host,
url: `/source/catalog/title/${parseInt(argv.s, 10)}`,
token: token,
useToken: true,
useProxy: true,
debug: argv.debug,
});
// check errors
if(!showData.ok){return;}
showData = JSON.parse(showData.res.body);
if(showData.status){
console.log('[ERROR] Error #%d: %s\n', showData.status, showData.data.errors[0].detail);
process.exit(1);
}
else if(!showData.items || showData.items.length<1){
console.log('[ERROR] Show not found\n');
process.exit(0);
}
showData = showData.items[0];
console.log('[#%s] %s (%s)',showData.id,showData.title,showData.releaseYear);
// show episodes
let qs = { limit: -1, sort: 'order', sort_direction: 'ASC', title_id: parseInt(argv.s, 10) };
if(argv.alt){ qs.language = 'English'; }
let episodesData = await getData({
baseUrl: api_host,
url: '/funimation/episodes/',
querystring: qs,
token: token,
useToken: true,
useProxy: true,
debug: argv.debug,
});
if(!episodesData.ok){return;}
let epsDataArr = JSON.parse(episodesData.res.body).items;
let epNumRegex = /^([A-Z0-9]*[A-Z])?(\d+)$/i;
let epSelEpsTxt = [], epSelList, typeIdLen = 0, epIdLen = 4;
const parseEpStr = (epStr) => {
epStr = epStr.match(epNumRegex);
if(epStr.length > 2){
epStr = [...epStr].splice(1);
epStr[0] = epStr[0] ? epStr[0] : '';
return epStr;
}
else return [ '', epStr[0] ];
};
epsDataArr = epsDataArr.map(e => {
const baseId = e.ids.externalAsianId ? e.ids.externalAsianId : e.ids.externalEpisodeId;
e.id = baseId.replace(new RegExp('^' + e.ids.externalShowId), '');
if(e.id.match(epNumRegex)){
const epMatch = parseEpStr(e.id);
epIdLen = epMatch[1].length > epIdLen ? epMatch[1].length : epIdLen;
typeIdLen = epMatch[0].length > typeIdLen ? epMatch[0].length : typeIdLen;
e.id_split = epMatch;
}
else{
typeIdLen = 3 > typeIdLen? 3 : typeIdLen;
console.log('[ERROR] FAILED TO PARSE: ', e.id);
e.id_split = [ 'ZZZ', 9999 ];
}
return e;
});
epSelList = parseSelect(argv.e);
let fnSlug = [], is_selected = false;
let eps = epsDataArr;
epsDataArr.sort((a, b) => {
if (a.item.seasonOrder < b.item.seasonOrder && a.id < b.id) {
return -1;
}
if (a.item.seasonOrder > b.item.seasonOrder && a.id > b.id) {
return 1;
}
return 0;
});
for(let e in eps){
eps[e].id_split[1] = parseInt(eps[e].id_split[1]).toString().padStart(epIdLen, '0');
let epStrId = eps[e].id_split.join('');
// select
is_selected = false;
if (argv.all || epSelList.isSelected(epStrId)) {
fnSlug.push({title:eps[e].item.titleSlug,episode:eps[e].item.episodeSlug});
epSelEpsTxt.push(epStrId);
is_selected = true;
}
else{
is_selected = false;
}
// console vars
let tx_snum = eps[e].item.seasonNum==1?'':` S${eps[e].item.seasonNum}`;
let tx_type = eps[e].mediaCategory != 'episode' ? eps[e].mediaCategory : '';
let tx_enum = eps[e].item.episodeNum !== '' ?
`#${(eps[e].item.episodeNum < 10 ? '0' : '')+eps[e].item.episodeNum}` : '#'+eps[e].item.episodeId;
let qua_str = eps[e].quality.height ? eps[e].quality.quality + eps[e].quality.height : 'UNK';
let aud_str = eps[e].audio.length > 0 ? `, ${eps[e].audio.join(', ')}` : '';
let rtm_str = eps[e].item.runtime !== '' ? eps[e].item.runtime : '??:??';
// console string
eps[e].id_split[0] = eps[e].id_split[0].padStart(typeIdLen, ' ');
epStrId = eps[e].id_split.join('');
let conOut = `[${epStrId}] `;
conOut += `${eps[e].item.titleName+tx_snum} - ${tx_type+tx_enum} ${eps[e].item.episodeName} `;
conOut += `(${rtm_str}) [${qua_str+aud_str}]`;
conOut += is_selected ? ' (selected)' : '';
conOut += eps.length-1 == e ? '\n' : '';
console.log(conOut);
}
if(fnSlug.length < 1){
console.log('[INFO] Episodes not selected!\n');
process.exit();
}
else{
console.log('[INFO] Selected Episodes: %s\n',epSelEpsTxt.join(', '));
for(let fnEp=0;fnEp<fnSlug.length;fnEp++){
await getEpisode(fnSlug[fnEp]);
}
}
}
async function getEpisode(fnSlug){
let episodeData = await getData({
baseUrl: api_host,
url: `/source/catalog/episode/${fnSlug.title}/${fnSlug.episode}/`,
token: token,
useToken: true,
useProxy: true,
debug: argv.debug,
});
if(!episodeData.ok){return;}
let ep = JSON.parse(episodeData.res.body).items[0], streamIds = [];
// build fn
showTitle = ep.parent.title;
title = ep.title;
season = parseInt(ep.parent.seasonNumber);
if(ep.mediaCategory != 'Episode'){
ep.number = ep.number !== '' ? ep.mediaCategory+ep.number : ep.mediaCategory+'#'+ep.id;
}
fnEpNum = isNaN(parseInt(ep.number)) ? ep.number : parseInt(ep.number);
// is uncut
let uncut = {
Japanese: false,
English: false
};
// end
console.log(
'[INFO] %s - S%sE%s - %s',
ep.parent.title,
(ep.parent.seasonNumber ? ep.parent.seasonNumber : '?'),
(ep.number ? ep.number : '?'),
ep.title
);
console.log('[INFO] Available streams (Non-Encrypted):');
// map medias
let media = ep.media.map(function(m){
if(m.mediaType == 'experience'){
if(m.version.match(/uncut/i)){
uncut[m.language] = true;
}
return {
id: m.id,
language: m.language,
version: m.version,
type: m.experienceType,
subtitles: getSubsUrl(m.mediaChildren),
};
}
else{
return { id: 0, type: '' };
}
});
const dubType = {
'enUS': 'English',
'esLA': 'Spanish (Latin Am)',
'ptBR': 'Portuguese (Brazil)',
'zhMN': 'Chinese (Mandarin, PRC)',
'jaJP': 'Japanese'
};
// select
stDlPath = [];
for(let m of media){
let selected = false;
if(m.id > 0 && m.type == 'Non-Encrypted'){
let dub_type = m.language;
let localSubs = [];
let selUncut = !argv.simul && uncut[dub_type] && m.version.match(/uncut/i)
? true
: (!uncut[dub_type] || argv.simul && m.version.match(/simulcast/i) ? true : false);
for (let curDub of argv.dub) {
if(dub_type == dubType[curDub] && selUncut){
streamIds.push({
id: m.id,
lang: merger.getLanguageCode(curDub, curDub.slice(0, -2))
});
stDlPath.push(...m.subtitles);
localSubs = m.subtitles;
selected = true;
}
}
console.log(`[#${m.id}] ${dub_type} [${m.version}]${(selected?' (selected)':'')}${
localSubs && localSubs.length > 0 && selected ? ` (using ${localSubs.map(a => `'${a.langName}'`).join(', ')} for subtitles)` : ''
}`);
}
}
let already = [];
stDlPath = stDlPath.filter(a => {
if (already.includes(a.language)) {
return false;
} else {
already.push(a.language);
return true;
}
});
if(streamIds.length <1){
console.log('[ERROR] Track not selected\n');
return;
}
else{
tsDlPath = [];
for (let streamId of streamIds) {
let streamData = await getData({
baseUrl: api_host,
url: `/source/catalog/video/${streamId.id}/signed`,
token: token,
dinstid: 'uuid',
useToken: true,
useProxy: true,
debug: argv.debug,
});
if(!streamData.ok){return;}
streamData = JSON.parse(streamData.res.body);
if(streamData.errors){
console.log('[ERROR] Error #%s: %s\n',streamData.errors[0].code,streamData.errors[0].detail);
return;
}
else{
for(let u in streamData.items){
if(streamData.items[u].videoType == 'm3u8'){
tsDlPath.push({
path: streamData.items[u].src,
lang: streamId.lang
});
break;
}
}
}
}
if(tsDlPath.length < 1){
console.log('[ERROR] Unknown error\n');
return;
}
else{
await downloadStreams();
}
}
}
function getSubsUrl(m){
if(argv.nosubs && !argv.sub){
return [];
}
let subLangs = argv.subLang;
const subType = {
'enUS': 'English',
'esLA': 'Spanish (Latin Am)',
'ptBR': 'Portuguese (Brazil)'
};
let subLangAvailable = m.some(a => subLangs.some(subLang => a.ext == 'vtt' && a.language === subType[subLang]));
if (!subLangAvailable) {
subLangs = [ 'enUS' ];
}
let found = [];
for(let i in m){
let fpp = m[i].filePath.split('.');
let fpe = fpp[fpp.length-1];
for (let lang of subLangs) {
if(fpe == 'vtt' && m[i].language === subType[lang]) {
found.push({
path: m[i].filePath,
ext: `.${lang}`,
langName: subType[lang],
language: m[i].languages[0].code || lang.slice(0, 2)
});
}
}
}
return found;
}
async function downloadStreams(){
// req playlist
let purvideo = [];
let puraudio = [];
let audioAndVideo = [];
for (let streamPath of tsDlPath) {
let plQualityReq = await getData({
url: streamPath.path,
useProxy: (argv.ssp ? false : true),
debug: argv.debug,
});
if(!plQualityReq.ok){return;}
let plQualityLinkList = m3u8(plQualityReq.res.body);
let mainServersList = [
'vmfst-api.prd.funimationsvc.com',
'd33et77evd9bgg.cloudfront.net',
'd132fumi6di1wa.cloudfront.net',
'funiprod.akamaized.net',
];
let plServerList = [],
plStreams = {},
plLayersStr = [],
plLayersRes = {},
plMaxLayer = 1,
plNewIds = 1,
plAud = { uri: '' };
// new uris
let vplReg = /streaming_video_(\d+)_(\d+)_(\d+)_index\.m3u8/;
if(plQualityLinkList.playlists[0].uri.match(vplReg)){
let audioKey = Object.keys(plQualityLinkList.mediaGroups.AUDIO).pop();
if(plQualityLinkList.mediaGroups.AUDIO[audioKey]){
let audioData = plQualityLinkList.mediaGroups.AUDIO[audioKey],
audioEl = Object.keys(audioData);
audioData = audioData[audioEl[0]];
plAud = { ...audioData, ...{ langStr: audioEl[0] } };
}
plQualityLinkList.playlists.sort((a, b) => {
let av = parseInt(a.uri.match(vplReg)[3]);
let bv = parseInt(b.uri.match(vplReg)[3]);
if(av > bv){
return 1;
}
if (av < bv) {
return -1;
}
return 0;
});
}
for(let s of plQualityLinkList.playlists){
if(s.uri.match(/_Layer(\d+)\.m3u8/) || s.uri.match(vplReg)){
// set layer and max layer
let plLayerId = 0;
if(s.uri.match(/_Layer(\d+)\.m3u8/)){
plLayerId = parseInt(s.uri.match(/_Layer(\d+)\.m3u8/)[1]);
}
else{
plLayerId = plNewIds, plNewIds++;
}
plMaxLayer = plMaxLayer < plLayerId ? plLayerId : plMaxLayer;
// set urls and servers
let plUrlDl = s.uri;
let plServer = new URL(plUrlDl).host;
if(!plServerList.includes(plServer)){
plServerList.push(plServer);
}
if(!Object.keys(plStreams).includes(plServer)){
plStreams[plServer] = {};
}
if(plStreams[plServer][plLayerId] && plStreams[plServer][plLayerId] != plUrlDl){
console.log(`[WARN] Non duplicate url for ${plServer} detected, please report to developer!`);
}
else{
plStreams[plServer][plLayerId] = plUrlDl;
}
// set plLayersStr
let plResolution = s.attributes.RESOLUTION;
plLayersRes[plLayerId] = plResolution;
let plBandwidth = Math.round(s.attributes.BANDWIDTH/1024);
if(plLayerId<10){
plLayerId = plLayerId.toString().padStart(2,' ');
}
let qualityStrAdd = `${plLayerId}: ${plResolution.width}x${plResolution.height} (${plBandwidth}KiB/s)`;
let qualityStrRegx = new RegExp(qualityStrAdd.replace(/(:|\(|\)|\/)/g,'\\$1'),'m');
let qualityStrMatch = !plLayersStr.join('\r\n').match(qualityStrRegx);
if(qualityStrMatch){
plLayersStr.push(qualityStrAdd);
}
}
else {
console.log(s.uri);
}
}
for(let s of mainServersList){
if(plServerList.includes(s)){
plServerList.splice(plServerList.indexOf(s), 1);
plServerList.unshift(s);
break;
}
}
if(typeof argv.q == 'object' && argv.q.length > 1){
argv.q = argv.q[argv.q.length-1];
}
argv.q = argv.q < 1 || argv.q > plMaxLayer ? plMaxLayer : argv.q;
let plSelectedServer = plServerList[argv.x-1];
let plSelectedList = plStreams[plSelectedServer];
let videoUrl = argv.x < plServerList.length+1 && plSelectedList[argv.q] ? plSelectedList[argv.q] : '';
plLayersStr.sort();
console.log(`[INFO] Servers available:\n\t${plServerList.join('\n\t')}`);
console.log(`[INFO] Available qualities:\n\t${plLayersStr.join('\n\t')}`);
if(videoUrl != ''){
console.log(`[INFO] Selected layer: ${argv.q} (${plLayersRes[argv.q].width}x${plLayersRes[argv.q].height}) @ ${plSelectedServer}`);
console.log('[INFO] Stream URL:',videoUrl);
fnOutput = parseFileName(argv.fileName, title, fnEpNum, showTitle, season, plLayersRes[argv.q].width, plLayersRes[argv.q].height);
if (fnOutput.length < 1)
throw new Error('Invalid path', fnOutput);
console.log(`[INFO] Output filename: ${fnOutput.join(path.sep)}.ts`);
}
else if(argv.x > plServerList.length){
console.log('[ERROR] Server not selected!\n');
return;
}
else{
console.log('[ERROR] Layer not selected!\n');
return;
}
let dlFailed = false;
let dlFailedA = false;
await fs.promises.mkdir(path.join(cfg.dir.content, ...fnOutput.slice(0, -1)), { recursive: true });
video: if (!argv.novids) {
if (plAud.uri && (purvideo.length > 0 || audioAndVideo.length > 0)) {
break video;
} else if (!plAud.uri && (audioAndVideo.some(a => a.lang === streamPath.lang) || puraudio.some(a => a.lang === streamPath.lang))) {
break video;
}
// download video
let reqVideo = await getData({
url: videoUrl,
useProxy: (argv.ssp ? false : true),
debug: argv.debug,
});
if (!reqVideo.ok) { break video; }
let chunkList = m3u8(reqVideo.res.body);
let tsFile = path.join(cfg.dir.content, ...fnOutput.slice(0, -1), `${fnOutput.slice(-1)}.video${(plAud.uri ? '' : '.' + streamPath.lang )}`);
dlFailed = !await downloadFile(tsFile, chunkList);
if (!dlFailed) {
if (plAud.uri) {
purvideo.push({
path: `${tsFile}.ts`,
lang: plAud.language
});
} else {
audioAndVideo.push({
path: `${tsFile}.ts`,
lang: streamPath.lang
});
}
}
}
else{
console.log('[INFO] Skip video downloading...\n');
}
audio: if (!argv.noaudio && plAud.uri) {
// download audio
if (audioAndVideo.some(a => a.lang === plAud.language) || puraudio.some(a => a.lang === plAud.language))
break audio;
let reqAudio = await getData({
url: plAud.uri,
useProxy: (argv.ssp ? false : true),
debug: argv.debug,
});
if (!reqAudio.ok) { return; }
let chunkListA = m3u8(reqAudio.res.body);
let tsFileA = path.join(cfg.dir.content, ...fnOutput.slice(0, -1), `${fnOutput.slice(-1)}.audio.${plAud.language}`);
dlFailedA = !await downloadFile(tsFileA, chunkListA);
if (!dlFailedA)
puraudio.push({
path: `${tsFileA}.ts`,
lang: plAud.language
});
}
}
// add subs
let subsExt = !argv.mp4 || argv.mp4 && argv.ass ? '.ass' : '.srt';
let addSubs = true;
// download subtitles
if(stDlPath.length > 0){
console.log('[INFO] Downloading subtitles...');
for (let subObject of stDlPath) {
let subsSrc = await getData({
url: subObject.path,
useProxy: true,
debug: argv.debug,
});
if(subsSrc.ok){
let assData = vttConvert(subsSrc.res.body, (subsExt == '.srt' ? true : false), subObject.langName, argv.fontSize);
subObject.file = path.join(cfg.dir.content, ...fnOutput.slice(0, -1), `${fnOutput.slice(-1)}.subtitle${subObject.ext}${subsExt}`);
fs.writeFileSync(subObject.file, assData);
}
else{
console.log('[ERROR] Failed to download subtitles!');
addSubs = false;
break;
}
}
if (addSubs)
console.log('[INFO] Subtitles downloaded!');
}
if((puraudio.length < 1 && audioAndVideo.length < 1) || (purvideo.length < 1 && audioAndVideo.length < 1)){
console.log('\n[INFO] Unable to locate a video AND audio file\n');
return;
}
if(argv.skipmux){
console.log('[INFO] Skipping muxing...');
return;
}
// usage
let usableMKVmerge = true;
let usableFFmpeg = true;
// check exec path
let mkvmergebinfile = await lookpath(path.join(cfg.bin.mkvmerge));
let ffmpegbinfile = await lookpath(path.join(cfg.bin.ffmpeg));
// check exec
if( !argv.mp4 && !mkvmergebinfile ){
console.log('[WARN] MKVMerge not found, skip using this...');
usableMKVmerge = false;
}
if( !usableMKVmerge && !ffmpegbinfile || argv.mp4 && !ffmpegbinfile ){
console.log('[WARN] FFmpeg not found, skip using this...');
usableFFmpeg = false;
}
if ( argv.novids ){
console.log('[INFO] Video not downloaded. Skip muxing video.');
}
if(!argv.mp4 && usableMKVmerge){
let ffext = !argv.mp4 ? 'mkv' : 'mp4';
let command = merger.buildCommandMkvMerge(argv.simul, audioAndVideo, purvideo, puraudio, stDlPath, `${path.join(cfg.dir.content,
...fnOutput)}.${ffext}`);
shlp.exec('mkvmerge', `"${mkvmergebinfile}"`, command);
}
else if(usableFFmpeg){
let ffext = !argv.mp4 ? 'mkv' : 'mp4';
let command = merger.buildCommandFFmpeg(argv.simul, audioAndVideo, purvideo, puraudio, stDlPath, `${path.join(cfg.dir.content,
...fnOutput)}.${ffext}`);
shlp.exec('ffmpeg',`"${ffmpegbinfile}"`,command);
}
else{
console.log('\n[INFO] Done!\n');
return;
}
if (argv.nocleanup)
return;
audioAndVideo.concat(puraudio).concat(purvideo).forEach(a => fs.unlinkSync(a.path));
stDlPath.forEach(subObject => fs.unlinkSync(subObject.file));
console.log('\n[INFO] Done!\n');
}
async function downloadFile(filename, chunkList) {
const downloadStatus = await new hlsDownload({
m3u8json: chunkList,
output: `${filename + '.ts'}`,
timeout: argv.timeout,
pcount: argv.partsize
}).download();
return downloadStatus.ok;
}
/**
* @param {string} input
* @param {string} title
* @param {number|string} episode
* @param {string} showTitle
* @param {number} season
* @param {number} width
* @param {number} height
* @returns {Array<string>}
*/
function parseFileName(input, title, episode, showTitle, season, width, height) {
const varRegex = /\${[A-Za-z1-9]+}/g;
const vars = input.match(varRegex);
for (let i = 0; i < vars.length; i++) {
const type = vars[i];
switch (type.slice(2, -1).toLowerCase()) {
case 'title':
input = input.replace(vars[i], title);
break;
case 'episode': {
if (typeof episode === 'number') {
let len = episode.toFixed(0).toString().length;
input = input.replace(vars[i], len < argv.numbers ? '0'.repeat(argv.numbers - len) + episode : episode);
} else {
input = input.replace(vars[i], episode);
}
break;
}
case 'showtitle':
input = input.replace(vars[i], showTitle);
break;
case 'season': {
let len = season.toFixed(0).toString().length;
input = input.replace(vars[i], len < argv.numbers ? '0'.repeat(argv.numbers - len) + season : season);
break;
}
case 'width':
input = input.replace(vars[i], width);
break;
case 'height':
input = input.replace(vars[i], height);
break;
default:
break;
}
}
return input.split(path.sep).map(a => shlp.cleanupFilename(a));
}