diff --git a/config/bin-path.yml b/config/bin-path.yml index 8a8e1a4..039122d 100644 --- a/config/bin-path.yml +++ b/config/bin-path.yml @@ -1,2 +1,2 @@ -ffmpeg: "./bin/ffmpeg" -mkvmerge: "./bin/mkvmerge" +ffmpeg: "C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe" +mkvmerge: "./bin/mkvtoolnix/mkvmerge.exe" diff --git a/config/cli-defaults.yml b/config/cli-defaults.yml index 08a6b4b..e3a894c 100644 --- a/config/cli-defaults.yml +++ b/config/cli-defaults.yml @@ -1,6 +1,5 @@ releaseGroup: Funimation videoLayer: 7 -fileSuffix: SIZEp nServer: 1 mp4mux: false muxSubs: false diff --git a/config/dir-path.yml b/config/dir-path.yml index f9c4877..43961c8 100644 --- a/config/dir-path.yml +++ b/config/dir-path.yml @@ -1,2 +1 @@ content: ./videos/ -trash: ./videos/_trash/ diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0cb42ae..4231816 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,7 @@ ## Change Log +This changelog is out of date and wont be continued. Please see the releases comments, or if not present the commit comments. + ### 4.7.0 (unreleased) - Change subtitles parser from ttml to vtt - Improve help command diff --git a/docs/README.md b/docs/README.md index 9b5e748..aa739da 100644 --- a/docs/README.md +++ b/docs/README.md @@ -42,15 +42,20 @@ After installing NodeJS with NPM go to directory with `package.json` file and ty * `-s -e ` sets the show id and episode ids (comma-separated, hyphen-sequence) * `-q ` sets the video layer quality [1...10] (optional, 0 is max) +* `--all` download all videos at once * `--alt` alternative episode listing (if available) -* `--sub` switch from English dub to Japanese dub with subtitles +* `--sub` select one or more subtile language +* `--dub` select one or more dub languages * `--simul` force select simulcast version instead of uncut version * `-x` select server -* `--novids` skip download videos (for downloading subtitles only) +* `--novids` skip download videos * `--nosubs` skip download subtitles for Dub (if available) +* `--noaudio` skip downloading audio ### Proxy +The proxy is currently unmainted. Use at your on risk. + * `--proxy ` http(s)/socks proxy WHATWG url (ex. https://myproxyhost:1080) * `--proxy-auth ` Colon-separated username and password for proxy * `--ssp` don't use proxy for stream downloading @@ -63,19 +68,17 @@ After installing NodeJS with NPM go to directory with `package.json` file and ty ### Filenaming (optional) -* `-a ` release group ("Funimation" by default) -* `-t ` show title override -* `--ep ` episode number override (ignored in batch mode) -* `--suffix ` filename suffix override (first "SIZEp" will be replaced with actual video size, "SIZEp" by default) +* `--fileName` Set the filename template. Use ${variable_name} to insert variables. + You may use 'title', 'episode', 'showTitle', 'season', 'width', 'height' as variables. ### Utility -* `--nocleanup` move unnecessary files to trash folder after completion instead of deleting +* `--nocleanup` don't delete the input files after the muxing * `-h`, `--help` show all options ## Filename Template -[`release group`] `title` - `episode` [`suffix`].`extension` +[Funimation] ${showTitle} - ${episode} [${height}p]"] ## CLI Examples diff --git a/funi.js b/funi.js index e6b7c7c..8ac0d38 100644 --- a/funi.js +++ b/funi.js @@ -16,23 +16,20 @@ const yaml = require('yaml'); const shlp = require('sei-helper'); const { lookpath } = require('lookpath'); const m3u8 = require('m3u8-parsed'); -const streamdl = require('hls-download'); -const crypto = require("crypto"); +const crypto = require('crypto'); const got = require('got'); // extra -const appYargs = require('./modules/module.app-args'); -const getYamlCfg = require('./modules/module.cfg-loader'); -const getData = require('./modules/module.getdata.js'); -const vttConvert = require('./modules/module.vttconvert'); - +const appYargs = require('./modules/module.app-args'); +const getYamlCfg = require('./modules/module.cfg-loader'); +const getData = require('./modules/module.getdata.js'); +const vttConvert = require('./modules/module.vttconvert'); // new-cfg -const cfgFolder = path.join(__dirname, '/config'); -const binCfgFile = path.join(cfgFolder, 'bin-path'); -const dirCfgFile = path.join(cfgFolder, 'dir-path'); -const cliCfgFile = path.join(cfgFolder, 'cli-defaults'); -const tokenFile = path.join(cfgFolder, 'token'); - +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 let cfg = { bin: getYamlCfg(binCfgFile), @@ -40,9 +37,42 @@ let cfg = { cli: getYamlCfg(cliCfgFile), }; +// make sure cfg params aren't null +if (cfg.bin === null){ + cfg.bin = {}; + console.log('[WARN] bin-path.yml is empty or does not exist!\n'); +} + +if (cfg.dir === null){ + cfg.dir = {}; + console.log('[WARN] dir-path.yml is empty or does not exist!\n'); +} + +if (!Object.prototype.hasOwnProperty.call(cfg.dir, 'content')) cfg.dir.content = './videos/'; + +if (cfg.cli === null){ + cfg.cli = {}; + console.log('[WARN] cli-defaults.yml is empty or does not exist!\n'); +} + +/* Normalise paths for use outside the current directory */ +for (let key of Object.keys(cfg.dir)) { + if (!path.isAbsolute(cfg.dir[key])) { + cfg.dir[key] = path.join(workingDir, cfg.dir[key]); + } +} + +for (let key of Object.keys(cfg.bin)) { + if (!path.isAbsolute(cfg.bin[key])) { + cfg.bin[key] = path.join(workingDir, cfg.bin[key]); + } +} + // token let token = getYamlCfg(tokenFile); -token = token.token ? token.token : false; +if (token === null) token = false; +else if (token.token === null) token = false; +else token = token.token; // info if token not set if(!token){ @@ -51,6 +81,14 @@ if(!token){ // cli const argv = appYargs.appArgv(cfg.cli); +module.exports = { + argv, + cfg +}; + +// Import merger after argv has been exported + +const merger = require('./modules/merger'); // check page if(!isNaN(parseInt(argv.p, 10)) && parseInt(argv.p, 10) > 0){ @@ -61,13 +99,13 @@ else{ } // fn variables -let fnTitle = '', - fnEpNum = '', - fnSuffix = '', +let title = '', + showTitle = '', + fnEpNum = 0, fnOutput = '', - tsDlPath = false, - stDlPath = false, - batchDL = false; + season = 0, + tsDlPath = [], + stDlPath = []; // select mode if(argv.auth){ @@ -214,7 +252,12 @@ async function getShow(){ let showStrId = eps[e].ids.externalShowId; let epStrId = eps[e].ids.externalEpisodeId.replace(new RegExp('^'+showStrId),''); // select - if(epSelList.includes(epStrId.replace(/^(?:([A-Z]+)|)(0+)/,'$1'))){ + if (argv.all) { + fnSlug.push({title:eps[e].item.titleSlug,episode:eps[e].item.episodeSlug}); + epSelEps.push(epStrId); + is_selected = true; + } + else if(epSelList.includes(epStrId.replace(/^(?:([A-Z]+)|)(0+)/,'$1'))){ fnSlug.push({title:eps[e].item.titleSlug,episode:eps[e].item.episodeSlug}); epSelEps.push(epStrId); is_selected = true; @@ -239,9 +282,6 @@ async function getShow(){ conOut += eps.length-1 == e ? '\n' : ''; console.log(conOut); } - if(fnSlug.length>1){ - batchDL = true; - } if(fnSlug.length<1){ console.log('[INFO] Episodes not selected!\n'); process.exit(); @@ -264,14 +304,15 @@ async function getEpisode(fnSlug){ debug: argv.debug, }); if(!episodeData.ok){return;} - let ep = JSON.parse(episodeData.res.body).items[0], streamId = 0; + let ep = JSON.parse(episodeData.res.body).items[0], streamIds = []; // build fn - fnTitle = argv.t ? argv.t : ep.parent.title; - ep.number = isNaN(ep.number) ? ep.number : ( parseInt(ep.number, 10) < 10 ? '0' + ep.number : ep.number ); + 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 = argv.ep && !batchDL ? ( parseInt(argv.ep, 10) < 10 ? '0' + argv.ep : argv.ep ) : ep.number; + fnEpNum = parseInt(ep.number); // is uncut let uncut = { @@ -313,61 +354,80 @@ async function getEpisode(fnSlug){ 'enUS': 'English', 'esLA': 'Spanish (Latin Am)', 'ptBR': 'Portuguese (Brazil)', + 'zhMN': 'Chinese (Mandarin, PRC)', + 'jaJP': 'Japanese' }; // select - media = media.reverse(); 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); - if(dub_type == 'Japanese' && argv.sub && selUncut){ - streamId = m.id; - stDlPath = m.subtitles; - selected = true; + 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; + } } - else if(dub_type == dubType[argv.dub] && !argv.sub && selUncut){ - streamId = m.id; - stDlPath = m.subtitles; - selected = true; - } - console.log(`[#${m.id}] ${dub_type} [${m.version}]`,(selected?'(selected)':'')); + console.log(`[#${m.id}] ${dub_type} [${m.version}]${(selected?' (selected)':'')}${ + localSubs && localSubs.length > 0 && selected ? ` (using ${localSubs.map(a => `'${a.langName}'`).join(', ')} for subtitles)` : '' + }`); } } - - if(streamId<1){ + + 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{ - let streamData = await getData({ - baseUrl: api_host, - url: `/source/catalog/video/${streamId}/signed`, - token: token, - dinstid: 'uuid', - useToken: true, - useProxy: true, - debug: argv.debug, - }); - if(!streamData.ok){return;} - streamData = JSON.parse(streamData.res.body); - tsDlPath = false; - 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 = streamData.items[u].src; - break; + 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){ + if(tsDlPath.length < 1){ console.log('[ERROR] Unknown error\n'); return; } @@ -379,375 +439,283 @@ async function getEpisode(fnSlug){ function getSubsUrl(m){ if(argv.nosubs && !argv.sub){ - return false; + 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]; - if(fpe == 'vtt'){ // dfxp (TTML), srt, vtt - return m[i].filePath; + 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 false; + + return found; } async function downloadStreams(){ // req playlist - let plQualityReq = await getData({ - url: tsDlPath, - 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', - '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)){ - if(plQualityLinkList.mediaGroups.AUDIO['audio-aacl-128']){ - let audioData = plQualityLinkList.mediaGroups.AUDIO['audio-aacl-128']; - 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.height}p`; - plLayersRes[plLayerId] = plResolution; - let plBandwidth = Math.round(s.attributes.BANDWIDTH/1024); - if(plLayerId<10){ - plLayerId = plLayerId.toString().padStart(2,' '); - } - let qualityStrAdd = `${plLayerId}: ${plResolution} (${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]}) @ ${plSelectedServer}`); - console.log('[INFO] Stream URL:',videoUrl); - fnSuffix = argv.suffix.replace('SIZEp',plLayersRes[argv.q]); - fnOutput = shlp.cleanupFilename(`[${argv.a}] ${fnTitle} - ${fnEpNum} [${fnSuffix}]`); - console.log(`[INFO] Output filename: ${fnOutput}.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; - - - if (!argv.novids) { - // download video - let reqVideo = await getData({ - url: videoUrl, + + let purvideo = []; + let puraudio = []; + let audioAndVideo = []; + let outName; + for (let streamPath of tsDlPath) { + let plQualityReq = await getData({ + url: streamPath.path, useProxy: (argv.ssp ? false : true), debug: argv.debug, }); - if (!reqVideo.ok) { return; } + if(!plQualityReq.ok){return;} - let chunkList = m3u8(reqVideo.res.body); - chunkList.baseUrl = videoUrl.split('/').slice(0, -1).join('/') + '/'; + let plQualityLinkList = m3u8(plQualityReq.res.body); - let tsFile = path.join(cfg.dir.content, fnOutput); + let mainServersList = [ + 'vmfst-api.prd.funimationsvc.com', + 'd132fumi6di1wa.cloudfront.net', + 'funiprod.akamaized.net', + ]; - if (chunkList.segments[0].uri.match(/streaming_video_(\d+)_(\d+)_(\d+)\.ts/)) { - if (fs.existsSync(tsFile + '.ts')) { - let rwts = await shlp.question(`[Q] File «${tsFile + '.ts'}» already exists! Rewrite? (y/N)`); - rwts = rwts || 'N'; - if (!['Y', 'y'].includes(rwts[0])) { - return; + 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; } - fs.unlinkSync(tsFile + '.ts') - } - - let chunk = chunkList.segments[0] - - let reqKey = await getData({ - url: chunk.key.uri, - responseType: 'buffer' - }) - if (!reqKey.ok) { return; } - let key = reqKey.res.body; - let iv = Buffer.alloc(16); - let ivs = chunk.key.iv ? chunk.key.iv : [0, 0, 0, 1]; - for (let i in ivs) { - iv.writeUInt32BE(ivs[i], i * 4); - } - key = crypto.createDecipheriv('aes-128-cbc', key, iv); - - let progress, intervall; - - function logInfo() { - if (progress && progress.percent && progress.transferred) - console.log(`[INFO] Downloaded ${progress.percent.toFixed(2) * 100}% (${(progress.transferred/1024).toFixed(0)}kb/${progress.total?(progress.total/1024).toFixed(0) + 'kb':'unknown'})`); - } - - let res = (await got({ - url: chunk.uri, - headers: { - 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64; rv:70.0) Gecko/20100101 Firefox/70.0', - }, - responseType: 'buffer' - }).on("downloadProgress", (pro) => { - progress = pro - if (intervall === undefined) - intervall = setInterval(logInfo, 2500) - }) - .catch(error => console.log(`[ERROR] ${error.name}: ${error.code||error.message}`))) - clearInterval(intervall) - - if (!res.body) { return; } - let dec = key.update(res.body); - dec = Buffer.concat([dec, key.final()]); - fs.writeFileSync(tsFile + '.ts', dec) - - console.log(`[INFO] Finished ${tsFile}`) - } else { - let proxyHLS = false; - if (argv.proxy && !argv.ssp) { - try { - proxyHLS = {}; - proxyHLS.url = buildProxyUrl(argv.proxy, argv['proxy-auth']); + if (av < bv) { + return -1; } - catch(e){ - console.log(`\n[WARN] Not valid proxy URL${e.input?' ('+e.input+')':''}!`); - console.log('[WARN] Skiping...'); - proxyHLS = false; + 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); } } - - let streamdlParams = { - fn: tsFile + '.ts', - m3u8json: chunkList, - baseurl: chunkList.baseUrl, - pcount: 10, - proxy: (proxyHLS ? proxyHLS : false) - }; - - let dldata = await new streamdl(streamdlParams).download(); - if(!dldata.ok){ - fs.writeFileSync(`${tsFile}.ts.resume`, JSON.stringify(dldata.parts)); - console.log(`[ERROR] DL Stats: ${JSON.stringify(dldata.parts)}\n`); - dlFailed = true; - } - else if(fs.existsSync(`${tsFile}.ts.resume`) && dldata.ok){ - fs.unlinkSync(`${tsFile}.ts.resume`); + else { + console.log(s.uri); } } - } - else{ - console.log('[INFO] Skip video downloading...\n'); - } - - if (!argv.novids && plAud.uri) { - // download 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); - chunkListA.baseUrl = plAud.uri.split('/').slice(0, -1).join('/') + '/'; - let tsFileA = path.join(cfg.dir.content, fnOutput + `.${plAud.language}`); - - if (fs.existsSync(tsFileA + '.ts')) { - let rwts = await shlp.question(`[Q] File «${tsFileA + '.ts'}» already exists! Rewrite? (y/N)`); - rwts = rwts || 'N'; - if (!['Y', 'y'].includes(rwts[0])) { - return; + for(let s of mainServersList){ + if(plServerList.includes(s)){ + plServerList.splice(plServerList.indexOf(s), 1); + plServerList.unshift(s); + break; } - fs.unlinkSync(tsFileA + '.ts') } - - let chunk = chunkListA.segments[0] - let reqKey = await getData({ - url: chunk.key.uri, - responseType: 'buffer' - }) - - if (!reqKey.ok) { return; } - let key = reqKey.res.body; - let iv = Buffer.alloc(16); - let ivs = chunk.key.iv ? chunk.key.iv : [0, 0, 0, 1]; - for (let i in ivs) { - iv.writeUInt32BE(ivs[i], i * 4); + if(typeof argv.q == 'object' && argv.q.length > 1){ + argv.q = argv.q[argv.q.length-1]; } - key = crypto.createDecipheriv('aes-128-cbc', key, iv); - let progress, intervall; + argv.q = argv.q < 1 || argv.q > plMaxLayer ? plMaxLayer : argv.q; - function logInfo() { - if (progress && progress.percent && progress.transferred) - console.log(`[INFO] Downloaded ${progress.percent.toFixed(2) * 100}% (${(progress.transferred/1024).toFixed(0)}kb/${progress.total?(progress.total/1024).toFixed(0) + 'kb':'unknown'})`); - } - - let res = (await got({ - url: chunk.uri, - headers: { - 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64; rv:70.0) Gecko/20100101 Firefox/70.0', - }, - responseType: 'buffer' - }).on("downloadProgress", (pro) => { - progress = pro - if (intervall === undefined) - intervall = setInterval(logInfo, 2500) - }) - .catch(error => console.log(`[ERROR] ${error.name}: ${error.code||error.message}`))) - clearInterval(intervall) - if (!res.body) { return; } - let dec = key.update(res.body); - dec = Buffer.concat([dec, key.final()]); - fs.writeFileSync(tsFileA + '.ts', dec) - - console.log(`[INFO] Finished ${tsFileA}`) - } + 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); - // add subs - let subsUrl = stDlPath; - let subsExt = !argv.mp4 || argv.mp4 && !argv.mks && argv.ass ? '.ass' : '.srt'; - let addSubs = argv.mks && subsUrl ? true : false; - - // download subtitles - if(subsUrl){ - console.log('[INFO] Downloading subtitles...'); - console.log(subsUrl); - let subsSrc = await getData({ - url: subsUrl, - useProxy: true, - debug: argv.debug, - }); - if(subsSrc.ok){ - let assData = vttConvert(subsSrc.res.body, (subsExt == '.srt' ? true : false)); - let assFile = path.join(cfg.dir.content, fnOutput) + subsExt; - fs.writeFileSync(assFile, assData); - console.log('[INFO] Subtitles downloaded!'); + fnOutput = parseFileName(argv.fileName, title, fnEpNum, showTitle, season, plLayersRes[argv.q].width, plLayersRes[argv.q].height); + outName = fnOutput; + console.log(`[INFO] Output filename: ${fnOutput}.ts`); + } + else if(argv.x > plServerList.length){ + console.log('[ERROR] Server not selected!\n'); + return; } else{ - console.log('[ERROR] Failed to download subtitles!'); - addSubs = false; + console.log('[ERROR] Layer not selected!\n'); + return; + } + + let dlFailed = false; + let dlFailedA = false; + + 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}.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 + `.audio.${plAud.language}`); + + dlFailedA = !await downloadFile(tsFileA, chunkListA); + if (!dlFailedA) + puraudio.push({ + path: `${tsFileA}.ts`, + lang: plAud.language + }); + } } - if(dlFailed || dlFailedA){ - console.log('\n[INFO] TS file not fully downloaded, skip muxing video...\n'); + // 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}.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; } - - let muxTrg = path.join(cfg.dir.content, fnOutput); - let muxTrgA = ''; - let tshTrg = path.join(cfg.dir.trash, fnOutput); - - if(!fs.existsSync(`${muxTrg}.ts`) || !fs.statSync(`${muxTrg}.ts`).isFile()){ - console.log('\n[INFO] TS file not found, skip muxing video...\n'); - return; - } - - if(plAud.uri){ - muxTrgA = path.join(cfg.dir.content, fnOutput + `.${plAud.language}`); - if(!fs.existsSync(`${muxTrgA}.ts`) || !fs.statSync(`${muxTrgA}.ts`).isFile()){ - console.log('\n[INFO] TS file not found, skip muxing video...\n'); - return; - } - } - + // usage let usableMKVmerge = true; let usableFFmpeg = true; @@ -755,7 +723,7 @@ async function downloadStreams(){ // 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...'); @@ -765,80 +733,141 @@ async function downloadStreams(){ console.log('[WARN] FFmpeg not found, skip using this...'); usableFFmpeg = false; } - - // ftag - argv.ftag = argv.ftag ? argv.ftag : argv.a; - argv.ftag = shlp.cleanupFilename(argv.ftag); - - // select muxer + if ( argv.novids ){ + console.log('[INFO] Video not downloaded. Skip muxing video.'); + } + if(!argv.mp4 && usableMKVmerge){ - // mux to mkv - let mkvmux = []; - mkvmux.push('-o',`${muxTrg}.mkv`); - mkvmux.push('--no-date','--disable-track-statistics-tags','--engage','no_variable_data'); - mkvmux.push('--track-name',`0:[${argv.ftag}]`); - mkvmux.push('--language',`1:${argv.sub?'jpn':''}`); - if(plAud.uri){ - mkvmux.push('--video-tracks','0','--no-audio'); - mkvmux.push('--no-subtitles','--no-attachments'); - mkvmux.push(`${muxTrg}.ts`); - mkvmux.push('--no-video','--audio-tracks','0'); - mkvmux.push('--no-subtitles','--no-attachments'); - mkvmux.push(`${muxTrgA}.ts`); - } - else{ - mkvmux.push('--video-tracks','0','--audio-tracks','1'); - mkvmux.push('--no-subtitles','--no-attachments'); - mkvmux.push(`${muxTrg}.ts`); - } - if(addSubs){ - mkvmux.push('--language','0:eng'); - mkvmux.push(`${muxTrg}${subsExt}`); - } - fs.writeFileSync(`${muxTrg}.json`,JSON.stringify(mkvmux,null,' ')); - shlp.exec('mkvmerge',`"${mkvmergebinfile}"`,`@"${muxTrg}.json"`); - fs.unlinkSync(`${muxTrg}.json`); + let ffext = !argv.mp4 ? 'mkv' : 'mp4'; + let command = merger.buildCommandMkvMerge(audioAndVideo, purvideo, puraudio, stDlPath, `${path.join(cfg.dir.content, outName)}.${ffext}`); + shlp.exec('mkvmerge', `"${mkvmergebinfile}"`, command); } else if(usableFFmpeg){ let ffext = !argv.mp4 ? 'mkv' : 'mp4'; - let ffmux = `-i "${muxTrg}.ts" `; - if(plAud.uri){ - ffmux += `-i "${muxTrgA}.ts" `; - } - ffmux += addSubs ? `-i "${muxTrg}${subsExt}" ` : ''; - ffmux += '-map 0 -c:v copy -c:a copy '; - ffmux += addSubs ? '-map 1 ' : ''; - ffmux += addSubs && !argv.mp4 ? '-c:s ass ' : ''; - ffmux += addSubs && argv.mp4 ? '-c:s mov_text ' : ''; - ffmux += '-metadata encoding_tool="no_variable_data" '; - ffmux += `-metadata:s:v:0 title="[${argv.a}]" -metadata:s:a:0 language=${argv.sub?'jpn':''} `; - ffmux += addSubs ? '-metadata:s:s:0 language=eng ' : ''; - ffmux += `"${muxTrg}.${ffext}"`; - // mux to mkv - shlp.exec('ffmpeg',`"${ffmpegbinfile}"`,ffmux); + let command = merger.buildCommandFFmpeg(audioAndVideo, purvideo, puraudio, stDlPath, `${path.join(cfg.dir.content, outName)}.${ffext}`); + shlp.exec('ffmpeg',`"${ffmpegbinfile}"`,command); } else{ console.log('\n[INFO] Done!\n'); return; } - if(argv.notrashfolder && argv.nocleanup){ - // don't move or delete temp files - } - else if(argv.nocleanup){ - fs.renameSync(muxTrg+'.ts', tshTrg + '.ts'); - if(subsUrl && addSubs){ - fs.renameSync(muxTrg +subsExt, tshTrg +subsExt); - } - } - else{ - fs.unlinkSync(muxTrg+'.ts'); - if(subsUrl && addSubs){ - fs.unlinkSync(muxTrg +subsExt); - } - } + 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) { + let offset = 0; + fileCheck: if (fs.existsSync(filename + '.ts')) { + if (fs.existsSync(filename + '.ts.resume')) { + const resume = JSON.parse(fs.readFileSync(`${filename}.ts.resume`)); + if (resume.total === chunkList.segments.length) { + offset = resume.downloaded; + break fileCheck; + } + } + let rwts = await shlp.question(`[Q] File «${filename + '.ts'}» already exists! Rewrite? (y/N)`); + rwts = rwts || 'N'; + if (!['Y', 'y'].includes(rwts[0])) { + return false; + } + fs.unlinkSync(filename + '.ts'); + } + + let start = Date.now(); + + console.log(`[INFO] Started ${filename}.ts`); + for (let i = offset; i < chunkList.segments.length; i+=argv.partsize) { + let cur = []; + for (let a = 0; a < Math.min(argv.partsize, chunkList.segments.length - i); a++) { + cur.push(downloadPart(chunkList.segments[i + a], i + a, chunkList.segments.length) + .catch(e => e)); + } + + let p = await Promise.all(cur); + if (p.some(el => el instanceof Error)) { + console.log(`[ERROR] An error occured while downloading ${filename}.ts`); + return false; + } + + fs.writeFileSync(`${filename}.ts.resume`, JSON.stringify({ total: chunkList.segments.length, downloaded: i + argv.partsize }, null, 4)); + + for (let a = 0; a < p.length; a++) { + fs.writeFileSync(filename + '.ts', p[a].content, { flag: 'a' }); + } + + logDownloadInfo(start, i + Math.min(argv.partsize, chunkList.segments.length - i), + chunkList.segments.length, i + Math.min(argv.partsize, chunkList.segments.length - i), + chunkList.segments.length); + } + + if (fs.existsSync(`${filename}.ts.resume`)) + fs.unlinkSync(`${filename}.ts.resume`); + + console.log(`[INFO] Finished ${filename}.ts`); + return true; +} + +async function downloadPart(chunk, index) { + + let key = await generateCrypto(chunk, index); + + let headers = { + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64; rv:70.0) Gecko/20100101 Firefox/70.0' + }; + + if (chunk.byterange) + headers.Range = `bytes=${chunk.byterange.offset}-${chunk.byterange.offset+chunk.byterange.length-1}`; + + let res = (await got({ + url: chunk.uri, + headers, + responseType: 'buffer' + }).catch(error => console.log(`[ERROR] ${error.name}: ${error.code||error.message}`))); + + if (!res.body) { return new Error('Invalid State'); } + try { + let dec = key.update(res.body); + dec = Buffer.concat([dec, key.final()]); + return { content: dec, index: index}; + } catch (e) { return e; } +} + +let keys = {}; +async function generateCrypto(chunk, index) { + let key = keys[chunk.key.uri]; + if (!key) { + let reqKey = await getData({ + url: chunk.key.uri, + responseType: 'buffer' + }); + + if (!reqKey.ok) { console.log('[ERROR] Can\'t get key'); return; } + key = reqKey.res.body; + keys[chunk.key.uri] = key; + } + let iv = Buffer.alloc(16); + let ivs = chunk.key.iv ? chunk.key.iv : [0, 0, 0, index]; + for (let i in ivs) { + iv.writeUInt32BE(ivs[i], i * 4); + } + key = crypto.createDecipheriv('aes-128-cbc', key, iv); + return key; +} + +/* Snacked from hls-download */ +function logDownloadInfo (dateStart, partsDL, partsTotal, partsDLRes, partsTotalRes) { + const dateElapsed = Date.now() - dateStart; + const percentFxd = (partsDL / partsTotal * 100).toFixed(); + const percent = percentFxd < 100 ? percentFxd : (partsTotal == partsDL ? 100 : 99); + const revParts = parseInt(dateElapsed * (partsTotal / partsDL - 1)); + const time = shlp.formatTime((revParts / 1000).toFixed()); + console.log(`[INFO] ${partsDLRes} of ${partsTotalRes} parts downloaded [${percent}%] (${time})`); +} + // make proxy URL function buildProxy(proxyBaseUrl, proxyAuth){ if(!proxyBaseUrl.match(/^(https?|socks4|socks5):/)){ @@ -869,3 +898,48 @@ function buildProxy(proxyBaseUrl, proxyAuth){ return proxyStr; } + +/** + * @param {string} input + * @param {string} title + * @param {number} episode + * @param {string} showTitle + * @param {number} season + * @param {number} width + * @param {number} height + * @returns {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': { + let len = episode.toFixed(0).toString().length; + input = input.replace(vars[i], len < argv.numbers ? '0'.repeat(argv.numbers - len) + episode : 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 shlp.cleanupFilename(input); +} diff --git a/modules/build.js b/modules/build.js index 3a1113c..5670c2e 100644 --- a/modules/build.js +++ b/modules/build.js @@ -44,7 +44,6 @@ const nodeVer = 'node14-'; fs.mkdirSync(`${buildDir}/bin`); fs.mkdirSync(`${buildDir}/config`); fs.mkdirSync(`${buildDir}/videos`); - fs.mkdirSync(`${buildDir}/videos/_trash`); fs.copySync('./bin/', `${buildDir}/bin/`); fs.copySync('./config/bin-path.yml', `${buildDir}/config/bin-path.yml`); fs.copySync('./config/cli-defaults.yml', `${buildDir}/config/cli-defaults.yml`); diff --git a/modules/merger.js b/modules/merger.js new file mode 100644 index 0000000..a111545 --- /dev/null +++ b/modules/merger.js @@ -0,0 +1,155 @@ +const iso639 = require('iso-639'); +const argv = require('../funi').argv; + +/** + * @param {Array} videoAndAudio + * @param {Array} onlyVid + * @param {Array} onlyAudio + * @param {Array} subtitles + * @param {string} output + * @returns {string} + */ +const buildCommandFFmpeg = (videoAndAudio, onlyVid, onlyAudio, subtitles, output) => { + let args = []; + let metaData = []; + + let index = 0; + let hasVideo = false; + for (let vid of videoAndAudio) { + args.push(`-i "${vid.path}"`); + if (!hasVideo) { + metaData.push(`-map ${index}`); + metaData.push(`-metadata:s:a:${index} language=${getLanguageCode(vid.lang, vid.lang)}`); + metaData.push(`-metadata:s:v:${index} title="[Funimation]"`); + hasVideo = true; + } else { + metaData.push(`-map ${index}:a`); + metaData.push(`-metadata:s:a:${index} language=${getLanguageCode(vid.lang, vid.lang)}`); + } + index++; + } + + for (let vid of onlyVid) { + if (!hasVideo) { + args.push(`-i "${vid.path}"`); + metaData.push(`-map ${index}`); + metaData.push(`-metadata:s:a:${index} language=${getLanguageCode(vid.lang, vid.lang)}`); + metaData.push(`-metadata:s:v:${index} title="[Funimation]"`); + hasVideo = true; + index++; + } + } + + for (let aud of onlyAudio) { + args.push(`-i "${aud.path}"`); + metaData.push(`-map ${index}`); + metaData.push(`-metadata:s:a:${index} language=${getLanguageCode(aud.lang, aud.lang)}`); + index++; + } + + for (let index in subtitles) { + let sub = subtitles[index]; + args.push(`-i "${sub.file}"`); + } + + args.push(...metaData); + args.push(...subtitles.map((_, subIndex) => `-map ${subIndex + index}`)); + args.push( + '-c:v copy', + '-c:a copy' + ); + args.push(output.split('.').pop().toLowerCase() === 'mp4' ? '-c:s mov_text' : '-c:s ass'); + args.push(...subtitles.map((sub, index) => `-metadata:s:${index + 2} language=${getLanguageCode(sub.language)}`)); + args.push(`"${output}"`); + return args.join(' '); +}; + +/** + * @param {string} videoFile + * @param {object} audioFile + * @param {Array} subtitles + * @returns {string} + */ +const buildCommandMkvMerge = (videoAndAudio, onlyVid, onlyAudio, subtitles, output) => { + let args = []; + + let hasVideo = false; + + args.push(`-o "${output}"`); + args.push( + '--no-date', + '--disable-track-statistics-tags', + '--engage no_variable_data', + ); + + for (let vid of onlyVid) { + if (!hasVideo) { + args.push( + '--video-tracks 0', + '--no-audio' + ); + args.push('--track-name 0:[Funimation]'); + hasVideo = true; + args.push(`"${vid.path}"`); + } + } + + for (let vid of videoAndAudio) { + console.log(vid, vid.lang); + if (!hasVideo) { + args.push( + '--video-tracks 0', + '--audio-tracks 1' + ); + args.push('--track-name 0:[Funimation]'); + args.push(`--language 1:${getLanguageCode(vid.lang, vid.lang)}`); + hasVideo = true; + } else { + args.push( + '--no-video', + '--audio-tracks 1' + ); + args.push(`--language 1:${getLanguageCode(vid.lang, vid.lang)}`); + } + args.push(`"${vid.path}"`); + } + + for (let aud of onlyAudio) { + args.push(`--language 0:${getLanguageCode(aud.lang, aud.lang)}`); + args.push( + '--no-video', + '--audio-tracks 0' + ); + args.push(`"${aud.path}"`); + } + + if(subtitles.length > 0){ + for (let subObj of subtitles) { + args.push('--language',`0:${getLanguageCode(subObj.language)}`); + args.push(`"${subObj.file}"`); + } + } else { + args.push( + '--no-subtitles', + '--no-attachments' + ); + } + + return args.join(' '); +}; + +const getLanguageCode = (from, _default = 'eng') => { + for (let lang in iso639.iso_639_2) { + let langObj = iso639.iso_639_2[lang]; + if (Object.prototype.hasOwnProperty.call(langObj, '639-1') && langObj['639-1'] === from) { + return langObj['639-2']; + } + } + return _default; +}; + +module.exports = { + buildCommandFFmpeg, + getLanguageCode, + buildCommandMkvMerge +}; \ No newline at end of file diff --git a/modules/module.app-args.js b/modules/module.app-args.js index 16abd6a..8f11149 100644 --- a/modules/module.app-args.js +++ b/modules/module.app-args.js @@ -1,193 +1,214 @@ const yargs = require('yargs'); -const appArgv = (cfg, langsData) => { +const availableFilenameVars = [ + 'title', + 'episode', + 'showTitle', + 'season', + 'width', + 'height' +]; + +const appArgv = (cfg) => { // init - return yargs.parserConfiguration({ - "duplicate-arguments-array": false, + const argv = yargs.parserConfiguration({ + 'duplicate-arguments-array': true, + 'camel-case-expansion': false }) // main - .wrap(Math.min(120)) // yargs.terminalWidth() - .help(false).version(false) - .usage('Usage: $0 [options]') + .wrap(Math.min(120)) // yargs.terminalWidth() + .help(false).version(false) + .usage('Usage: $0 [options]') // auth - .option('auth', { - group: 'Authentication:', - describe: 'Enter authentication mode', - type: 'boolean', - }) + .option('auth', { + group: 'Authentication:', + describe: 'Enter authentication mode', + type: 'boolean', + }) // search - .option('search', { - alias: 'f', - group: 'Search:', - describe: 'Search show ids', - type: 'string', - }) + .option('search', { + alias: 'f', + group: 'Search:', + describe: 'Search show ids', + type: 'string', + }) // select show and eps - .option('s', { - group: 'Downloading:', - describe: 'Sets the show id', - type: 'number', - }) - .option('e', { - group: 'Downloading:', - describe: 'Select episode ids (comma-separated, hyphen-sequence)', - type: 'string', - }) + .option('s', { + group: 'Downloading:', + describe: 'Sets the show id', + type: 'number', + }) + .option('e', { + group: 'Downloading:', + describe: 'Select episode ids (comma-separated, hyphen-sequence)', + type: 'string', + }) + .option('all', { + group: 'Downloading:', + describe: 'Used to download all episodes from the show', + type: 'boolean', + default: cfg.all || false + }) + .option('partsize', { + group: 'Downloading:', + describe: 'The amount of parts that should be downloaded in paralell', + type: 'number', + default: cfg.partsize || 10 + }) // quality - .option('q', { - group: 'Downloading:', - describe: 'Select video layer (0 is max)', - choices: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], - default: cfg.videoLayer || 7, - type: 'number', - }) + .option('q', { + group: 'Downloading:', + describe: 'Select video layer (0 is max)', + choices: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + default: cfg.videoLayer || 7, + type: 'number', + }) // alt listing - .option('alt', { - group: 'Downloading:', - describe: 'Alternative episode listing (if available)', - default: cfg.altList || false, - type: 'boolean', - }) + .option('alt', { + group: 'Downloading:', + describe: 'Alternative episode listing (if available)', + default: cfg.altList || false, + type: 'boolean', + }) // switch to subs - .option('dub', { - group: 'Downloading:', - describe: 'Download non-Japanese Dub (English Dub mode by default)', - choices: [ 'enUS', 'esLA', 'ptBR' ], - default: cfg.dub || 'enUS', - type: 'string', - }) - .option('sub', { - group: 'Downloading:', - describe: 'Japanese Dub with subtitles mode (English Dub mode by default)', - default: cfg.subsMode || false, - type: 'boolean', - }) + .option('dub', { + group: 'Downloading:', + describe: 'Download non-Japanese Dub (English Dub mode by default)', + choices: [ 'enUS', 'esLA', 'ptBR', 'zhMN', 'jaJP' ], + default: cfg.dub || 'enUS', + type: 'array', + }) + .option('subLang', { + group: 'Downloading:', + describe: 'Set the subtitle language (English is default and fallback)', + default: cfg.subLang || 'enUS', + choices: [ 'enUS', 'esLA', 'ptBR' ], + type: 'array' + }) + .option('fontSize', { + group: 'Downloading:', + describe: 'Used to set the fontsize of the subtitles', + default: cfg.fontSize || 55, + type: 'number' + }) // simulcast - .option('simul', { - group: 'Downloading:', - describe: 'Force downloading simulcast ver. instead of uncut ver. (if uncut ver. available)', - default: cfg.forceSimul || false, - type: 'boolean', - }) + .option('simul', { + group: 'Downloading:', + describe: 'Force downloading simulcast ver. instead of uncut ver. (if uncut ver. available)', + default: cfg.forceSimul || false, + type: 'boolean', + }) // server number - .option('x', { - alias: 'server', - group: 'Downloading:', - describe: 'Select server', - choices: [1, 2, 3, 4], - default: cfg.nServer || 1, - type: 'number', - }) + .option('x', { + alias: 'server', + group: 'Downloading:', + describe: 'Select server', + choices: [1, 2, 3, 4], + default: cfg.nServer || 1, + type: 'number', + }) // skip - .option('novids', { - group: 'Downloading:', - alias: 'skipdl', - describe: 'Skip downloading video (for downloading subtitles only)', - type: 'boolean', - }) - .option('nosubs', { - group: 'Downloading:', - describe: 'Skip downloading subtitles for English Dub (if available)', - type: 'boolean', - }) + .option('noaudio', { + group: 'Downloading:', + describe: 'Skip downloading audio', + type: 'boolean' + }) + .option('novids', { + group: 'Downloading:', + alias: 'skipdl', + describe: 'Skip downloading video', + type: 'boolean', + }) + .option('nosubs', { + group: 'Downloading:', + describe: 'Skip downloading subtitles for English Dub (if available)', + type: 'boolean', + default: false + }) // proxy - .option('proxy', { - group: 'Proxy:', - describe: 'Set http(s)/socks proxy WHATWG url', - default: cfg.proxy || false, - hidden: true, - }) - .option('proxy-auth', { - group: 'Proxy:', - describe: 'Colon-separated username and password for proxy', - default: cfg.proxy_auth || false, - hidden: true, - }) - .option('ssp', { - group: 'Proxy:', - describe: 'Don\'t use proxy for stream and subtitles downloading', - default: cfg.proxy_ssp || false, - hidden: true, - type: 'boolean', - }) + .option('proxy', { + group: 'Proxy:', + describe: 'Set http(s)/socks proxy WHATWG url', + default: cfg.proxy || false, + hidden: true, + }) + .option('proxy-auth', { + group: 'Proxy:', + describe: 'Colon-separated username and password for proxy', + default: cfg.proxy_auth || false, + hidden: true, + }) + .option('ssp', { + group: 'Proxy:', + describe: 'Don\'t use proxy for stream and subtitles downloading', + default: cfg.proxy_ssp || false, + hidden: true, + type: 'boolean', + }) // muxing - .option('skipmux', { - group: 'Muxing:', - describe: 'Skip muxing video and subtitles', - type: 'boolean', - }) - .option('mp4', { - group: 'Muxing:', - describe: 'Mux into mp4', - default: cfg.mp4mux || false, - type: 'boolean' - }) - .option('mks', { - group: 'Muxing:', - describe: 'Add subtitles to mkv/mp4 (if available)', - default: cfg.muxSubs || false, - type: 'boolean' - }) + .option('skipmux', { + group: 'Muxing:', + describe: 'Skip muxing video and subtitles', + type: 'boolean', + }) + .option('mp4', { + group: 'Muxing:', + describe: 'Mux into mp4', + default: cfg.mp4mux || false, + type: 'boolean' + }) // filenaming - .option('a', { - alias: 'grouptag', - group: 'Filename Template:', - describe: 'Release group', - default: cfg.releaseGroup || 'Funimation', - type: 'string' - }) - .option('t', { - alias: 'title', - group: 'Filename Template:', - describe: 'Series title override', - type: 'string' - }) - .option('ep', { - group: 'Filename Template:', - describe: 'Episode number override (ignored in batch mode)', - type: 'string' - }) - .option('suffix', { - group: 'Filename Template:', - describe: 'Filename suffix override (first "SIZEp" will be replaced with actual video size)', - default: cfg.fileSuffix || 'SIZEp', - type: 'string' - }) + .option('fileName', { + group: 'Filename Template:', + describe: `Set the filename template. Use \${variable_name} to insert variables.\nYou may use ${availableFilenameVars + .map(a => `'${a}'`).join(', ')} as variables.`, + type: 'string', + default: cfg.fileName || '[Funimation] ${showTitle} - ${episode} [${height}p]' + }) + .option('numbers', { + group: 'Filename Template:', + describe: `Set how long a number in the title should be at least.\n${[[3, 5, '005'], [2, 1, '01'], [1, 20, '20']] + .map(val => `Set in config: ${val[0]}; Episode number: ${val[1]}; Output: ${val[2]}`).join('\n')}`, + type: 'number', + default: cfg.numbers || 2 + }) // util - .option('nocleanup', { - group: 'Utilities:', - describe: 'Move temporary files to trash folder instead of deleting', - default: cfg.noCleanUp || false, - type: 'boolean' - }) - .option('notrashfolder', { - implies: ['nocleanup'], - group: 'Utilities:', - describe: 'Don\'t move temporary files to trash folder (Used with --nocleanup)', - default: cfg.noTrashFolder || false, - type: 'boolean' - }) + .option('nocleanup', { + group: 'Utilities:', + describe: 'Dont\'t delete the input files after muxing', + default: cfg.noCleanUp || false, + type: 'boolean' + }) // help - .option('help', { - alias: 'h', - group: 'Help:', - describe: 'Show this help', - type: 'boolean' - }) + .option('help', { + alias: 'h', + group: 'Help:', + describe: 'Show this help', + type: 'boolean' + }) // usage - .example([ - ['$0 --search "My Hero"', 'search "My Hero" in title'], - ['$0 -s 124389 -e 1,2,3', 'download episodes 1-3 from show with id 124389'], - ['$0 -s 124389 -e 1-3,2-7,s1-2', 'download episodes 1-7 and "S"-episodes 1-2 from show with id 124389'], - ]) + .example([ + ['$0 --search "My Hero"', 'search "My Hero" in title'], + ['$0 -s 124389 -e 1,2,3', 'download episodes 1-3 from show with id 124389'], + ['$0 -s 124389 -e 1-3,2-7,s1-2', 'download episodes 1-7 and "S"-episodes 1-2 from show with id 124389'], + ]) // -- - .argv; -} + .argv; + + // Resolve unwanted arrays + for (let key in argv) { + if (argv[key] instanceof Array && !(key === 'subLang' || key === 'dub')) { + argv[key] = argv[key].pop(); + } + } + return argv; +}; const showHelp = yargs.showHelp; module.exports = { appArgv, - showHelp + showHelp, + availableFilenameVars }; diff --git a/modules/module.cfg-loader.js b/modules/module.cfg-loader.js index e85241b..3b5d207 100644 --- a/modules/module.cfg-loader.js +++ b/modules/module.cfg-loader.js @@ -4,7 +4,7 @@ const existsFile = fs.existsSync; const loadYamlFile = (file) => { return yaml.parse(fs.readFileSync(file, 'utf8')); -} +}; const loadYamlCfg = (file) => { if(existsFile(`${file}.user.yml`)){ @@ -21,6 +21,6 @@ const loadYamlCfg = (file) => { } } return {}; -} +}; module.exports = loadYamlCfg; diff --git a/modules/module.getdata.js b/modules/module.getdata.js index dde50e4..fa65fef 100644 --- a/modules/module.getdata.js +++ b/modules/module.getdata.js @@ -10,7 +10,7 @@ const getData = async (options) => { } }; if(options.responseType) { - gOptions.responseType = options.responseType + gOptions.responseType = options.responseType; } if(options.baseUrl){ gOptions.prefixUrl = options.baseUrl; @@ -71,6 +71,6 @@ const getData = async (options) => { error, }; } -} +}; module.exports = getData; diff --git a/modules/module.vttconvert.js b/modules/module.vttconvert.js index ae6c437..dcdfba9 100644 --- a/modules/module.vttconvert.js +++ b/modules/module.vttconvert.js @@ -35,10 +35,10 @@ function loadVtt(vttStr) { } // ass specific -function convertToAss(vttStr){ +function convertToAss(vttStr, lang, fontSize){ let ass = [ '\ufeff[Script Info]', - 'Title: English', + `Title: ${lang}`, 'ScriptType: v4.00+', 'PlayResX: 1280', 'PlayResY: 720', @@ -49,8 +49,8 @@ function convertToAss(vttStr){ 'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, ' + 'Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, ' + 'BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding', - 'Style: Main,Noto Sans,55,&H00FFFFFF,&H000000FF,&H00020713,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,10,1', - 'Style: MainTop,Noto Sans,55,&H00FFFFFF,&H000000FF,&H00020713,&H00000000,0,0,0,0,100,100,0,0,1,3,0,8,10,10,10,1', + `Style: Main,Noto Sans,${fontSize},&H00FFFFFF,&H000000FF,&H00020713,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,10,1`, + `Style: MainTop,Noto Sans,${fontSize},&H00FFFFFF,&H000000FF,&H00020713,&H00000000,0,0,0,0,100,100,0,0,1,3,0,8,10,10,10`, '', '[Events]', 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', @@ -141,7 +141,7 @@ function toSubsTime(str, srtFormat) { let hLen = srtFormat ? 2 : 1; x[3] = '0.' + ('' + x[3]).padStart(3, '0'); - sx = x[0]*60*60 + x[1]*60 + x[2] + Number(x[3]) + sx = x[0]*60*60 + x[1]*60 + x[2] + Number(x[3]); sx = sx.toFixed(msLen).split('.'); @@ -160,7 +160,7 @@ function padTimeNum(sep, input, pad){ } // export module -module.exports = (vttStr, toSrt) => { +module.exports = (vttStr, toSrt, lang = 'English', fontSize) => { const convert = toSrt ? convertToSrt : convertToAss; - return convert(vttStr); + return convert(vttStr, lang, fontSize); }; diff --git a/package.json b/package.json index 1044f72..f2d1d68 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "funimation-downloader-nx", "short_name": "funi", - "version": "4.8.0-beta.1", + "version": "4.9.1", "description": "Download videos from Funimation via cli.", "keywords": [ "download", @@ -12,21 +12,21 @@ "utility", "cli" ], - "author": "AniDL", - "homepage": "https://github.com/anidl/funimation-downloader-nx", + "author": "AniDL/Izu-co", + "homepage": "https://github.com/izu-co/funimation-downloader-nx", "repository": { "type": "git", - "url": "https://github.com/anidl/funimation-downloader-nx.git" + "url": "https://github.com/izu-co/funimation-downloader-nx.git" }, "bugs": { - "url": "https://github.com/anidl/funimation-downloader-nx/issues" + "url": "https://github.com/izu-co/funimation-downloader-nx/issues" }, "license": "MIT", "main": "funi.js", "dependencies": { "form-data": "^3.0.0", "got": "^11.7.0", - "hls-download": "^2.5.3", + "iso-639": "^0.2.2", "lookpath": "^1.1.0", "m3u8-parsed": "^1.3.0", "sei-helper": "^3.3.0", diff --git a/videos/_trash/.gitkeep b/videos/_trash/.gitkeep deleted file mode 100644 index e69de29..0000000