This commit is contained in:
AniDL 2020-01-21 01:43:01 +03:00
commit 0d8ef58d98
14 changed files with 1183 additions and 0 deletions

27
.eslintrc.yml Normal file
View file

@ -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

18
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,18 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG] "
labels: bug
assignees: ''
---
<!-- Please fill in the placeholders.-->
** Main info: **
Script version:
Show ID:
Episode ID:
** Additional Info: **

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
/node_modules/
.DS_Store
ffmpeg
mkvmerge
token.yml
package-lock.json
*.exe
*.dll
*.ts
*.mkv
*.mp4
*.srt

21
LICENSE.md Normal file
View file

@ -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.

0
bin/.gitkeep Normal file
View file

3
cmd-here.bat Normal file
View file

@ -0,0 +1,3 @@
@echo off
title CmdHere
cmd /k PROMPT @$S$P$_$_$G$S

2
config/bin-path.yml Normal file
View file

@ -0,0 +1,2 @@
ffmpeg: ./bin/ffmpeg
mkvmerge: ./bin/mkvmerge

7
config/cli-defaults.yml Normal file
View file

@ -0,0 +1,7 @@
releaseGroup: Funimation
videoLayer: 7
fileSuffix: SIZEp
nServer: 1
mp4mux: false
muxSubs: false
noCleanUp: false

2
config/dir-path.yml Normal file
View file

@ -0,0 +1,2 @@
content: ./videos/
trash: ./videos/_trash/

169
docs/CHANGELOG.md Normal file
View file

@ -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

84
docs/README.md Normal file
View file

@ -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 <s>` sets the show title for search
### Download Video
* `-s <i> -e <s>` sets the show id and episode ids (comma-separated, hyphen-sequence)
* `-q <i>` 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 <s>` http(s)/socks proxy WHATWG url (ex. https://myproxyhost:1080)
* `--proxy-auth <s>` 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 <s>` release group ("Funimation" by default)
* `-t <s>` show title override
* `--ep <s>` episode number override (ignored in batch mode)
* `--suffix <s>` 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

801
funi.js Normal file
View file

@ -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<e[1]+1;i++){
epSelRanges.push(letter+i);
}
return '';
}
else{
return (letter+e[0]);
}
}
else{
return '';
}
}
else if(e.match(/^(?:[A-Z]|)\d+$/i)){
return e.replace(/^(?:([A-Z])|)(0+)/i,'$1').toUpperCase();
}
else{
return '';
}
});
epSelList = [...new Set(epSelList.concat(epSelRanges))];
// parse episodes list
for(let e in eps){
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'))){
fnSlug.push({title:eps[e].item.titleSlug,episode:eps[e].item.episodeSlug});
epSelEps.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
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<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}/`,
useToken: true,
useProxy: true,
});
if(!episodeData.ok){return;}
let ep = JSON.parse(episodeData.res.body).items[0], streamId = 0;
// 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 );
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;
// 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: '' };
}
});
// 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 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(/^</)){
throw { name: 'HTMLError', res };
}
return {
ok: true,
res,
};
}
catch(error){
if(argv.debug){
console.log(error);
}
if(error.response && error.response.statusCode && error.response.statusMessage){
console.log(`[ERROR] ${error.name} ${error.response.statusCode}: ${error.response.statusMessage}`);
}
else if(error.name && error.name == 'HTMLError' && error.res && error.res.body){
console.log(`[ERROR] ${error.name}:`);
console.log(error.res.body);
}
else{
console.log(`[ERROR] ${error.name}: ${error.code||error.message}`);
}
return {
ok: false,
error,
};
}
}
function buildProxyUrl(proxyBaseUrl,proxyAuth){
let proxyCfg = new URL(proxyBaseUrl);
if(!proxyCfg.hostname || !proxyCfg.port){
throw new Error();
}
if(proxyAuth && proxyAuth.match(':')){
proxyCfg.auth = proxyAuth;
}
return url.format({
protocol: proxyCfg.protocol,
slashes: true,
auth: proxyCfg.auth,
hostname: proxyCfg.hostname,
port: proxyCfg.port,
});
}

37
package.json Normal file
View file

@ -0,0 +1,37 @@
{
"name": "funimation-downloader-nx",
"version": "4.5.0",
"description": "Download videos from Funimation via cli.",
"keywords": [
"download",
"downloader",
"funimation",
"funimationnow",
"util",
"utility",
"cli"
],
"author": "AniDL",
"homepage": "https://github.com/anidl/funimation-downloader-nx",
"repository": {
"type": "git",
"url": "https://github.com/anidl/funimation-downloader-nx.git"
},
"bugs": {
"url": "https://github.com/anidl/funimation-downloader-nx/issues"
},
"license": "MIT",
"main": "funi.js",
"dependencies": {
"form-data": "^3.0.0",
"got": "^10.2.2",
"hls-download": "^2.3.1",
"lookpath": "^1.0.4",
"m3u8-parsed": "^1.2.0",
"proxy-agent": "^3.1.1",
"sei-helper": "^3.3.0",
"ttml2srt": "^1.1.0",
"yaml": "^1.7.2",
"yargs": "^15.1.0"
}
}

0
videos/_trash/.gitkeep Normal file
View file