Starting with merge

This commit is contained in:
Izuco 2021-10-29 22:42:50 +02:00
parent 79384bdc34
commit 4a83046116
No known key found for this signature in database
GPG key ID: 318460063D70949F
22 changed files with 2892 additions and 4303 deletions

View file

@ -1,7 +1,7 @@
---
name: Bug report
about: Found a Bug? Than report it here :)
title: "[BUG] <Short description here>"
title: "[BUG] [Funimation/Crunchy] <Short description here>"
labels: bug
assignees: izu-co
@ -12,6 +12,7 @@ assignees: izu-co
** Main info: **
Script version:
Service:
Show ID:
Episode ID:
Command used:

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2019 AniDL
Copyright (c) 2019-2021 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

1258
crunchy-beta.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,31 @@
# Downloading
videoQuality: 720p
nServer: 1
kStream: 1
tsparts: 10
hsLang: none
dlSubs: all
# Muxing
dubLanguage: jpn
defSubLang: none
useBCPtags: false
mp4mux: false
muxSubs: false
# Filenaming
filenameTemplate: "[{rel_group}] {title} - {ep_num} [{suffix}]"
releaseGroup: CR
epNumLength: 2
fileSuffix: SIZEp
# Proxy
proxy: ''
proxy_auth: ''
proxy_ups: false
use_curl: false
# Utilities
useFolder: false
noCleanUp: false
noTrashFolder: false

View file

@ -0,0 +1,77 @@
#!/usr/bin/env node
// build requirements
const pkg = require('../package.json');
const fs = require('fs-extra');
const { exec } = require('pkg');
const { lookpath } = require('lookpath');
const buildsDir = './_builds';
const curNodeVer = 'node16-';
// main
(async function (){
doBuild();
})();
// do build
async function doBuild(nodeVer){
const buildStr = `${pkg.name}-${pkg.version}`;
nodeVer = nodeVer ? nodeVer : '';
const acceptableBuilds = ['win64','linux64','macos64'];
const buildType = process.argv[2];
if(!acceptableBuilds.includes(buildType)){
console.error('[ERROR] unknown build type!');
process.exit(1);
}
if(!fs.existsSync(buildsDir)){
fs.mkdirSync(buildsDir);
}
const buildFull = `${buildStr}-${buildType}`;
const buildDir = `${buildsDir}`;
const buildConfig = [
pkg.main,
'--target', nodeVer + getTarget(buildType),
'--output', `${buildDir}/${pkg.short_name}`,
];
const buildConfigBeta = [
`${pkg.short_name}-beta.js`,
'--target', nodeVer + getTarget(buildType),
'--output', `${buildDir}/${pkg.short_name}-beta`,
];
console.log(`[Build] Build configuration: ${buildFull}`);
try {
const targetCrClassic = await lookpath(`${buildDir}/${pkg.short_name}`);
const targetCrBeta = await lookpath(`${buildDir}/${pkg.short_name}-beta`);
if(!fs.existsSync(targetCrClassic)){
fs.removeSync(targetCrClassic);
}
if(!fs.existsSync(targetCrBeta)){
fs.removeSync(targetCrBeta);
}
await exec(buildConfig);
await exec(buildConfigBeta);
}
catch(e){
console.log(e);
if(nodeVer == ''){
await doBuild(curNodeVer);
}
process.exit(1);
}
console.log('[LOG] Build ready:', buildFull);
}
function getTarget(bt){
switch(bt){
case 'win64':
return 'windows-x64';
case 'linux64':
return 'linux-x64';
case 'macos64':
return 'macos-x64';
default:
return 'windows-x64';
}
}

80
crunchy/modules/build.js Normal file
View file

@ -0,0 +1,80 @@
#!/usr/bin/env node
// build requirements
const pkg = require('../package.json');
const fs = require('fs-extra');
const { exec } = require('pkg');
const buildsDir = './_builds';
const curNodeVer = 'node16-';
// main
(async function (){
doBuild();
})();
// do build
async function doBuild(nodeVer){
const buildStr = `${pkg.name}-${pkg.version}`;
nodeVer = nodeVer ? nodeVer : '';
const acceptableBuilds = ['win64','linux64','macos64'];
const buildType = process.argv[2];
if(!acceptableBuilds.includes(buildType)){
console.error('[ERROR] unknown build type!');
process.exit(1);
}
if(!fs.existsSync(buildsDir)){
fs.mkdirSync(buildsDir);
}
const buildFull = `${buildStr}-${buildType}`;
const buildDir = `${buildsDir}/${buildFull}`;
if(fs.existsSync(buildDir)){
fs.removeSync(buildDir);
}
fs.mkdirSync(buildDir);
const buildConfig = [
pkg.main,
'--target', nodeVer + getTarget(buildType),
'--output', `${buildDir}/${pkg.short_name}`,
];
const buildConfigBeta = [
`${pkg.short_name}-beta.js`,
'--target', nodeVer + getTarget(buildType),
'--output', `${buildDir}/${pkg.short_name}-beta`,
];
console.log(`[Build] Build configuration: ${buildFull}`);
try {
await exec(buildConfig);
await exec(buildConfigBeta);
}
catch(e){
console.log(e);
if(nodeVer == ''){
await doBuild(curNodeVer);
}
process.exit(1);
}
if(buildType == 'win64'){
fs.copySync('./modules/cmd-here.bat', `${buildDir}/cmd-here.bat`);
}
fs.copySync('./docs/', `${buildDir}/docs/`);
fs.copySync('./LICENSE.md', `${buildDir}/docs/LICENSE.md`);
if(fs.existsSync(`${buildsDir}/${buildFull}.7z`)){
fs.removeSync(`${buildsDir}/${buildFull}.7z`);
}
require('child_process').execSync(`7z a -t7z "${buildsDir}/${buildFull}.7z" "${buildDir}"`, {stdio:[0,1,2]});
console.log('[LOG] Build ready:', `${buildsDir}/${buildFull}.7z`);
}
function getTarget(bt){
switch(bt){
case 'win64':
return 'windows-x64';
case 'linux64':
return 'linux-x64';
case 'macos64':
return 'macos-x64';
default:
return 'windows-x64';
}
}

View file

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

View file

@ -0,0 +1,122 @@
const epNumLen = { E: 4, S: 3, M: 7 };
const maxRange = 1000;
// selectors
const epRegex = new RegExp (/^(?:E?|S|M)(\d+)$/);
const betaEpRegex = new RegExp (/^[0-9A-Z]{9}$/);
const epLtReg = new RegExp (/(?:E|S|M)/);
class doFilter {
constructor(){}
ifMaxEp(type, num){
const maxEp = Math.pow(10, epNumLen[type]) - 1;
return num > maxEp ? true : false;
}
powNum(type){
return Math.pow(10, epNumLen[type]);
}
checkFilter(inputEps){
// check
inputEps = typeof inputEps != 'undefined'
? inputEps.toString().split(',') : [];
// input range
const inputEpsRange = [];
// filter wrong numbers
inputEps = inputEps.map((e) => {
// convert to uppercase
e = e.toUpperCase();
// if range
if(e.match('-') && e.split('-').length == 2){
const eRange = e.split('-');
// check range
if (!eRange[0].match(epRegex)) return '';
// set ep latter and pad
const epLetter = eRange[0].match(epLtReg) ? eRange[0].match(epLtReg)[0] : 'E';
const padLen = epNumLen[epLetter];
// parse range
eRange[0] = eRange[0].replace(epLtReg, '');
eRange[0] = parseInt(eRange[0]);
eRange[0] = this.ifMaxEp(epLetter, eRange[0]) ? this.powNum(epLetter) - 1 : eRange[0];
eRange[1] = eRange[1].match(/^\d+$/) ? parseInt(eRange[1]) : 0;
eRange[1] = this.ifMaxEp(epLetter, eRange[1]) ? this.powNum(epLetter) - 1 : eRange[1];
// check if correct range
if (eRange[0] > eRange[1]){
const parsedEl = [
epLetter != 'E' ? epLetter : '',
eRange[0].toString().padStart(padLen, '0'),
].join('');
return parsedEl;
}
if(eRange[1] - eRange[0] + 1 > maxRange){
eRange[1] = eRange[0] + maxRange - 1;
}
const rangeLength = eRange[1] - eRange[0] + 1;
const epsRangeArr = Array(rangeLength).fill(0);
for(const i in epsRangeArr){
const parsedRangeEl = [
epLetter != 'E' ? epLetter : '',
(parseInt(i) + eRange[0]).toString().padStart(padLen, '0'),
].join('');
inputEpsRange.push(parsedRangeEl);
}
return '';
}
else if(e.match(epRegex)){
const epLetter = e.match(epLtReg) ? e.match(epLtReg)[0] : 'E';
const padLen = epNumLen[epLetter];
e = parseInt(e.replace(epLtReg, ''));
e = this.ifMaxEp(epLetter, e) ? this.powNum(epLetter) - 1 : e;
return (epLetter != 'E' ? epLetter : '') + e.toString().padStart(padLen, '0');
}
else if(e.match(betaEpRegex)){
return e;
}
return '';
});
// end
inputEps = [...new Set(inputEps.concat(inputEpsRange))];
inputEps = inputEps.indexOf('') > -1 ? inputEps.slice(1) : inputEps;
return inputEps;
}
checkMediaFilter(e){
e = e.split(',');
const epLetter = 'M';
const inpMedia = [''];
// map select
e.map((e) => {
if(e.match('-')){
const eRange = e.split('-');
if(eRange[0].match(/^m?\d+$/i)){
eRange[0] = eRange[0].replace(/^m/i,'');
eRange[0] = parseInt(eRange[0]);
eRange[0] = this.ifMaxEp(epLetter, eRange[0]) ? this.powNum(epLetter) - 1 : eRange[0];
inpMedia.push(eRange[0].toString());
}
}
else if(e.match(/^m?\d+$/i)){
const eMedia = parseInt(e.replace(/^m/i,''));
const eMediaStr = this.ifMaxEp(epLetter, eMedia) ? this.powNum(epLetter) - 1 : eMedia;
inpMedia.push(eMediaStr.toString());
}
});
return [...new Set(inpMedia)].splice(1);
}
checkBetaFilter(e){
e = ['', ...e.split(',')];
e = e.map((e) => {
if(e.match(betaEpRegex)){
return e;
}
return '';
});
e = [...new Set(e)].splice(1);
e = e.length > 100 ? e.slice(0, 100) : e;
return e;
}
}
module.exports = {
epNumLen,
doFilter,
};

View file

@ -0,0 +1,285 @@
const path = require('path');
const fs = require('fs-extra');
const shlp = require('sei-helper');
const got = require('got');
const cookieFile = require('./module.cookieFile');
const yamlCfg = require('./module.cfg-loader');
const curlReq = require('./module.curl-req');
// set usable cookies
const usefulCookies = {
auth: [
'etp_rt',
'c_visitor',
],
sess: [
'session_id',
],
};
// req
const Req = class {
constructor(domain, argv, is_beta){
// settings and cookies
this.is_beta = Boolean(is_beta);
this.loadSessTxt = this.is_beta ? false : true;
// main cfg
this.domain = domain;
this.argv = argv;
// session cfg
this.sessCfg = yamlCfg.sessCfgFile,
this.session = this.is_beta ? {} : yamlCfg.loadCRSession();
this.cfgDir = yamlCfg.cfgFolder;
this.curl = false;
}
async getData (durl, params) {
params = params || {};
// options
let options = {
method: params.method ? params.method : 'GET',
headers: {
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64; rv:90.0) Gecko/20100101 Firefox/90.0',
},
};
// additional params
if(params.headers){
options.headers = {...options.headers, ...params.headers};
}
if(options.method == 'POST'){
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
if(params.body){
options.body = params.body;
}
if(params.binary == true){
options.responseType = 'buffer';
}
if(typeof params.followRedirect == 'boolean'){
options.followRedirect = params.followRedirect;
}
// check if cookies.txt exists
const sessTxtFile = path.join(this.cfgDir, 'cookies.txt');
if(!this.is_beta && this.loadSessTxt && fs.existsSync(sessTxtFile)){
const cookiesTxtName = path.basename(sessTxtFile);
try{
// console.log(`[INFO] Loading custom ${cookiesTxtName} file...`);
const netcookie = fs.readFileSync(sessTxtFile, 'utf8');
fs.unlinkSync(sessTxtFile);
this.setNewCookie('', true, netcookie);
}
catch(e){
console.log(`[ERROR] Cannot load ${cookiesTxtName} file!`);
}
}
this.loadSessTxt = false;
// proxy
if(params.useProxy && this.argv.proxy && this.argv.curl){
try{
options.curlProxy = buildProxy(this.argv.proxy);
options.curlProxyAuth = this.argv['proxy-auth'];
}
catch(e){
console.log(`[WARN] Not valid proxy URL${e.input?' ('+e.input+')':''}!`);
console.log('[WARN] Skipping...\n');
this.argv.proxy = false;
}
}
// if auth
let cookie = [];
const loc = new URL(durl);
if(!this.is_beta && Object.values(this.domain).includes(loc.origin)){
for(let uCookie of usefulCookies.auth){
const checkedCookie = this.checkCookieVal(this.session[uCookie]);
if(checkedCookie){
cookie.push(uCookie);
}
}
for(let uCookie of usefulCookies.sess){
if(this.checkSessId(this.session[uCookie]) && !this.argv.nosess){
cookie.push(uCookie);
}
}
if(!params.skipCookies){
cookie.push('c_locale');
options.headers.Cookie = shlp.cookie.make({
...{ c_locale : { value: 'enUS' } },
...this.session,
}, cookie);
}
}
// avoid cloudflare protection
if(loc.origin == this.domain.www){
options.minVersion = 'TLSv1.3';
options.maxVersion = 'TLSv1.3';
options.http2 = true;
}
// debug
options.hooks = {
beforeRequest: [
(options) => {
if(this.argv.debug){
console.log('[DEBUG] GOT OPTIONS:');
console.log(options);
}
}
]
};
if(this.argv.debug){
options.curlDebug = true;
}
// try do request
try {
let res;
if(this.curl && this.argv.curl && Object.values(this.domain).includes(loc.origin)){
res = await curlReq(this.curl, durl.toString(), options, this.cfgDir);
}
else{
res = await got(durl.toString(), options);
}
if(!this.is_beta && !params.skipCookies && res && res.headers && res.headers['set-cookie']){
this.setNewCookie(res.headers['set-cookie'], false);
for(let uCookie of usefulCookies.sess){
if(this.session[uCookie] && this.argv.nosess){
this.argv.nosess = false;
}
}
}
return {
ok: true,
res,
};
}
catch(error){
if(error.response && error.response.statusCode && error.response.statusMessage){
console.log(`[ERROR] ${error.name} ${error.response.statusCode}: ${error.response.statusMessage}`);
}
else{
console.log(`[ERROR] ${error.name}: ${error.code || error.message}`);
}
if(error.response && !error.res){
error.res = error.response;
const docTitle = error.res.body.match(/<title>(.*)<\/title>/);
if(error.res.body && docTitle){
console.log('[ERROR]', docTitle[1]);
}
}
if(error.res && error.res.body && error.response.statusCode
&& error.response.statusCode != 404 && error.response.statusCode != 403){
console.log('[ERROR] Body:', error.res.body);
}
return {
ok: false,
error,
};
}
}
setNewCookie(setCookie, isAuth, fileData){
let cookieUpdated = [], lastExp = 0;
setCookie = fileData ? cookieFile(fileData) : shlp.cookie.parse(setCookie);
for(let cookieName of Object.keys(setCookie)){
if(setCookie[cookieName] && setCookie[cookieName].value && setCookie[cookieName].value == 'deleted'){
delete setCookie[cookieName];
}
}
for(let uCookie of usefulCookies.auth){
const cookieForceExp = 60*60*24*7;
const cookieExpCur = this.session[uCookie] ? this.session[uCookie] : { expires: 0 };
const cookieExp = new Date(cookieExpCur.expires).getTime() - cookieForceExp;
if(cookieExp > lastExp){
lastExp = cookieExp;
}
}
for(let uCookie of usefulCookies.auth){
if(!setCookie[uCookie]){
continue;
}
if(isAuth || setCookie[uCookie] && Date.now() > lastExp){
this.session[uCookie] = setCookie[uCookie];
cookieUpdated.push(uCookie);
}
}
for(let uCookie of usefulCookies.sess){
if(!setCookie[uCookie]){
continue;
}
if(
isAuth
|| this.argv.nosess && setCookie[uCookie]
|| setCookie[uCookie] && !this.checkSessId(this.session[uCookie])
){
const sessionExp = 60*60;
this.session[uCookie] = setCookie[uCookie];
this.session[uCookie].expires = new Date(Date.now() + sessionExp*1000);
this.session[uCookie]['Max-Age'] = sessionExp.toString();
cookieUpdated.push(uCookie);
}
}
if(cookieUpdated.length > 0){
if(this.argv.debug){
console.log('[SAVING FILE]',`${this.sessCfg}.yml`);
}
yamlCfg.saveCRSession(this.session);
console.log(`[INFO] Cookies were updated! (${cookieUpdated.join(', ')})\n`);
}
}
checkCookieVal(chcookie){
return chcookie
&& chcookie.toString() == '[object Object]'
&& typeof chcookie.value == 'string'
? true : false;
}
checkSessId(session_id){
if(session_id && typeof session_id.expires == 'string'){
session_id.expires = new Date(session_id.expires);
}
return session_id
&& session_id.toString() == '[object Object]'
&& typeof session_id.expires == 'object'
&& Date.now() < new Date(session_id.expires).getTime()
&& typeof session_id.value == 'string'
? true : false;
}
uuidv4(){
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
let r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
};
function buildProxy(proxyBaseUrl, proxyAuth){
if(!proxyBaseUrl.match(/^(https?|socks4|socks5):/)){
proxyBaseUrl = 'http://' + proxyBaseUrl;
}
let proxyCfg = new URL(proxyBaseUrl);
let proxyStr = `${proxyCfg.protocol}//`;
if(typeof proxyCfg.hostname != 'string' || proxyCfg.hostname == ''){
throw new Error('[ERROR] Hostname and port required for proxy!');
}
if(proxyAuth && typeof proxyAuth == 'string' && proxyAuth.match(':')){
proxyCfg.username = proxyAuth.split(':')[0];
proxyCfg.password = proxyAuth.split(':')[1];
proxyStr += `${proxyCfg.username}:${proxyCfg.password}@`;
}
proxyStr += proxyCfg.hostname;
if(!proxyCfg.port && proxyCfg.protocol == 'http:'){
proxyStr += ':80';
}
else if(!proxyCfg.port && proxyCfg.protocol == 'https:'){
proxyStr += ':443';
}
return proxyStr;
}
module.exports = {
buildProxy,
usefulCookies,
Req,
};

View file

@ -1,10 +1,10 @@
# Funimation Downloader NX
# Anime Downloader NX by AniDL
Funimation Downloader NX is capable of downloading videos from the *Funimation* streaming service.
This downloader can download anime from diffrent sites. Currently supported are *Funimation* and *Crunchyroll*.
## 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.
This application is not endorsed by or affiliated with *Funimation* or *Crunchyroll*. 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

1
index.ts Normal file
View file

@ -0,0 +1 @@
import { appArgv } from "./modules/module.app-args";

View file

@ -0,0 +1,73 @@
// api domains
const domain = {
www: 'https://www.crunchyroll.com',
api: 'https://api.crunchyroll.com',
www_beta: 'https://beta.crunchyroll.com',
api_beta: 'https://beta-api.crunchyroll.com',
};
export type APIType = {
newani: string,
search1: string,
search2: string,
rss_cid: string,
rss_gid: string
media_page: string
series_page: string
auth: string
// mobile api
search3: string
session: string
collections: string
// beta api
beta_auth: string
beta_authBasic: string
beta_authBasicMob: string
beta_profile: string
beta_cmsToken: string
beta_search: string
beta_browse: string
beta_cms: string,
beta_authHeader: HeadersInit,
beta_authHeaderMob: HeadersInit
}
// api urls
const api: APIType = {
// web
newani: `${domain.www}/rss/anime`,
search1: `${domain.www}/ajax/?req=RpcApiSearch_GetSearchCandidates`,
search2: `${domain.www}/search_page`,
rss_cid: `${domain.www}/syndication/feed?type=episodes&id=`, // &lang=enUS
rss_gid: `${domain.www}/syndication/feed?type=episodes&group_id=`, // &lang=enUS
media_page: `${domain.www}/media-`,
series_page: `${domain.www}/series-`,
auth: `${domain.www}/login`,
// mobile api
search3: `${domain.api}/autocomplete.0.json`,
session: `${domain.api}/start_session.0.json`,
collections: `${domain.api}/list_collections.0.json`,
// beta api
beta_auth: `${domain.api_beta}/auth/v1/token`,
beta_authBasic: 'Basic bm9haWhkZXZtXzZpeWcwYThsMHE6',
beta_authBasicMob: 'Basic YTZ5eGxvYW04c2VqaThsZDhldnc6aFQ3d2FjWHhNaURJcDhSNE9kekJybWVoQUtLTEVKUEE=',
beta_profile: `${domain.api_beta}/accounts/v1/me/profile`,
beta_cmsToken: `${domain.api_beta}/index/v2`,
beta_search: `${domain.api_beta}/content/v1/search`,
beta_browse: `${domain.api_beta}/content/v1/browse`,
beta_cms: `${domain.api_beta}/cms/v2`,
beta_authHeader: {},
beta_authHeaderMob: {}
};
// set header
api.beta_authHeader = {
Authorization: api.beta_authBasic,
};
api.beta_authHeaderMob = {
Authorization: api.beta_authBasicMob,
};
export {
domain, api
};

View file

@ -1,4 +1,18 @@
import yargs from 'yargs';
import * as langsData from "./module.langsData";
yargs(process.argv.slice(2));
const groups = {
'auth': 'Authentication:',
'fonts': 'Fonts:',
'search': 'Search:',
'dl': 'Downloading:',
'mux': 'Muxing:',
'fileName': 'Filename Template:',
'debug': 'Debug:',
'util': 'Utilities:'
}
const availableFilenameVars = [
'title',
@ -6,7 +20,8 @@ const availableFilenameVars = [
'showTitle',
'season',
'width',
'height'
'height',
'service'
];
export type possibleDubs = (
@ -18,238 +33,235 @@ export type possibleSubs = (
const subLang: possibleSubs = ['enUS', 'esLA', 'ptBR'];
const dubLang: possibleDubs = ['enUS', 'esLA', 'ptBR', 'zhMN', 'jaJP'];
const appArgv = (cfg: {
[key: string]: unknown
[key: string]: unknown
}) => {
// init
const parseDefault = <T = unknown>(key: string, _default: T) : T=> {
if (Object.prototype.hasOwnProperty.call(cfg, key)) {
return cfg[key] as T;
} else
return _default;
};
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]')
// auth
.option('auth', {
group: 'Authentication:',
describe: 'Enter authentication mode',
type: 'boolean',
})
// search
.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('all', {
group: 'Downloading:',
describe: 'Used to download all episodes from the show',
type: 'boolean',
default: parseDefault<boolean>('all', false)
})
.option('partsize', {
group: 'Downloading:',
describe: 'The amount of parts that should be downloaded in paralell',
type: 'number',
default: parseDefault<number>('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: parseDefault<number>('videoLayer', 7),
type: 'number',
})
// alt listing
.option('alt', {
group: 'Downloading:',
describe: 'Alternative episode listing (if available)',
default: parseDefault<boolean>('altList', false),
type: 'boolean',
})
// switch to subs
.option('dub', {
group: 'Downloading:',
describe: 'Download non-Japanese Dub (English Dub mode by default)',
choices: dubLang,
default: parseDefault<possibleDubs>('dub', ['enUS']),
type: 'array',
})
.option('subLang', {
group: 'Downloading:',
describe: 'Set the subtitle language (English is default and fallback)',
default: parseDefault<possibleSubs>('subLang', ['enUS']),
choices: subLang,
type: 'array'
})
.option('fontSize', {
group: 'Downloading:',
describe: 'Used to set the fontsize of the subtitles',
default: parseDefault<number>('fontSize', 55),
type: 'number'
})
.option('allSubs', {
group: 'Downloading:',
describe: 'If set to true, all available subs will get downloaded',
default: false,
type: 'boolean'
})
.option('allDubs', {
group: 'Downloading:',
describe: 'If set to true, all available dubs will get downloaded',
default: false,
type: 'boolean'
})
// simulcast
.option('simul', {
group: 'Downloading:',
describe: 'Force downloading simulcast ver. instead of uncut ver. (if uncut ver. available)',
default: parseDefault<boolean>('forceSimul', false),
type: 'boolean',
})
// server number
.option('x', {
alias: 'server',
group: 'Downloading:',
describe: 'Select server',
choices: [1, 2, 3, 4],
default: parseDefault<number>('nServer', 1),
type: 'number',
})
// skip
.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: parseDefault<boolean>('proxy', false),
hidden: true,
})
.option('proxy-auth', {
group: 'Proxy:',
describe: 'Colon-separated username and password for proxy',
default: parseDefault<string|boolean>('proxy_auth', false),
hidden: true,
})
.option('ssp', {
group: 'Proxy:',
describe: 'Don\'t use proxy for stream and subtitles downloading',
default: parseDefault<boolean>('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: parseDefault<boolean>('mp4mux', false),
type: 'boolean'
})
// filenaming
.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: parseDefault<string>('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: parseDefault<number>('numbers', 2)
})
// util
.option('nocleanup', {
group: 'Utilities:',
describe: 'Dont\'t delete the input files after muxing',
default: parseDefault<boolean>('noCleanUp', false),
type: 'boolean'
})
.option('timeout', {
group: 'Downloading:',
describe: 'Set the timeout of all download reqests. Set in millisecods',
type: 'number',
default: parseDefault('timeout', 60 * 1000)
})
.option('debug', {
group: 'Utilities:',
describe: 'Used to enter debug mode. Please use this flag when opening an issue to get more information'
+ '\n!Be careful! - Your token might be exposed so make sure to delete it!',
type: 'boolean',
default: false
})
// help
.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'],
])
// --
.parseSync();
// Resolve unwanted arrays
if (argv.allDubs)
argv.dub = dubLang;
if (argv.allSubs)
argv.subLang = subLang;
for (const key in argv) {
if (argv[key] instanceof Array && !(key === 'subLang' || key === 'dub')) {
argv[key] = (argv[key] as Array<unknown>).pop();
}
}
return argv;
};
const argv = yargs.parserConfiguration({
"duplicate-arguments-array": false,
"camel-case-expansion": false
})
.wrap(yargs.terminalWidth())
.usage('Usage: $0 [options]')
.help(false).version(false)
.option('auth', {
group: groups.auth,
describe: 'Enter authentication mode',
type: 'boolean'
})
.option('dlFonts', {
group: groups.fonts,
describe: 'Download all required fonts for mkv muxing',
type: 'boolean'
})
.option('search', {
group: groups.search,
alias: 'f',
describe: 'Search for an anime',
type: 'string'
})
.option('search-type', {
group: groups.search,
describe: 'Search type used for crunchyroll',
choices: [ '', 'top_results', 'series', 'movie_listing', 'episode' ],
default: '',
type: 'string',
})
.option('page', {
group: groups.search,
alias: 'p',
describe: 'Page number for search results',
type: 'number',
})
.option('search-locale', {
group: groups.search,
describe: 'Search locale used for crunchyroll',
choices: langsData.searchLocales,
default: '',
type: 'string',
})
.option('new', {
group: groups.dl,
describe: 'Get last updated series list from crunchyroll',
type: 'boolean',
})
.option('movie-listing', {
group: groups.dl,
alias: 'flm',
describe: 'Get video list by Movie Listing ID',
type: 'string',
})
.option('series', {
group: groups.dl,
alias: 'srz',
describe: 'Get season list by Series ID',
type: 'string',
})
.option('s', {
group: groups.dl,
describe: 'Set the season ID',
type: 'string'
})
.option('e', {
group: groups.dl,
describe: 'Sets the Episode Number/IDs (comma-separated, hyphen-sequence)',
type: 'string',
})
.option('q', {
group: groups.dl,
describe: 'Set the quality layer. Use 0 to get the best quality.',
default: parseDefault<number>('videoLayer', 7),
type: 'number'
})
.option('server', {
group: groups.dl,
alias: 'x',
describe: 'Select server',
choices: [1, 2, 3, 4],
default: parseDefault<number>('nServer', 1),
type: 'number',
})
.option('kstream', {
group: groups.dl,
alias: 'k',
describe: 'Select specific stream for crunchyroll',
choices: [1, 2, 3, 4, 5, 6, 7],
default: parseDefault<number>('kStream', 1),
type: 'number',
})
.option('partsize', {
group: groups.dl,
describe: 'Set the amount of parts that should be downloaded in paralell',
type: 'number',
default: parseDefault<number>('partsize', 10)
})
.option('hsland', {
group: groups.dl,
describe: 'Download video with specific hardsubs',
choices: langsData.subtitleLanguagesFilter.slice(1),
default: parseDefault<string>('hsLang', 'none'),
type: 'string',
})
.option('subLang', {
group: groups.dl,
describe: 'Set the subtitles to download (Funi only)',
choices: subLang,
default: parseDefault<string[]>('subLang', []),
type: 'array'
})
.option('novids', {
group: groups.dl,
describe: 'Skip downloading videos',
type: 'boolean'
})
.option('noaudio', {
group: groups.dl,
describe: 'Skip downloading audio',
type: 'boolean'
})
.option('nosubs', {
group: groups.dl,
describe: 'Skip downloading subtitles',
type: 'boolean'
})
.option('dub', {
group: groups.dl,
describe: 'Set languages to download (funi only)',
choices: dubLang,
default: parseDefault<possibleDubs>('dub', ['enUS']),
type: 'array'
})
.option('all', {
group: groups.dl,
describe: 'Used to download all episodes from the show (Funi only)',
type: 'boolean',
default: parseDefault<boolean>('all', false)
})
.option('fontSize', {
group: groups.dl,
describe: 'Used to set the fontsize of the subtitles',
default: parseDefault<number>('fontSize', 55),
type: 'number'
})
.option('allSubs', {
group: groups.dl,
describe: 'If set to true, all available subs will get downloaded (Funi only)',
default: false,
type: 'boolean'
})
.option('allDubs', {
group: groups.dl,
describe: 'If set to true, all available dubs will get downloaded (Funi only)',
default: false,
type: 'boolean'
})
.option('timeout', {
group: groups.dl,
describe: 'Set the timeout of all download reqests. Set in millisecods',
type: 'number',
default: parseDefault('timeout', 60 * 1000)
})
.option('simul', {
group: groups.dl,
describe: 'Force downloading simulcast ver. instead of uncut ver. (if uncut ver. available) (Funi only)',
default: parseDefault<boolean>('forceSimul', false),
type: 'boolean',
})
.option('mp4', {
group: groups.mux,
describe: 'Mux video into mp4',
default: parseDefault<boolean>('mp4mux', false),
type: 'boolean'
})
.option('skipmux', {
group: groups.mux,
describe: 'Skip muxing video and subtitles',
type: 'boolean'
})
.option('fileName', {
group: groups.fileName,
describe: `Set the filename template. Use \${variable_name} to insert variables.\nYou may use ${availableFilenameVars
.map(a => `'${a}'`).join(', ')} as variables.`,
type: 'string',
default: parseDefault<string>('fileName', '[${service}] ${showTitle} - ${episode} [${height}p]')
})
.option('numbers', {
group: groups.fileName,
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: parseDefault<number>('numbers', 2)
})
.option('nosess', {
group: groups.debug,
type: 'boolean',
default: 'Reset session cookie for testing purposes'
})
.option('debug', {
group: groups.debug,
describe: 'Debug mode (tokens may be revield in the console output)',
type: 'boolean'
})
.option('nocleanup', {
group: groups.util,
describe: 'Don\'t delete subtitles and videos after muxing',
default: parseDefault<boolean>('noCleanUp', false),
type: 'boolean'
})
.option('help', {
alias: 'h',
group: 'Help:',
describe: 'Show this help',
type: 'boolean'
})
.parseSync();
}
const showHelp = yargs.showHelp;
@ -257,6 +269,6 @@ export {
appArgv,
showHelp,
availableFilenameVars,
dubLang,
subLang
};
subLang,
dubLang
}

View file

@ -7,10 +7,14 @@ import { lookpath } from 'lookpath';
const workingDir = (process as NodeJS.Process & {
pkg?: unknown
}).pkg ? path.dirname(process.execPath) : path.join(__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');
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 sessCfgFile = path.join(workingDir, 'config', 'session');
const tokenFile = {
funi: path.join(workingDir, 'config', 'funi_token'),
cr: path.join(workingDir, 'config', 'cr_token')
};
const loadYamlCfgFile = <T extends Record<string, any>>(file: string, isSess?: boolean): T => {
if(fs.existsSync(`${file}.user.yml`) && !isSess){
@ -49,13 +53,13 @@ const loadCfg = () : ConfigObject => {
const defaultCfg: ConfigObject = {
bin: {},
dir: loadYamlCfgFile<{
content: string,
trash: string,
fonts: string
}>(dirCfgFile),
content: string,
trash: string,
fonts: string
}>(dirCfgFile),
cli: loadYamlCfgFile<{
[key: string]: any
}>(cliCfgFile),
[key: string]: any
}>(cliCfgFile),
};
const defaultDirs = {
fonts: '${wdir}/fonts/',
@ -65,7 +69,7 @@ const loadCfg = () : ConfigObject => {
if (typeof defaultCfg.dir !== 'object' || defaultCfg.dir === null || Array.isArray(defaultCfg.dir)) {
defaultCfg.dir = defaultDirs;
}
const keys = Object.keys(defaultDirs) as (keyof typeof defaultDirs)[];
for (const key of keys) {
if (!Object.prototype.hasOwnProperty.call(defaultCfg.dir, key) || typeof defaultCfg.dir[key] !== 'string') {
@ -75,7 +79,7 @@ const loadCfg = () : ConfigObject => {
defaultCfg.dir[key] = path.join(workingDir, defaultCfg.dir[key].replace(/^\${wdir}/, ''));
}
}
if(!fs.existsSync(defaultCfg.dir.content)){
try{
fs.ensureDirSync(defaultCfg.dir.content);
@ -118,14 +122,57 @@ const loadBinCfg = async () => {
return binCfg;
};
const loadCRSession = () => {
let session = loadYamlCfgFile(sessCfgFile, true);
if(typeof session !== 'object' || session === null || Array.isArray(session)){
session = {};
}
for(const cv of Object.keys(session)){
if(typeof session[cv] !== 'object' || session[cv] === null || Array.isArray(session[cv])){
session[cv] = {};
}
}
return session;
};
const saveCRSession = (data: Record<string, unknown>) => {
const cfgFolder = path.dirname(sessCfgFile);
try{
fs.ensureDirSync(cfgFolder);
fs.writeFileSync(`${sessCfgFile}.yml`, yaml.stringify(data));
}
catch(e){
console.log('[ERROR] Can\'t save session file to disk!');
}
};
const loadCRToken = () => {
let token = loadYamlCfgFile(tokenFile.cr, true);
if(typeof token !== 'object' || token === null || Array.isArray(token)){
token = {};
}
return token;
};
const saveCRToken = (data: Record<string, unknown>) => {
const cfgFolder = path.dirname(tokenFile.cr);
try{
fs.ensureDirSync(cfgFolder);
fs.writeFileSync(`${tokenFile.cr}.yml`, yaml.stringify(data));
}
catch(e){
console.log('[ERROR] Can\'t save token file to disk!');
}
};
const loadFuniToken = () => {
const loadedToken = loadYamlCfgFile<{
token?: string
}>(tokenFile, true);
token?: string
}>(tokenFile.funi, true);
let token: false|string = false;
if (loadedToken && loadedToken.token)
token = loadedToken.token;
// info if token not set
token = loadedToken.token;
// info if token not set
if(!token){
console.log('[INFO] Token not set!\n');
}
@ -135,14 +182,14 @@ const loadFuniToken = () => {
const saveFuniToken = (data: {
token?: string
}) => {
const cfgFolder = path.dirname(tokenFile);
const cfgFolder = path.dirname(tokenFile.funi);
try{
fs.ensureDirSync(cfgFolder);
fs.writeFileSync(`${tokenFile}.yml`, yaml.stringify(data));
fs.writeFileSync(`${tokenFile.funi}.yml`, yaml.stringify(data));
}
catch(e){
console.log('[ERROR] Can\'t save token file to disk!');
}
};
export { loadBinCfg, loadCfg, loadFuniToken, saveFuniToken };
export { loadBinCfg, loadCfg, loadFuniToken, saveFuniToken, saveCRSession, saveCRToken, loadCRToken, loadCRSession };

View file

@ -0,0 +1,26 @@
const parse = (data: string) => {
const res: Record<string, {
value: string,
expires: Date,
path: string,
domain: string,
secure: boolean
}> = {};
const split = data.replace(/\r/g,'').split('\n');
for (const line of split) {
const c = line.split('\t');
if(c.length < 7){
continue;
}
res[c[5]] = {
value: c[6],
expires: new Date(parseInt(c[4])*1000),
path: c[2],
domain: c[0].replace(/^\./,''),
secure: c[3] == 'TRUE' ? true : false
};
}
return res;
};
module.exports = parse;

161
modules/module.curl-req.ts Normal file
View file

@ -0,0 +1,161 @@
// build-in
import child_process from 'child_process';
import fs from 'fs-extra';
import path from 'path';
export type CurlOptions = {
headers?: Record<string, string>,
curlProxy?: boolean,
curlProxyAuth?: string,
minVersion?: string,
http2?: string,
body?: unknown,
curlDebug?: boolean
} | undefined;
export type Res = {
httpVersion: string,
statusCode: string,
statusMessage: string,
rawHeaders: string,
headers: Record<string, string[]|string>,
rawBody: Buffer,
body: string,
}
// req
const curlReq = async (curlBin: string, url: string, options: CurlOptions, cache: string) => {
let curlOpt = [
`"${curlBin}"`,
`"${url}"`,
];
options = options || {};
if(options.headers && Object.keys(options.headers).length > 0){
for(let h of Object.keys(options.headers)){
let hC = options.headers[h];
curlOpt.push('-H', `"${h}: ${hC}"`);
}
}
if(options.curlProxy){
curlOpt.push('--proxy-insecure', '-x', `"${options.curlProxy}"`);
if(options.curlProxyAuth && typeof options.curlProxyAuth == 'string' && options.curlProxyAuth.match(':')){
curlOpt.push('-U', `"${options.curlProxyAuth}"`);
}
}
const reqId = uuidv4();
const headFile = path.join(cache, `/res-headers-${reqId}`);
const bodyFile = path.join(cache, `/res-body-${reqId}`);
const errFile = path.join(cache, `/res-err-${reqId}`);
curlOpt.push('-D', `"${headFile}"`);
curlOpt.push('-o', `"${bodyFile}"`);
curlOpt.push('--stderr', `"${errFile}"`);
curlOpt.push('-L', '-s', '-S');
if(options.minVersion == 'TLSv1.3'){
curlOpt.push('--tlsv1.3');
}
if(options.http2){
curlOpt.push('--http2');
}
if(options.body){
curlOpt.push('--data-urlencode', `"${options.body}"`);
}
const curlComm = curlOpt.join(' ');
try{
if(options.curlDebug){
console.log(curlComm, '\n');
}
child_process.execSync(curlComm, { stdio: 'inherit', windowsHide: true });
}
catch(next){
const errData = { name: 'RequestError', message: 'EACCES' };
try{
fs.unlinkSync(headFile);
}
catch(e){
// ignore it...
}
try{
errData.message =
fs.readFileSync(errFile, 'utf8')
.replace(/^curl: /, '');
fs.unlinkSync(errFile);
}
catch(e){
// ignore it...
}
throw errData;
}
const rawHeaders = fs.readFileSync(headFile, 'utf8');
const rawBody = fs.readFileSync(bodyFile);
fs.unlinkSync(headFile);
fs.unlinkSync(bodyFile);
fs.unlinkSync(errFile);
let res: Res = {
httpVersion: '',
statusCode: '',
statusMessage: '',
rawHeaders: rawHeaders,
headers: {},
rawBody: rawBody,
body: rawBody.toString(),
};
let headersCont = rawHeaders.replace(/\r/g, '').split('\n');
for(let h of headersCont){
if( h == '' ){ continue; }
if(!h.match(':')){
let statusRes = h.split(' ');
res.httpVersion = statusRes[0].split('/')[1];
res.statusCode = statusRes[1];
res.statusMessage = statusRes.slice(2).join(' ');
}
else{
let resHeader = h.split(': ');
let resHeadName = resHeader[0].toLowerCase();
let resHeadCont = resHeader.slice(1).join(': ');
if(resHeadName == 'set-cookie'){
if(!Object.prototype.hasOwnProperty.call(res.headers, resHeadName)){
res.headers[resHeadName] = [];
}
(res.headers[resHeadName] as string[]).push(resHeadCont);
}
else{
res.headers[resHeadName] = resHeadCont;
}
}
}
if(!res.statusCode.match(/^(2|3)\d\d$/)){
let httpStatusMessage = res.statusMessage ? ` (${res.statusMessage})` : '';
throw {
name: 'HTTPError',
message: `Response code ${res.statusCode}${httpStatusMessage}`,
response: res
};
}
return res;
};
function uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
let r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
module.exports = curlReq;

View file

@ -0,0 +1,93 @@
// fonts src
const root = 'https://static.crunchyroll.com/vilos-v2/web/vilos/assets/libass-fonts/';
// file list
const fonts = {
'Adobe Arabic': 'AdobeArabic-Bold.otf',
'Andale Mono': 'andalemo.ttf',
'Arial': 'arial.ttf',
'Arial Bold': 'arialbd.ttf',
'Arial Bold Italic': 'arialbi.ttf',
'Arial Italic': 'ariali.ttf',
'Arial Unicode MS': 'arialuni.ttf',
'Arial Black': 'ariblk.ttf',
'Comic Sans MS': 'comic.ttf',
'Comic Sans MS Bold': 'comicbd.ttf',
'Courier New': 'cour.ttf',
'Courier New Bold': 'courbd.ttf',
'Courier New Bold Italic': 'courbi.ttf',
'Courier New Italic': 'couri.ttf',
'DejaVu LGC Sans Mono Bold': 'DejaVuLGCSansMono-Bold.ttf',
'DejaVu LGC Sans Mono Bold Oblique': 'DejaVuLGCSansMono-BoldOblique.ttf',
'DejaVu LGC Sans Mono Oblique': 'DejaVuLGCSansMono-Oblique.ttf',
'DejaVu LGC Sans Mono': 'DejaVuLGCSansMono.ttf',
'DejaVu Sans Bold': 'DejaVuSans-Bold.ttf',
'DejaVu Sans Bold Oblique': 'DejaVuSans-BoldOblique.ttf',
'DejaVu Sans ExtraLight': 'DejaVuSans-ExtraLight.ttf',
'DejaVu Sans Oblique': 'DejaVuSans-Oblique.ttf',
'DejaVu Sans': 'DejaVuSans.ttf',
'DejaVu Sans Condensed Bold': 'DejaVuSansCondensed-Bold.ttf',
'DejaVu Sans Condensed Bold Oblique': 'DejaVuSansCondensed-BoldOblique.ttf',
'DejaVu Sans Condensed Oblique': 'DejaVuSansCondensed-Oblique.ttf',
'DejaVu Sans Condensed': 'DejaVuSansCondensed.ttf',
'DejaVu Sans Mono Bold': 'DejaVuSansMono-Bold.ttf',
'DejaVu Sans Mono Bold Oblique': 'DejaVuSansMono-BoldOblique.ttf',
'DejaVu Sans Mono Oblique': 'DejaVuSansMono-Oblique.ttf',
'DejaVu Sans Mono': 'DejaVuSansMono.ttf',
'Georgia': 'georgia.ttf',
'Georgia Bold': 'georgiab.ttf',
'Georgia Italic': 'georgiai.ttf',
'Georgia Bold Italic': 'georgiaz.ttf',
'Impact': 'impact.ttf',
'Rubik Black': 'Rubik-Black.ttf',
'Rubik Black Italic': 'Rubik-BlackItalic.ttf',
'Rubik Bold': 'Rubik-Bold.ttf',
'Rubik Bold Italic': 'Rubik-BoldItalic.ttf',
'Rubik Italic': 'Rubik-Italic.ttf',
'Rubik Light': 'Rubik-Light.ttf',
'Rubik Light Italic': 'Rubik-LightItalic.ttf',
'Rubik Medium': 'Rubik-Medium.ttf',
'Rubik Medium Italic': 'Rubik-MediumItalic.ttf',
'Rubik': 'Rubik-Regular.ttf',
'Tahoma': 'tahoma.ttf',
'Times New Roman': 'times.ttf',
'Times New Roman Bold': 'timesbd.ttf',
'Times New Roman Bold Italic': 'timesbi.ttf',
'Times New Roman Italic': 'timesi.ttf',
'Trebuchet MS': 'trebuc.ttf',
'Trebuchet MS Bold': 'trebucbd.ttf',
'Trebuchet MS Bold Italic': 'trebucbi.ttf',
'Trebuchet MS Italic': 'trebucit.ttf',
'Verdana': 'verdana.ttf',
'Verdana Bold': 'verdanab.ttf',
'Verdana Italic': 'verdanai.ttf',
'Verdana Bold Italic': 'verdanaz.ttf',
'Webdings': 'webdings.ttf',
};
// collect styles from ass string
function assFonts(ass: string){
let strings = ass.replace(/\r/g,'').split('\n');
let styles = [];
for(let s of strings){
if(s.match(/^Style: /)){
let addStyle = s.split(',');
styles.push(addStyle[1]);
}
}
return [...new Set(styles)];
}
// font mime type
function fontMime(fontFile: string){
if(fontFile.match(/\.otf$/)){
return 'application/vnd.ms-opentype';
}
if(fontFile.match(/\.ttf$/)){
return 'application/x-truetype-font';
}
return 'application/octet-stream';
}
// output
export { root, fonts, assFonts, fontMime };

176
modules/module.langsData.ts Normal file
View file

@ -0,0 +1,176 @@
// available langs
export type LanguageItem = {
cr_locale: string,
locale: string,
code: string,
name: string,
language?: string
}
const languages: LanguageItem[] = [
{ cr_locale: 'en-US', locale: 'en', code: 'eng', name: 'English' },
{ cr_locale: 'es-LA', locale: 'es-419', code: 'spa', name: 'Spanish', language: 'Latin American Spanish' },
{ cr_locale: 'es-419', locale: 'es-419', code: 'spa', name: 'Spanish', language: 'Latin American Spanish' },
{ cr_locale: 'es-ES', locale: 'es', code: 'spa', name: 'Spanish' },
{ cr_locale: 'pt-BR', locale: 'pt-BR', code: 'por', name: 'Portuguese', language: 'Brazilian Portuguese' },
{ cr_locale: 'fr-FR', locale: 'fr', code: 'fra', name: 'French' },
{ cr_locale: 'de-DE', locale: 'de', code: 'deu', name: 'German' },
{ cr_locale: 'ar-ME', locale: 'ar', code: 'ara', name: 'Arabic' },
{ cr_locale: 'ar-SA', locale: 'ar', code: 'ara', name: 'Arabic' },
{ cr_locale: 'it-IT', locale: 'it', code: 'ita', name: 'Italian' },
{ cr_locale: 'ru-RU', locale: 'ru', code: 'rus', name: 'Russian' },
{ cr_locale: 'tr-TR', locale: 'tr', code: 'tur', name: 'Turkish' },
{ cr_locale: 'ja-JP', locale: 'ja', code: 'jpn', name: 'Japanese' },
];
// add en language names
(() =>{
for(let languageIndex in languages){
if(!languages[languageIndex].language){
languages[languageIndex].language = languages[languageIndex].name;
}
}
})();
// construct dub language codes
const dubLanguageCodes = (() => {
const dubLanguageCodesArray = [];
for(const language of languages){
dubLanguageCodesArray.push(language.code);
}
return [...new Set(dubLanguageCodesArray)];
})();
// construct subtitle languages filter
const subtitleLanguagesFilter = (() => {
const subtitleLanguagesExtraParameters = ['all', 'none'];
return [...subtitleLanguagesExtraParameters, ...new Set(languages.map(l => { return l.locale; }).slice(0, -1))];
})();
const searchLocales = (() => {
return ['', ...new Set(languages.map(l => { return l.cr_locale; }).slice(0, -1))];
})();
// convert
const fixLanguageTag = (tag: string) => {
tag = typeof tag == 'string' ? tag : 'und';
const tagLangLC = tag.match(/^(\w{2})-?(\w{2})$/);
if(tagLangLC){
const tagLang = `${tagLangLC[1]}-${tagLangLC[2].toUpperCase()}`;
if(findLang(tagLang).cr_locale != 'und'){
return findLang(tagLang).cr_locale;
}
else{
return tagLang;
}
}
else{
return tag;
}
};
// find lang by cr_locale
const findLang = (cr_locale: string) => {
const lang = languages.find(l => { return l.cr_locale == cr_locale; });
return lang ? lang : { cr_locale: 'und', locale: 'un', code: 'und', name: '', language: '' };
};
const fixAndFindCrLC = (cr_locale: string) => {
return findLang(fixLanguageTag(cr_locale));
};
// rss subs lang parser
const parseRssSubtitlesString = (subs: string) => {
const splitMap = subs.replace(/\s/g, '').split(',').map((s) => {
return fixAndFindCrLC(s).locale;
});
const sort = sortTags(splitMap);
return sort.join(', ');
};
// parse subtitles Array
const parseSubtitlesArray = (tags: string[]) => {
const sort = sortSubtitles(tags.map((t) => {
return { locale: fixAndFindCrLC(t).locale };
}));
return sort.map((t) => { return t.locale; }).join(', ');
};
// sort subtitles
const sortSubtitles = (data: Partial<LanguageItem>[], sortkey: keyof LanguageItem = 'locale') => {
const idx: Record<string, number> = {};
sortkey = sortkey || 'locale';
const tags = [...new Set(Object.values(languages).map(e => e.locale))];
for(const l of tags){
idx[l] = Object.keys(idx).length + 1;
}
data.sort((a, b) => {
const ia = idx[a[sortkey] as string] ? idx[a[sortkey] as string] : 50;
const ib = idx[b[sortkey] as string] ? idx[b[sortkey] as string] : 50;
return ia - ib;
});
return data;
};
const sortTags = (data: string[]) => {
const retData = data.map(e => { return { locale: e }; });
const sort = sortSubtitles(retData);
return sort.map(e => e.locale);
};
const subsFile = (fnOutput:string, subsIndex: string, langItem: LanguageItem) => {
subsIndex = (parseInt(subsIndex) + 1).toString().padStart(2, '0');
return `${fnOutput}.${subsIndex} ${langItem.code} ${langItem.language}.ass`;
};
// construct dub langs const
const dubLanguages = (() => {
const dubDb: Record<string, string> = {};
for(const lang of languages){
if(!Object.keys(dubDb).includes(lang.name)){
dubDb[lang.name] = lang.code;
}
}
return dubDb;
})();
// dub regex
const dubRegExpStr =
`\\((${Object.keys(dubLanguages).join('|')})(?: (Dub|VO))?\\)$`;
const dubRegExp = new RegExp(dubRegExpStr);
// code to lang name
const langCode2name = (code: string) => {
const codeIdx = dubLanguageCodes.indexOf(code);
return Object.keys(dubLanguages)[codeIdx];
};
// locale to lang name
const locale2language = (locale: string) => {
const filteredLocale = languages.filter(l => {
return l.locale == locale;
});
return filteredLocale[0];
};
// output
export {
languages,
dubLanguageCodes,
dubLanguages,
langCode2name,
locale2language,
dubRegExp,
subtitleLanguagesFilter,
searchLocales,
fixLanguageTag,
findLang,
fixAndFindCrLC,
parseRssSubtitlesString,
parseSubtitlesArray,
sortSubtitles,
sortTags,
subsFile,
};

View file

@ -1,4 +1,8 @@
import * as iso639 from 'iso-639';
import { fonts, fontMime } from "./module.fontsData";
import path from "path";
import fs from "fs";
import { LanguageItem } from './module.langsData';
export type MergerInput = {
path: string,
@ -8,6 +12,15 @@ export type MergerInput = {
export type SubtitleInput = {
language: string,
file: string,
fonts?: ParsedFont[]
}
export type Font = keyof typeof fonts;
export type ParsedFont = {
name: string,
path: string,
mime: string,
}
export type MergerOptions = {
@ -16,7 +29,7 @@ export type MergerOptions = {
onlyAudio: MergerInput[],
subtitels: SubtitleInput[],
output: string,
simul?: boolean
simul?: boolean,
}
class Merger {
@ -166,6 +179,13 @@ class Merger {
args.push('--track-name', `0:"${trackName}"`);
args.push('--language', `0:${Merger.getLanguageCode(subObj.language)}`);
args.push(`"${subObj.file}"`);
if (subObj.fonts && subObj.fonts.length > 0) {
for (const f of subObj.fonts) {
args.push('--attachment-name', f.name);
args.push('--attachment-mime-type', f.mime);
args.push('--attach-file', f.path);
}
}
}
} else {
args.push(
@ -174,6 +194,7 @@ class Merger {
);
}
return args.join(' ');
};
@ -200,6 +221,40 @@ class Merger {
}
public static makeFontsList (fontsDir: string, subs: {
language: LanguageItem,
fonts: Font[]
}[]) : ParsedFont[] {
let fontsNameList: Font[] = [], fontsList = [], subsList = [], isNstr = true;
for(const s of subs){
fontsNameList.push(...s.fonts);
subsList.push(s.language.locale);
}
fontsNameList = [...new Set(fontsNameList)];
if(subsList.length > 0){
console.log('\n[INFO] Subtitles: %s (Total: %s)', subsList.join(', '), subsList.length);
isNstr = false;
}
if(fontsNameList.length > 0){
console.log((isNstr ? '\n' : '') + '[INFO] Required fonts: %s (Total: %s)', fontsNameList.join(', '), fontsNameList.length);
}
for(const f of fontsNameList){
const fontFile = fonts[f];
if(fontFile){
const fontPath = path.join(fontsDir, fontFile);
const mime = fontMime(fontFile);
if(fs.existsSync(fontPath) && fs.statSync(fontPath).size != 0){
fontsList.push({
name: fontFile,
path: fontPath,
mime: mime,
});
}
}
}
return fontsList;
};
}
export default Merger;

4185
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,7 @@
"license": "MIT",
"main": "funi.js",
"dependencies": {
"cheerio": "^1.0.0-rc.10",
"form-data": "^4.0.0",
"fs-extra": "^10.0.0",
"got": "^11.7.0",
@ -59,6 +60,5 @@
"eslint-fix": "eslint *.js modules --fix",
"pretest": "npm run tsc",
"test": "cd lib && node modules/build win64 && node modules/build linux64 && node modules/build macos64"
}
}

View file

@ -65,7 +65,8 @@
/* Advanced Options */
"resolveJsonModule": true,
"skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
"forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
"downlevelIteration": true
},
"exclude": [
"./videos",