commit 0d8ef58d9805ebe8e7885f9374963b6f9237090c Author: AniDL Date: Tue Jan 21 01:43:01 2020 +0300 4.5.0 diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 0000000..eec2e96 --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,27 @@ +env: + commonjs: true + es6: true + node: true +extends: 'eslint:recommended' +globals: + Atomics: readonly + SharedArrayBuffer: readonly +parserOptions: + ecmaVersion: 2018 +rules: + no-empty: + - error + - { "allowEmptyCatch": true } + indent: + - error + - 4 + - { "SwitchCase": 1 } + linebreak-style: + - error + - windows + quotes: + - error + - single + semi: + - error + - always diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..0fcac98 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,18 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG] " +labels: bug +assignees: '' + +--- + + + +** Main info: ** + +Script version: +Show ID: +Episode ID: + +** Additional Info: ** diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..63db8fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/node_modules/ +.DS_Store +ffmpeg +mkvmerge +token.yml +package-lock.json +*.exe +*.dll +*.ts +*.mkv +*.mp4 +*.srt diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..256c27e --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 AniDL + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/bin/.gitkeep b/bin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cmd-here.bat b/cmd-here.bat new file mode 100644 index 0000000..a73cd47 --- /dev/null +++ b/cmd-here.bat @@ -0,0 +1,3 @@ +@echo off +title CmdHere +cmd /k PROMPT @$S$P$_$_$G$S diff --git a/config/bin-path.yml b/config/bin-path.yml new file mode 100644 index 0000000..267995c --- /dev/null +++ b/config/bin-path.yml @@ -0,0 +1,2 @@ +ffmpeg: ./bin/ffmpeg +mkvmerge: ./bin/mkvmerge diff --git a/config/cli-defaults.yml b/config/cli-defaults.yml new file mode 100644 index 0000000..08a6b4b --- /dev/null +++ b/config/cli-defaults.yml @@ -0,0 +1,7 @@ +releaseGroup: Funimation +videoLayer: 7 +fileSuffix: SIZEp +nServer: 1 +mp4mux: false +muxSubs: false +noCleanUp: false diff --git a/config/dir-path.yml b/config/dir-path.yml new file mode 100644 index 0000000..f9c4877 --- /dev/null +++ b/config/dir-path.yml @@ -0,0 +1,2 @@ +content: ./videos/ +trash: ./videos/_trash/ diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..328a467 --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,169 @@ +## Change Log + +### 4.5.0 (2020/01/21) +- Resume downloading +- Known bug: Proxy not working + +### 4.4.2 (2019/07/21) +- Better proxy handling for stream download + +### 4.4.1 (2019/07/21) +- Fixed proxy for stream download + +### 4.4.0 (2019/06/04) +- Added `--novids` option (Thanks to @subdiox) +- Update modules + +### 4.3.2 (2019/05/09) +- Code improvements +- Fix `hls-download` error printing + +### 4.3.1 (2019/05/09) +- Fix auto detection max quality (Regression in d7d280c) + +### 4.3.0 (2019/05/09) +- Better server selection (Closes #42) + +### 4.2.1 (2019/05/04) +- Filter duplicate urls for cloudfront.net (Closes #40) + +### 4.2.0 (2019/05/02) +- Replace `request` module with `got` +- Changed proxy cli options +- Changed `login` option name to `auth` +- Changed `hls-download` parallel download configuration from 5 parts to 10 +- Update modules + +### 4.1.0 (2019/04/05) +- CLI options for login moved to CUI +- Removed showing set token at startup + +### 4.0.5 (2019/02/09) +- Fix downloading shows with autoselect max quality + +### 4.0.4 (2019/01/26) +- Fix search when shows not found +- Update modules + +### 4.0.3 (2018/12/06) +- Select only non-encrypted (HLS) streams, encrypted streams is MPEG-DASH + +### 4.0.2 (2018/11/25) +- Fix typos and update modules + +### 4.0.1 (2018/11/23) +- Code refactoring and small fixes + +### 4.0.0 RC 1 (2018/11/17) +- Select range of episodes using hyphen-sequence +- Skip muxing if executables not found +- Fixed typos and duplicate options + +### 4.0.0 Beta 2 (2018/11/12) +- Select alternative server +- Updated readme + +### 4.0.0 Beta 1 (2018/11/10) +- Rearrange folders structure +- Configuration changed to yaml format +- Muxing changed to MKV by default +- tsMuxeR+mp4box replaced with FFMPEG +- Updated commands help and readme +- Fixed typos and duplicate options +- `ttml2srt` moved to separate module +- Drop `m3u8-stream-list` module +- Code improvements + +### 3.2.8 (2018/06/16) +- Fix video request when token not specified + +### 3.2.7 (2018/06/15) +- Update modules + +### 3.2.6 (2018/02/18) +- Fix commands help + +### 3.2.5 (2018/02/12) +- Fixes and update modules + +### 3.2.4 (2018/02/01) +- Update modules + +### 3.2.3 (2018/01/31) +- Rearrange folders structure + +### 3.2.2 (2018/01/16) +- Update modules + +### 3.2.1 (2018/01/16) +- Update modules +- Small fixes + +### 3.2.0 (2018/01/16) +- `hls-download` module moved to independent module +- Auth for socks proxy + +### 3.1.0 (2017/12/30) +- Convert DXFP (TTML) subtitles to SRT format + +### 3.0.1 (2017/12/05) +- Check subtitles availability +- Download subtitles in SRT format instead of VTT +- Extended hls download progress info + +### 3.0.0 Beta 3 (2017/12/03) +- Restored MKV and MP4 muxing +- Convert VTT subtitles to SRT format + +### 3.0.0 Beta 2 (2017/10/18) +- Fix video downloading + +### 3.0.0 Beta 1 (2017/10/17) +- Major code changes and improvements +- Drop Streamlink and added own module for hls download + +### 2.5.0 (2017/09/04) +- `nosubs` option +- Request video with app api + +### 2.4.1 (2017/09/02) +- Fixed typo in package.json +- Fix #11: URL for getting video stream url was changed + +### 2.4.0 (2017/07/04) +- IPv4 Socks5 proxy support + +### 2.3.3 (2017/06/19) +- Removed forgotten debug code + +### 2.3.2 (2017/06/19) +- Fix #5: Script fails to multiplex unique file names + +### 2.3.1 (2017/04/29) +- Code improvements + +### 2.3.0 (2017/04/27) +- Code improvements + +### 2.2.5 (2017/04/17) +- Minor code improvements and fixes + +### 2.1.4 (2017/04/10) +- Minor changes + +### 2.1.3 (2017/04/10) +- Minor changes and fixes + +### 2.1.2 (2017/04/10) +- Fix config path + +### 2.1.1 (2017/04/10) +- Minor text changes +- Fix config +- Minor changes + +### 2.1.0 (2017/04/10) +- First stable release + +### 2.0.0 Beta (lost in time) +- First public release \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..9b5e748 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,84 @@ +# Funimation Downloader NX + +Funimation Downloader NX is capable of downloading videos from the *Funimation* streaming service. + +Fork of @seiya-dev's Funimation Downloader NX + +## Legal Warning + +This application is not endorsed by or affiliated with *Funimation*. This application enables you to download videos for offline viewing which may be forbidden by law in your country. The usage of this application may also cause a violation of the *Terms of Service* between you and the stream provider. This tool is not responsible for your actions; please make an informed decision before using this application. + +## Prerequisites + +* NodeJS >= 12.4.0 (https://nodejs.org/) +* NPM >= 6.9.0 (https://www.npmjs.org/) +* ffmpeg >= 4.0.0 (https://www.videohelp.com/software/ffmpeg) +* MKVToolNix >= 20.0.0 (https://www.videohelp.com/software/MKVToolNix) + +### Paths Configuration + +By default this application uses the following paths to programs (main executables): +* `./bin/mkvmerge` +* `./bin/ffmpeg` + +To change these paths you need to edit `bin-path.yml` in `./config/` directory. + +### Node Modules + +After installing NodeJS with NPM go to directory with `package.json` file and type: `npm i`. +* [check dependencies](https://david-dm.org/anidl/funimation-downloader-nx) + +## CLI Options + +### Authentication + +* `--auth` enter auth mode + +### Get Show ID + +* `--search ` sets the show title for search + +### Download Video + +* `-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) +* `--alt` alternative episode listing (if available) +* `--sub` switch from English dub to Japanese dub with subtitles +* `--simul` force select simulcast version instead of uncut version +* `-x` select server +* `--novids` skip download videos (for downloading subtitles only) +* `--nosubs` skip download subtitles for Dub (if available) + +### Proxy + +* `--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 + +### Muxing + +`[note] this application mux into mkv by default` +* `--mp4` mux into mp4 +* `--mks` add subtitles to mkv or mp4 (if available) + +### 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) + +### Utility + +* `--nocleanup` move unnecessary files to trash folder after completion instead of deleting +* `-h`, `--help` show all options + +## Filename Template + +[`release group`] `title` - `episode` [`suffix`].`extension` + +## CLI Examples + +* `node funi --search "My Hero"` search "My Hero" in title +* `node funi -s 124389 -e 1,2,3` download episodes 1-3 from show with id 124389 +* `node funi -s 124389 -e 1-3,2-7,s1-2` download episodes 1-7 and "S"-episodes 1-2 from show with id 124389 diff --git a/funi.js b/funi.js new file mode 100644 index 0000000..ba9b1a3 --- /dev/null +++ b/funi.js @@ -0,0 +1,801 @@ +#!/usr/bin/env node + +// modules build-in +const fs = require('fs'); +const path = require('path'); +const url = require('url'); + +// package json +const packageJson = require(path.join(__dirname,'package.json')); + +// program name +console.log(`\n=== Funimation Downloader NX ${packageJson.version} ===\n`); +const api_host = 'https://prod-api-funimationnow.dadcdigital.com/api'; + +// request +const got = require('got').extend({ + headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64; rv:70.0) Gecko/20100101 Firefox/70.0' }, +}); + +// modules extra +const yaml = require('yaml'); +const shlp = require('sei-helper'); +const yargs = require('yargs'); +const FormData = require('form-data'); +const { lookpath } = require('lookpath'); + +// m3u8 and ttml +const m3u8 = require('m3u8-parsed'); +const streamdl = require('hls-download'); +const { ttml2srt } = require('ttml2srt'); + +// get cfg file +function getYamlCfg(file){ + let data = {}; + if(fs.existsSync(file)){ + try{ + data = yaml.parse(fs.readFileSync(file, 'utf8')); + return data; + } + catch(e){} + } + return data; +} + +// new-cfg +const cfgFolder = __dirname + '/config'; +const binCfgFile = path.join(cfgFolder,'bin-path.yml'); +const dirCfgFile = path.join(cfgFolder,'dir-path.yml'); +const cliCfgFile = path.join(cfgFolder,'cli-defaults.yml'); +const tokenFile = path.join(cfgFolder,'token.yml'); + +// params +let cfg = { + bin: getYamlCfg(binCfgFile), + dir: getYamlCfg(dirCfgFile), + cli: getYamlCfg(cliCfgFile), +}; + +// token +let token = getYamlCfg(tokenFile); +token = token.token ? token.token : false; + +// info if token not set +if(!token){ + console.log('[INFO] Token not set!\n'); +} + +// cli +let argv = yargs + .wrap(Math.min(100)) + .usage('Usage: $0 [options]') + .help(false).version(false) + + // auth + .describe('auth','Enter auth mode') + + // search + .describe('search','Sets the show title for search') + + // params + .describe('s','Sets the show id') + .describe('e','Select episode ids (comma-separated, hyphen-sequence)') + + .describe('q','Video layer (0 is max)') + .choices('q', [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + .default('q', cfg.cli.videoLayer) + + .describe('alt','Alternative episode listing (if available)') + .boolean('alt') + + .describe('sub','Subtitles mode (Dub mode by default)') + .boolean('sub') + + .describe('simul','Forсe download simulcast version instead of uncut') + .boolean('simul') + + .describe('x','Select server') + .choices('x', [1, 2, 3, 4]) + .default('x', cfg.cli.nServer) + + .describe('novids', 'Skip download videos') + .boolean('novids') + + .describe('nosubs','Skip download subtitles for Dub (if available)') + .boolean('nosubs') + + // proxy + .describe('proxy','http(s)/socks proxy WHATWG url (ex. https://myproxyhost:1080/)') + .describe('proxy-auth','Colon-separated username and password for proxy') + .describe('ssp','Ignore proxy settings for stream downloading') + .boolean('ssp') + + .describe('mp4','Mux into mp4') + .boolean('mp4') + .default('mp4',cfg.cli.mp4mux) + .describe('mks','Add subtitles to mkv or mp4 (if available)') + .boolean('mks') + .default('mks',cfg.cli.muxSubs) + + .describe('a','Filenaming: Release group') + .default('a',cfg.cli.releaseGroup) + .describe('t','Filenaming: Series title override') + .describe('ep','Filenaming: Episode number override (ignored in batch mode)') + .describe('suffix','Filenaming: Filename suffix override (first "SIZEp" will be replaced with actual video size)') + .default('suffix',cfg.cli.fileSuffix) + + // util + .describe('nocleanup','move temporary files to trash folder instead of deleting') + .boolean('nocleanup') + .default('nocleanup',cfg.cli.noCleanUp) + + // help + .describe('h','Show this help') + .alias('h','help') + .boolean('h') + + .version(false) + .help(false) + .argv; + +// 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 fnTitle = '', + fnEpNum = '', + fnSuffix = '', + fnOutput = '', + tsDlPath = false, + stDlPath = false, + batchDL = false; + +// go to work folder +try { + fs.accessSync(cfg.dir.content, fs.R_OK | fs.W_OK); +} +catch (e) { + console.log(e); + console.log('[ERROR] %s',e.messsage); + process.exit(); +} + +// go to content folder (remove it in future version!) +process.chdir(cfg.dir.content); + +// 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{ + yargs.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, + }); + 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)); + fs.writeFileSync(tokenFile,yaml.stringify({'token':authData.token})); + } + else if(authData.error){ + console.log('[ERROR]',authData.error,'\n'); + 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, + useToken: true, + useProxy: true, + }); + 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)}`, + useToken: true, + useProxy: true, + }); + // 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, + useToken: true, + useProxy: true, + }); + if(!episodesData.ok){return;} + let eps = JSON.parse(episodesData.res.body).items, fnSlug = [], is_selected = false; + argv.e = typeof argv.e == 'number' || typeof argv.e == 'string' ? argv.e.toString() : ''; + argv.e = argv.e.match(',') ? argv.e.split(',') : [argv.e]; + let epSelList = argv.e, epSelRanges = [], epSelEps = []; + epSelList = epSelList.map((e)=>{ + if(e.match('-')){ + e = e.split('-'); + if( e[0].match(/^(?:[A-Z]|)\d+$/i) && e[1].match(/^\d+$/) ){ + e[0] = e[0].replace(/^(?:([A-Z])|)(0+)/i,'$1'); + let letter = e[0].match(/^([A-Z])\d+$/i) ? e[0].match(/^([A-Z])\d+$/i)[1].toUpperCase() : ''; + e[0] = e[0].replace(/^[A-Z](\d+)$/i,'$1'); + e[0] = parseInt(e[0]); + e[1] = parseInt(e[1]); + if(e[0] < e[1]){ + for(let i=e[0];i 0 ? `, ${eps[e].audio.join(', ')}` : ''; + let rtm_str = eps[e].item.runtime !== '' ? eps[e].item.runtime : '??:??'; + // console string + let episodeIdStr = epStrId; + let conOut = `[${episodeIdStr}] `; + 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){ + batchDL = true; + } + if(fnSlug.length<1){ + console.log('[INFO] Episodes not selected!\n'); + process.exit(); + } + else{ + console.log('[INFO] Selected Episodes: %s\n',epSelEps.join(', ')); + for(let fnEp=0;fnEp 0 && m.type == 'Non-Encrypted'){ + let dub_type = m.language; + 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; + } + else if(dub_type == 'English' && !argv.sub && selUncut){ + streamId = m.id; + stDlPath = m.subtitles; + selected = true; + } + console.log(`[#${m.id}] ${dub_type} [${m.version}]`,(selected?'(selected)':'')); + } + } + if(streamId<1){ + console.log('[ERROR] Track not selected\n'); + return; + } + else{ + let streamData = await getData({ + baseUrl: api_host, + url: `/source/catalog/video/${streamId}/signed`, + useToken: true, + useProxy: true, + dinstid: 'uuid', + }); + 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; + } + } + } + if(!tsDlPath){ + console.log('[ERROR] Unknown error\n'); + return; + } + else{ + await downloadStreams(); + } + } +} + +function getSubsUrl(m){ + if(argv.nosubs && !argv.sub){ + return false; + } + for(let i in m){ + let fpp = m[i].filePath.split('.'); + let fpe = fpp[fpp.length-1]; + if(fpe == 'dfxp'){ // dfxp, srt, vtt + return m[i].filePath; + } + } + return false; +} + +async function downloadStreams(){ + + // req playlist + let plQualityReq = await getData({ + url: tsDlPath, + useProxy: (argv.ssp ? false : true), + }); + if(!plQualityReq.ok){return;} + + let plQualityLinkList = m3u8(plQualityReq.res.body); + + let mainServersList = [ + 'd132fumi6di1wa.cloudfront.net', + 'funiprod.akamaized.net' + ]; + + let plServerList = [], + plStreams = {}, + plLayersStr = [], + plLayersRes = {}, + plMaxLayer = 1; + + for(let s of plQualityLinkList.playlists){ + // set layer and max layer + let plLayerId = parseInt(s.uri.match(/_Layer(\d+)\.m3u8$/)[1]); + plMaxLayer = plMaxLayer < plLayerId ? plLayerId : plMaxLayer; + // set urls and servers + let plUrlDl = s.uri; + let plServer = plUrlDl.split('/')[2]; + 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); + } + } + + for(let s of mainServersList){ + if(plServerList.includes(s)){ + plServerList.splice(plServerList.indexOf(s),1); + plServerList.unshift(s); + break; + } + } + + 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}`); + } + else if(argv.x > plServerList.length){ + console.log('[ERROR] Server not selected!\n'); + return; + } + else{ + console.log('[ERROR] Layer not selected!\n'); + return; + } + + if (!argv.novids) { + // download video + let reqVideo = await getData({ + url: videoUrl, + useProxy: (argv.ssp ? false : true), + }); + if (!reqVideo.ok) { return; } + + let chunkList = m3u8(reqVideo.res.body); + chunkList.baseUrl = videoUrl.split('/').slice(0, -1).join('/') + '/'; + + let proxyHLS = false; + if (argv.proxy && !argv.ssp) { + try { + proxyHLS = {}; + proxyHLS.url = buildProxyUrl(argv.proxy,argv['proxy-auth']); + } + catch(e){ + console.log(`\n[WARN] Not valid proxy URL${e.input?' ('+e.input+')':''}!`); + console.log('[WARN] Skiping...'); + proxyHLS = false; + } + } + + let tsFile = `${fnOutput}.ts`; + let resumeFile = `${tsFile}.resume`; + let streamOffset = 0; + if(fs.existsSync(tsFile) && fs.existsSync(resumeFile)){ + try{ + let resume = JSON.parse(fs.readFileSync(resumeFile, 'utf-8')); + if(resume.total == chunkList.segments.length && resume.completed != resume.total){ + streamOffset = resume.completed; + } + } + catch(e){ + console.log(e); + } + } + + let streamdlParams = { + fn: tsFile, + m3u8json: chunkList, + baseurl: chunkList.baseUrl, + pcount: 10, + partsOffset: streamOffset, + proxy: (proxyHLS ? proxyHLS : false) + }; + let dldata = await new streamdl(streamdlParams).download(); + if (!dldata.ok) { + fs.writeFileSync(resumeFile, JSON.stringify(dldata.parts)); + console.log(`[ERROR] ${dldata.error}\n`); + return; + } + else { + console.log('[INFO] Video downloaded!\n'); + } + } + else{ + console.log('[INFO] Skip video downloading...\n'); + } + + // download subtitles + if(stDlPath){ + console.log('[INFO] Downloading subtitles...'); + console.log(stDlPath); + let subsSrc = await getData({ + url: stDlPath, + useProxy: true, + }); + if(subsSrc.ok){ + let srtData = ttml2srt(subsSrc.res.body); + fs.writeFileSync(`${fnOutput}.srt`,srtData); + console.log('[INFO] Subtitles downloaded!'); + } + else{ + console.log('[ERROR] Failed to download subtitles!'); + argv.mks = false; + } + } + + if(!fs.statSync(`${fnOutput}.ts`).isFile()){ + console.log('\n[INFO] TS file not found, skip muxing video...\n'); + return; + } + + // add subs + let addSubs = argv.mks && stDlPath ? true : false; + + // 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...'); + cfg.bin.mkvmerge = false; + } + if( !mkvmergebinfile && !ffmpegbinfile || argv.mp4 && !ffmpegbinfile ){ + console.log('[WARN] FFmpeg not found, skip using this...'); + cfg.bin.ffmpeg = false; + } + + // ftag + argv.ftag = argv.ftag ? argv.ftag : argv.a; + argv.ftag = shlp.cleanupFilename(argv.ftag); + + // select muxer + if(!argv.mp4 && cfg.bin.mkvmerge){ + // mux to mkv + let mkvmux = []; + mkvmux.push('-o',`${fnOutput}.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':'eng'}`); + mkvmux.push('--video-tracks','0','--audio-tracks','1'); + mkvmux.push('--no-subtitles','--no-attachments'); + mkvmux.push(`${fnOutput}.ts`); + if(addSubs){ + mkvmux.push('--language','0:eng'); + mkvmux.push(`${fnOutput}.srt`); + } + fs.writeFileSync(`${fnOutput}.json`,JSON.stringify(mkvmux,null,' ')); + shlp.exec('mkvmerge',`"${cfg.bin.mkvmerge}"`,`@"${fnOutput}.json"`); + fs.unlinkSync(`${fnOutput}.json`); + } + else if(cfg.bin.ffmpeg){ + let ffext = !argv.mp4 ? 'mkv' : 'mp4'; + let ffmux = `-i "${fnOutput}.ts" `; + ffmux += addSubs ? `-i "${fnOutput}.srt" ` : ''; + ffmux += '-map 0 -c:v copy -c:a copy '; + ffmux += addSubs ? '-map 1 ' : ''; + ffmux += addSubs && !argv.mp4 ? '-c:s srt ' : ''; + 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':'eng'} `; + ffmux += addSubs ? '-metadata:s:s:0 language=eng ' : ''; + ffmux += `"${fnOutput}.${ffext}"`; + // mux to mkv + shlp.exec('ffmpeg',`"${cfg.bin.ffmpeg}"`,ffmux); + } + else{ + console.log('\n[INFO] Done!\n'); + return; + } + if(argv.nocleanup){ + fs.renameSync(fnOutput+'.ts', path.join(cfg.dir.trash,`/${fnOutput}.ts`)); + if(stDlPath && argv.mks){ + fs.renameSync(fnOutput+'.srt', path.join(cfg.dir.trash,`/${fnOutput}.srt`)); + } + } + else{ + fs.unlinkSync(fnOutput+'.ts', path.join(cfg.dir.trash,`/${fnOutput}.ts`)); + if(stDlPath && argv.mks){ + fs.unlinkSync(fnOutput+'.srt', path.join(cfg.dir.trash,`/${fnOutput}.srt`)); + } + } + console.log('\n[INFO] Done!\n'); +} + +// get data from url +async function getData(options){ + let gOptions = { url: options.url, headers: {} }; + if(options.baseUrl){ + gOptions.prefixUrl = options.baseUrl; + gOptions.url = gOptions.url.replace(/^\//,''); + } + if(options.querystring){ + gOptions.url += `?${new URLSearchParams(options.querystring).toString()}`; + } + if(options.auth){ + gOptions.method = 'POST'; + gOptions.body = new FormData(); + gOptions.body.append('username', options.auth.user); + gOptions.body.append('password', options.auth.pass); + } + if(options.useToken && token){ + gOptions.headers.Authorization = `Token ${token}`; + } + if(options.dinstid){ + gOptions.headers.devicetype = 'Android Phone'; + } + if(options.useProxy && argv.proxy){ + try{ + const ProxyAgent = require('proxy-agent'); + let proxyUrl = buildProxyUrl(argv.proxy,argv['proxy-auth']); + gOptions.agent = new ProxyAgent(proxyUrl); + gOptions.timeout = 10000; + } + catch(e){ + console.log(`\n[WARN] Not valid proxy URL${e.input?' ('+e.input+')':''}!`); + console.log('[WARN] Skiping...'); + argv.proxy = false; + } + } + try { + if(argv.debug){ + console.log('[Debug] REQ:', gOptions); + } + let res = await got(gOptions); + if(res.body && res.body.match(/^