mirror of
https://github.com/anidl/multi-downloader-nx.git
synced 2026-01-11 20:10:20 +00:00
Forgive me father for I have sinned (this code with it's "temporary" solutions). This maps all downloaded "versions" so that each version can have it's delay applied as needed
1096 lines
46 KiB
TypeScript
1096 lines
46 KiB
TypeScript
// Built In
|
|
import path from 'path';
|
|
import fs from 'fs-extra';
|
|
|
|
// Package
|
|
import packageJson from './package.json';
|
|
|
|
// Plugins
|
|
import shlp from 'sei-helper';
|
|
|
|
// Custom Modules
|
|
import { console } from './modules/log';
|
|
import streamdl, { M3U8Json } from './modules/hls-download';
|
|
import * as fontsData from './modules/module.fontsData';
|
|
import * as langsData from './modules/module.langsData';
|
|
import * as yamlCfg from './modules/module.cfg-loader';
|
|
import * as yargs from './modules/module.app-args';
|
|
import Merger, { Font, MergerInput, SubtitleInput } from './modules/module.merger';
|
|
import vtt2ass from './modules/module.vtt2ass';
|
|
import { domain, api } from './modules/module.api-urls';
|
|
import * as reqModule from './modules/module.req';
|
|
import parseFileName, { Variable } from './modules/module.filename';
|
|
import { downloaded } from './modules/module.downloadArchive';
|
|
import parseSelect from './modules/module.parseSelect';
|
|
import { AvailableFilenameVars } from './modules/module.args';
|
|
import { MPDParsed, parse } from './modules/module.transform-mpd';
|
|
import getKeys, { canDecrypt } from './modules/widevine';
|
|
import { exec } from './modules/sei-helper-fixes';
|
|
import { KeyContainer } from './modules/license';
|
|
|
|
// Types
|
|
import type { AuthData, AuthResponse, SearchData, SearchResponse, SearchResponseItem } from './@types/messageHandler';
|
|
import type { ServiceClass } from './@types/serviceClassInterface';
|
|
import type { Hit, NewHidiveSearch } from './@types/newHidiveSearch';
|
|
import type { NewHidiveSeries } from './@types/newHidiveSeries';
|
|
import type { Episode, NewHidiveEpisodeExtra, NewHidiveSeason, NewHidiveSeriesExtra } from './@types/newHidiveSeason';
|
|
import type { NewHidiveEpisode } from './@types/newHidiveEpisode';
|
|
import type { NewHidivePlayback, Subtitle } from './@types/newHidivePlayback';
|
|
import type { DownloadedMedia, DownloadedMediaMap, sxItem } from './@types/downloaderTypes';
|
|
|
|
export default class Hidive implements ServiceClass {
|
|
public cfg: yamlCfg.ConfigObject;
|
|
private token: Record<string, any>;
|
|
private req: reqModule.Req;
|
|
|
|
constructor(private debug = false) {
|
|
this.cfg = yamlCfg.loadCfg();
|
|
this.token = yamlCfg.loadNewHDToken();
|
|
this.req = new reqModule.Req(domain, debug, false, 'hd');
|
|
}
|
|
|
|
public async cli() {
|
|
console.info(`\n=== Multi Downloader NX ${packageJson.version} ===\n`);
|
|
const argv = yargs.appArgv(this.cfg.cli);
|
|
if (argv.debug)
|
|
this.debug = true;
|
|
|
|
//below is for quickly testing API calls
|
|
/*const apiTest = await this.apiReq('/v4/season/18871', '', 'auth', 'GET');
|
|
if(!apiTest.ok || !apiTest.res){return;}
|
|
console.info(apiTest.res.body);
|
|
fs.writeFileSync('apitest.json', JSON.stringify(JSON.parse(apiTest.res.body), null, 2));
|
|
return console.info('test done');*/
|
|
|
|
// load binaries
|
|
this.cfg.bin = await yamlCfg.loadBinCfg();
|
|
if (argv.allDubs) {
|
|
argv.dubLang = langsData.dubLanguageCodes;
|
|
}
|
|
if (argv.auth) {
|
|
//Authenticate
|
|
await this.doAuth({
|
|
username: argv.username ?? await shlp.question('[Q] LOGIN/EMAIL'),
|
|
password: argv.password ?? await shlp.question('[Q] PASSWORD ')
|
|
});
|
|
} else if (argv.search && argv.search.length > 2){
|
|
await this.doSearch({ ...argv, search: argv.search as string });
|
|
} else if (argv.s && !isNaN(parseInt(argv.s,10)) && parseInt(argv.s,10) > 0) {
|
|
const selected = await this.selectSeason(parseInt(argv.s), argv.e, argv.but, argv.all);
|
|
if (selected.isOk && selected.showData) {
|
|
for (const select of selected.value) {
|
|
//download episode
|
|
if (!(await this.downloadEpisode(select, {...argv}))) {
|
|
console.error(`Unable to download selected episode ${select.episodeInformation.episodeNumber}`);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
} else if (argv.srz && !isNaN(parseInt(argv.srz,10)) && parseInt(argv.srz,10) > 0) {
|
|
const selected = await this.selectSeries(parseInt(argv.srz), argv.e, argv.but, argv.all);
|
|
if (selected.isOk && selected.showData) {
|
|
for (const select of selected.value) {
|
|
//download episode
|
|
if (!(await this.downloadEpisode(select, {...argv}))) {
|
|
console.error(`Unable to download selected episode ${select.episodeInformation.episodeNumber}`);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
} else if (argv.new) {
|
|
console.error('--new is not yet implemented in the new API');
|
|
} else if(argv.e) {
|
|
if (!(await this.downloadSingleEpisode(parseInt(argv.e), {...argv}))) {
|
|
console.error(`Unable to download selected episode ${argv.e}`);
|
|
return false;
|
|
}
|
|
} else {
|
|
console.info('No option selected or invalid value entered. Try --help.');
|
|
}
|
|
}
|
|
|
|
|
|
public async apiReq(endpoint: string, body: string | object = '', authType: 'refresh' | 'auth' | 'both' | 'other' | 'none' = 'none', method: 'GET' | 'POST' = 'POST', authHeader?: string) {
|
|
const options = {
|
|
headers: {
|
|
'X-Api-Key': api.hd_new_apiKey,
|
|
'X-App-Var': api.hd_new_version,
|
|
'realm': 'dce.hidive',
|
|
'Referer': 'https://www.hidive.com/',
|
|
'Origin': 'https://www.hidive.com'
|
|
} as Record<string, unknown>,
|
|
method: method as 'GET'|'POST',
|
|
url: api.hd_new_api+endpoint as string,
|
|
body: body,
|
|
useProxy: true
|
|
};
|
|
// get request type
|
|
const isGet = method == 'GET';
|
|
if(!isGet){
|
|
options.body = body == '' ? body : JSON.stringify(body);
|
|
options.headers['Content-Type'] = 'application/json';
|
|
}
|
|
if (authType == 'other') {
|
|
options.headers['Authorization'] = authHeader;
|
|
} else if (authType == 'auth') {
|
|
options.headers['Authorization'] = `Bearer ${this.token.authorisationToken}`;
|
|
} else if (authType == 'refresh') {
|
|
options.headers['Authorization'] = `Bearer ${this.token.refreshToken}`;
|
|
} else if (authType == 'both') {
|
|
options.headers['Authorization'] = `Mixed ${this.token.authorisationToken} ${this.token.refreshToken}`;
|
|
}
|
|
if (this.debug) {
|
|
console.debug('[DEBUG] Request params:');
|
|
console.debug(options);
|
|
}
|
|
const apiReqOpts: reqModule.Params = {
|
|
method: options.method,
|
|
headers: options.headers as Record<string, string>,
|
|
body: options.body as string
|
|
};
|
|
let apiReq = await this.req.getData(options.url, apiReqOpts);
|
|
if(!apiReq.ok || !apiReq.res){
|
|
if (apiReq.error && apiReq.error.res?.statusCode == 401) {
|
|
console.warn('Token expired, refreshing token and retrying.');
|
|
if (await this.refreshToken()) {
|
|
if (authType == 'other') {
|
|
options.headers['Authorization'] = authHeader;
|
|
} else if (authType == 'auth') {
|
|
options.headers['Authorization'] = `Bearer ${this.token.authorisationToken}`;
|
|
} else if (authType == 'refresh') {
|
|
options.headers['Authorization'] = `Bearer ${this.token.refreshToken}`;
|
|
} else if (authType == 'both') {
|
|
options.headers['Authorization'] = `Mixed ${this.token.authorisationToken} ${this.token.refreshToken}`;
|
|
}
|
|
apiReq = await this.req.getData(options.url, apiReqOpts);
|
|
if(!apiReq.ok || !apiReq.res) {
|
|
console.error('API Request Failed!');
|
|
return {
|
|
ok: false,
|
|
res: apiReq.res,
|
|
error: apiReq.error
|
|
};
|
|
}
|
|
} else {
|
|
console.error('Failed to refresh token...');
|
|
return {
|
|
ok: false,
|
|
res: apiReq.res,
|
|
error: apiReq.error
|
|
};
|
|
}
|
|
} else {
|
|
console.error('API Request Failed!');
|
|
return {
|
|
ok: false,
|
|
res: apiReq.res,
|
|
error: apiReq.error
|
|
};
|
|
}
|
|
}
|
|
return {
|
|
ok: true,
|
|
res: apiReq.res,
|
|
};
|
|
}
|
|
|
|
public async doAuth(data: AuthData): Promise<AuthResponse> {
|
|
if (!this.token.refreshToken || !this.token.authorisationToken) {
|
|
await this.doAnonymousAuth();
|
|
}
|
|
const authReq = await this.apiReq('/v2/login', {
|
|
id: data.username,
|
|
secret: data.password
|
|
}, 'auth');
|
|
if(!authReq.ok || !authReq.res){
|
|
console.error('Authentication failed!');
|
|
return { isOk: false, reason: new Error('Authentication failed') };
|
|
}
|
|
const tokens: Record<string, string> = JSON.parse(authReq.res.body);
|
|
for (const token in tokens) {
|
|
this.token[token] = tokens[token];
|
|
}
|
|
this.token.guest = false;
|
|
yamlCfg.saveNewHDToken(this.token);
|
|
console.info('Auth complete!');
|
|
return { isOk: true, value: undefined };
|
|
}
|
|
|
|
public async doAnonymousAuth() {
|
|
const authReq = await this.apiReq('/v2/login/guest/checkin');
|
|
if(!authReq.ok || !authReq.res){
|
|
console.error('Authentication failed!');
|
|
return false;
|
|
}
|
|
const tokens: Record<string, string> = JSON.parse(authReq.res.body);
|
|
for (const token in tokens) {
|
|
this.token[token] = tokens[token];
|
|
}
|
|
//this.token.expires = new Date(Date.now() + 300);
|
|
this.token.guest = true;
|
|
yamlCfg.saveNewHDToken(this.token);
|
|
return true;
|
|
}
|
|
|
|
public async refreshToken() {
|
|
if (!this.token.refreshToken || !this.token.authorisationToken) {
|
|
return await this.doAnonymousAuth();
|
|
} else {
|
|
const authReq = await this.apiReq('/v2/token/refresh', {
|
|
'refreshToken': this.token.refreshToken
|
|
}, 'auth');
|
|
if(!authReq.ok || !authReq.res){
|
|
console.error('Token refresh failed, reinitializing session...');
|
|
return this.initSession();
|
|
}
|
|
const tokens: Record<string, string> = JSON.parse(authReq.res.body);
|
|
for (const token in tokens) {
|
|
this.token[token] = tokens[token];
|
|
}
|
|
yamlCfg.saveNewHDToken(this.token);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
public async initSession() {
|
|
const authReq = await this.apiReq('/v1/init/', '', 'both', 'GET');
|
|
if(!authReq.ok || !authReq.res){
|
|
console.error('Failed to initialize session.');
|
|
return false;
|
|
}
|
|
const tokens: Record<string, string> = JSON.parse(authReq.res.body).authentication;
|
|
for (const token in tokens) {
|
|
this.token[token] = tokens[token];
|
|
}
|
|
yamlCfg.saveNewHDToken(this.token);
|
|
return true;
|
|
}
|
|
|
|
public async doSearch(data: SearchData): Promise<SearchResponse> {
|
|
const searchReq = await this.req.getData('https://h99xldr8mj-dsn.algolia.net/1/indexes/*/queries?x-algolia-agent=Algolia%20for%20JavaScript%20(3.35.1)%3B%20Browser&x-algolia-application-id=H99XLDR8MJ&x-algolia-api-key=e55ccb3db0399eabe2bfc37a0314c346', {
|
|
method: 'POST',
|
|
body: JSON.stringify({'requests':
|
|
[
|
|
{'indexName':'prod-dce.hidive-livestreaming-events','params':'query='+encodeURIComponent(data.search)+'&facetFilters=%5B%22type%3ALIVE_EVENT%22%5D&hitsPerPage=25'+(data.page ? '&page='+(data.page-1) : '')},
|
|
{'indexName':'prod-dce.hidive-livestreaming-events','params':'query='+encodeURIComponent(data.search)+'&facetFilters=%5B%22type%3AVOD_VIDEO%22%5D&hitsPerPage=25'+(data.page ? '&page='+(data.page-1) : '')},
|
|
{'indexName':'prod-dce.hidive-livestreaming-events','params':'query='+encodeURIComponent(data.search)+'&facetFilters=%5B%22type%3AVOD_PLAYLIST%22%5D&hitsPerPage=25'+(data.page ? '&page='+(data.page-1) : '')},
|
|
{'indexName':'prod-dce.hidive-livestreaming-events','params':'query='+encodeURIComponent(data.search)+'&facetFilters=%5B%22type%3AVOD_SERIES%22%5D&hitsPerPage=25'+(data.page ? '&page='+(data.page-1) : '')}
|
|
]
|
|
})
|
|
});
|
|
if(!searchReq.ok || !searchReq.res){
|
|
console.error('Search FAILED!');
|
|
return { isOk: false, reason: new Error('Search failed. No more information provided') };
|
|
}
|
|
const searchData = JSON.parse(searchReq.res.body) as NewHidiveSearch;
|
|
const searchItems: Hit[] = [];
|
|
console.info('Search Results:');
|
|
for (const category of searchData.results) {
|
|
for (const hit of category.hits) {
|
|
searchItems.push(hit);
|
|
let fullType: string;
|
|
if (hit.type == 'VOD_SERIES') {
|
|
fullType = `Z.${hit.id}`;
|
|
} else if (hit.type == 'VOD_VIDEO') {
|
|
fullType = `E.${hit.id}`;
|
|
} else {
|
|
fullType = `${hit.type} #${hit.id}`;
|
|
}
|
|
console.log(`[${fullType}] ${hit.name} ${hit.seasonsCount ? '('+hit.seasonsCount+' Seasons)' : ''}`);
|
|
}
|
|
}
|
|
return { isOk: true, value: searchItems.filter(a => a.type == 'VOD_SERIES').flatMap((a): SearchResponseItem => {
|
|
return {
|
|
id: a.id+'',
|
|
image: a.coverUrl ?? '/notFound.png',
|
|
name: a.name,
|
|
rating: -1,
|
|
desc: a.description
|
|
};
|
|
})};
|
|
}
|
|
|
|
public async getSeries(id: number) {
|
|
const getSeriesData = await this.apiReq(`/v4/series/${id}?rpp=20`, '', 'auth', 'GET');
|
|
if (!getSeriesData.ok || !getSeriesData.res) {
|
|
console.error('Failed to get Series Data');
|
|
return { isOk: false };
|
|
}
|
|
const seriesData = JSON.parse(getSeriesData.res.body) as NewHidiveSeries;
|
|
return { isOk: true, value: seriesData };
|
|
}
|
|
|
|
/**
|
|
* Function to get the season data from the API
|
|
* @param id ID of the season
|
|
* @param lastSeen Last episode ID seen, used for paging
|
|
* @returns
|
|
*/
|
|
public async getSeason(id: number, lastSeen?: number) {
|
|
const getSeasonData = await this.apiReq(`/v4/season/${id}?rpp=20${lastSeen ? '&lastSeen='+lastSeen : ''}`, '', 'auth', 'GET');
|
|
if (!getSeasonData.ok || !getSeasonData.res) {
|
|
console.error('Failed to get Season Data');
|
|
return { isOk: false };
|
|
}
|
|
const seasonData = JSON.parse(getSeasonData.res.body) as NewHidiveSeason;
|
|
return { isOk: true, value: seasonData };
|
|
}
|
|
|
|
public async listSeries(id: number) {
|
|
const series = await this.getSeries(id);
|
|
if (!series.isOk || !series.value) {
|
|
console.error('Failed to list series data: Failed to get series');
|
|
return { isOk: false };
|
|
}
|
|
console.info(`[Z.${series.value.id}] ${series.value.title} (${series.value.seasons.length} Seasons)`);
|
|
if (series.value.seasons.length === 0) {
|
|
console.info(' No Seasons found!');
|
|
return { isOk: false };
|
|
}
|
|
const episodes: Episode[] = [];
|
|
for (const seasonData of series.value.seasons) {
|
|
const season = await this.getSeason(seasonData.id);
|
|
if (!season.isOk || !season.value) {
|
|
console.error('Failed to list series data: Failed to get season '+seasonData.id);
|
|
return { isOk: false };
|
|
}
|
|
console.info(` [S.${season.value.id}] ${season.value.title} (${season.value.episodeCount} Episodes)`);
|
|
while (season.value.paging.moreDataAvailable) {
|
|
const seasonPage = await this.getSeason(seasonData.id, season.value.paging.lastSeen);
|
|
if (!seasonPage.isOk || !seasonPage.value) break;
|
|
season.value.episodes = season.value.episodes.concat(seasonPage.value.episodes);
|
|
season.value.paging.lastSeen = seasonPage.value.paging.lastSeen;
|
|
season.value.paging.moreDataAvailable = seasonPage.value.paging.moreDataAvailable;
|
|
}
|
|
for (const episode of season.value.episodes) {
|
|
const datePattern = /\d{1,2}\/\d{1,2}\/\d{2,4} \d{1,2}:\d{2} UTC/;
|
|
if (episode.title.includes(' - ')) {
|
|
episode.episodeInformation.episodeNumber = parseFloat(episode.title.split(' - ')[0].replace('E', ''));
|
|
episode.title = episode.title.split(' - ')[1];
|
|
}
|
|
//S${episode.episodeInformation.seasonNumber}E${episode.episodeInformation.episodeNumber} -
|
|
if (!datePattern.test(episode.title) && episode.duration !== 10) {
|
|
episodes.push(episode);
|
|
}
|
|
console.info(` [E.${episode.id}] ${episode.title}`);
|
|
}
|
|
}
|
|
return { isOk: true, value: episodes, series: series.value };
|
|
}
|
|
|
|
public async listSeason(id: number) {
|
|
const season = await this.getSeason(id);
|
|
if (!season.isOk || !season.value) {
|
|
console.error('Failed to list series data: Failed to get season '+id);
|
|
return { isOk: false };
|
|
}
|
|
console.info(` [S.${season.value.id}] ${season.value.title} (${season.value.episodeCount} Episodes)`);
|
|
while (season.value.paging.moreDataAvailable) {
|
|
const seasonPage = await this.getSeason(id, season.value.paging.lastSeen);
|
|
if (!seasonPage.isOk || !seasonPage.value) break;
|
|
season.value.episodes = season.value.episodes.concat(seasonPage.value.episodes);
|
|
season.value.paging.lastSeen = seasonPage.value.paging.lastSeen;
|
|
season.value.paging.moreDataAvailable = seasonPage.value.paging.moreDataAvailable;
|
|
}
|
|
const episodes: Episode[] = [];
|
|
for (const episode of season.value.episodes) {
|
|
const datePattern = /\d{1,2}\/\d{1,2}\/\d{2,4} \d{1,2}:\d{2} UTC/;
|
|
if (episode.title.includes(' - ')) {
|
|
episode.episodeInformation.episodeNumber = parseFloat(episode.title.split(' - ')[0].replace('E', ''));
|
|
episode.title = episode.title.split(' - ')[1];
|
|
}
|
|
//S${episode.episodeInformation.seasonNumber}E${episode.episodeInformation.episodeNumber} -
|
|
if (!datePattern.test(episode.title) && episode.duration !== 10) {
|
|
episodes.push(episode);
|
|
}
|
|
console.info(` [E.${episode.id}] ${episode.title}`);
|
|
}
|
|
const series: NewHidiveSeriesExtra = {...season.value.series, season: season.value};
|
|
return { isOk: true, value: episodes, series: series };
|
|
}
|
|
|
|
/**
|
|
* Lists the requested series, and returns the selected episodes
|
|
* @param id Series ID
|
|
* @param e Selector
|
|
* @param but Download all but selected videos
|
|
* @param all Whether to download all available videos
|
|
* @returns
|
|
*/
|
|
public async selectSeries(id: number, e: string | undefined, but: boolean, all: boolean) {
|
|
const getShowData = await this.listSeries(id);
|
|
if (!getShowData.isOk || !getShowData.value) {
|
|
return { isOk: false, value: [] };
|
|
}
|
|
const showData = getShowData.value;
|
|
const doEpsFilter = parseSelect(e as string);
|
|
// build selected episodes
|
|
const selEpsArr: NewHidiveEpisodeExtra[] = []; let ovaSeq = 1; let movieSeq = 1;
|
|
for (let i = 0; i < showData.length; i++) {
|
|
const titleId = showData[i].id;
|
|
const seriesTitle = getShowData.series.title;
|
|
const seasonTitle = getShowData.series.seasons[showData[i].episodeInformation.seasonNumber-1]?.title ?? seriesTitle;
|
|
let nameLong = showData[i].title;
|
|
if (nameLong.match(/OVA/i)) {
|
|
nameLong = 'ova' + (('0' + ovaSeq).slice(-2)); ovaSeq++;
|
|
} else if (nameLong.match(/Theatrical/i)) {
|
|
nameLong = 'movie' + (('0' + movieSeq).slice(-2)); movieSeq++;
|
|
}
|
|
let selMark = '';
|
|
if (all ||
|
|
but && !doEpsFilter.isSelected([parseFloat(showData[i].episodeInformation.episodeNumber+'')+'', showData[i].id+'']) ||
|
|
!but && doEpsFilter.isSelected([parseFloat(showData[i].episodeInformation.episodeNumber+'')+'', showData[i].id+''])
|
|
) {
|
|
selEpsArr.push({ isSelected: true, titleId, nameLong, seasonTitle, seriesTitle, ...showData[i] });
|
|
selMark = '✓ ';
|
|
}
|
|
console.info('%s[%s] %s',
|
|
selMark,
|
|
'S'+parseFloat(showData[i].episodeInformation.seasonNumber+'')+'E'+parseFloat(showData[i].episodeInformation.episodeNumber+''),
|
|
showData[i].title,
|
|
);
|
|
}
|
|
return { isOk: true, value: selEpsArr, showData: getShowData.series };
|
|
}
|
|
|
|
/**
|
|
* Lists the requested season, and returns the selected episodes
|
|
* @param id Season ID
|
|
* @param e Selector
|
|
* @param but Download all but selected videos
|
|
* @param all Whether to download all available videos
|
|
* @returns
|
|
*/
|
|
public async selectSeason(id: number, e: string | undefined, but: boolean, all: boolean) {
|
|
const getShowData = await this.listSeason(id);
|
|
if (!getShowData.isOk || !getShowData.value) {
|
|
return { isOk: false, value: [] };
|
|
}
|
|
const showData = getShowData.value;
|
|
const doEpsFilter = parseSelect(e as string);
|
|
// build selected episodes
|
|
const selEpsArr: NewHidiveEpisodeExtra[] = []; let ovaSeq = 1; let movieSeq = 1;
|
|
for (let i = 0; i < showData.length; i++) {
|
|
const titleId = showData[i].id;
|
|
const seriesTitle = getShowData.series.title;
|
|
const seasonTitle = getShowData.series.season.title;
|
|
let nameLong = showData[i].title;
|
|
if (nameLong.match(/OVA/i)) {
|
|
nameLong = 'ova' + (('0' + ovaSeq).slice(-2)); ovaSeq++;
|
|
} else if (nameLong.match(/Theatrical/i)) {
|
|
nameLong = 'movie' + (('0' + movieSeq).slice(-2)); movieSeq++;
|
|
}
|
|
let selMark = '';
|
|
if (all ||
|
|
but && !doEpsFilter.isSelected([parseFloat(showData[i].episodeInformation.episodeNumber+'')+'', showData[i].id+'']) ||
|
|
!but && doEpsFilter.isSelected([parseFloat(showData[i].episodeInformation.episodeNumber+'')+'', showData[i].id+''])
|
|
) {
|
|
selEpsArr.push({ isSelected: true, titleId, nameLong, seasonTitle, seriesTitle, ...showData[i] });
|
|
selMark = '✓ ';
|
|
}
|
|
console.info('%s[%s] %s',
|
|
selMark,
|
|
'S'+parseFloat(showData[i].episodeInformation.seasonNumber+'')+'E'+parseFloat(showData[i].episodeInformation.episodeNumber+''),
|
|
showData[i].title,
|
|
);
|
|
}
|
|
return { isOk: true, value: selEpsArr, showData: getShowData.series };
|
|
}
|
|
|
|
public async downloadEpisode(selectedEpisode: NewHidiveEpisodeExtra, options: Record<any, any>) {
|
|
//Get Episode data
|
|
const episodeDataReq = await this.apiReq(`/v4/vod/${selectedEpisode.id}?includePlaybackDetails=URL`, '', 'auth', 'GET');
|
|
if (!episodeDataReq.ok || !episodeDataReq.res) {
|
|
console.error('Failed to get episode data');
|
|
return { isOk: false, reason: new Error('Failed to get Episode Data') };
|
|
}
|
|
const episodeData = JSON.parse(episodeDataReq.res.body) as NewHidiveEpisode;
|
|
|
|
if (!episodeData.playerUrlCallback) {
|
|
console.error('Failed to download episode: You do not have access to this');
|
|
return { isOk: false, reason: new Error('You do not have access to this') };
|
|
}
|
|
|
|
//Get Playback data
|
|
const playbackReq = await this.req.getData(episodeData.playerUrlCallback);
|
|
if(!playbackReq.ok || !playbackReq.res){
|
|
console.error('Playback Request Failed');
|
|
return { isOk: false, reason: new Error('Playback request failed') };
|
|
}
|
|
const playbackData = JSON.parse(playbackReq.res.body) as NewHidivePlayback;
|
|
|
|
//Get actual MPD
|
|
const mpdRequest = await this.req.getData(playbackData.dash[0].url);
|
|
if(!mpdRequest.ok || !mpdRequest.res){
|
|
console.error('MPD Request Failed');
|
|
return { isOk: false, reason: new Error('MPD request failed') };
|
|
}
|
|
const mpd = mpdRequest.res.body as string;
|
|
|
|
selectedEpisode.jwtToken = playbackData.dash[0].drm.jwtToken;
|
|
|
|
//Output metadata and prepare for download
|
|
const availableSubs = playbackData.dash[0].subtitles.filter(a => a.format === 'vtt');
|
|
const showTitle = `${selectedEpisode.seriesTitle} S${selectedEpisode.episodeInformation.seasonNumber}`;
|
|
console.info(`[INFO] ${showTitle} - ${selectedEpisode.episodeInformation.episodeNumber}`);
|
|
console.info('[INFO] Available dubs and subtitles:');
|
|
console.info('\tAudios: ' + episodeData.offlinePlaybackLanguages.map(a => langsData.languages.find(b => b.code == a)?.name).join('\n\t\t'));
|
|
console.info('\tSubs : ' + availableSubs.map(a => langsData.languages.find(b => b.new_hd_locale == a.language)?.name).join('\n\t\t'));
|
|
console.info(`[INFO] Selected dub(s): ${options.dubLang.join(', ')}`);
|
|
const baseUrl = playbackData.dash[0].url.split('master')[0];
|
|
const parsedmpd = await parse(mpd, undefined, baseUrl);
|
|
const res = await this.downloadMPD(parsedmpd, availableSubs, selectedEpisode, options);
|
|
if (res === undefined || res.error) {
|
|
console.error('Failed to download media list');
|
|
return { isOk: false, reason: new Error('Failed to download media list') };
|
|
} else {
|
|
if (!options.skipmux) {
|
|
await this.muxStreams(res.data, res.mediaMap, { ...options, output: res.fileName }, false);
|
|
} else {
|
|
console.info('Skipping mux');
|
|
}
|
|
downloaded({
|
|
service: 'hidive',
|
|
type: 's'
|
|
}, selectedEpisode.titleId+'', [selectedEpisode.episodeInformation.episodeNumber+'']);
|
|
return { isOk: res, value: undefined };
|
|
}
|
|
}
|
|
|
|
public async downloadSingleEpisode(id: number, options: Record<any, any>) {
|
|
//Get Episode data
|
|
const episodeDataReq = await this.apiReq(`/v4/vod/${id}?includePlaybackDetails=URL`, '', 'auth', 'GET');
|
|
if (!episodeDataReq.ok || !episodeDataReq.res) {
|
|
console.error('Failed to get episode data');
|
|
return { isOk: false, reason: new Error('Failed to get Episode Data') };
|
|
}
|
|
const episodeData = JSON.parse(episodeDataReq.res.body) as NewHidiveEpisode;
|
|
|
|
if (episodeData.title.includes(' - ') && episodeData.episodeInformation) {
|
|
episodeData.episodeInformation.episodeNumber = parseFloat(episodeData.title.split(' - ')[0].replace('E', ''));
|
|
episodeData.title = episodeData.title.split(' - ')[1];
|
|
}
|
|
|
|
if (!episodeData.playerUrlCallback) {
|
|
console.error('Failed to download episode: You do not have access to this');
|
|
return { isOk: false, reason: new Error('You do not have access to this') };
|
|
}
|
|
|
|
let seasonData: Awaited<ReturnType<typeof this.getSeason>> | undefined = undefined;
|
|
if (episodeData.episodeInformation) {
|
|
seasonData = await this.getSeason(episodeData.episodeInformation.season);
|
|
if (!seasonData.isOk || !seasonData.value) {
|
|
console.error('Failed to get season data');
|
|
return { isOk: false, reason: new Error('Failed to get season data') };
|
|
}
|
|
} else {
|
|
episodeData.episodeInformation = {
|
|
season: 0,
|
|
seasonNumber: 0,
|
|
episodeNumber: 0,
|
|
};
|
|
}
|
|
|
|
//Get Playback data
|
|
const playbackReq = await this.req.getData(episodeData.playerUrlCallback);
|
|
if(!playbackReq.ok || !playbackReq.res){
|
|
console.error('Playback Request Failed');
|
|
return { isOk: false, reason: new Error('Playback request failed') };
|
|
}
|
|
const playbackData = JSON.parse(playbackReq.res.body) as NewHidivePlayback;
|
|
|
|
//Get actual MPD
|
|
const mpdRequest = await this.req.getData(playbackData.dash[0].url);
|
|
if(!mpdRequest.ok || !mpdRequest.res){
|
|
console.error('MPD Request Failed');
|
|
return { isOk: false, reason: new Error('MPD request failed') };
|
|
}
|
|
const mpd = mpdRequest.res.body as string;
|
|
|
|
const selectedEpisode: NewHidiveEpisodeExtra = {
|
|
...episodeData,
|
|
nameLong: episodeData.title,
|
|
titleId: episodeData.id,
|
|
seasonTitle: seasonData?.value.title ?? episodeData.title,
|
|
seriesTitle: seasonData?.value.series.title ?? episodeData.title,
|
|
isSelected: true
|
|
};
|
|
|
|
selectedEpisode.jwtToken = playbackData.dash[0].drm.jwtToken;
|
|
|
|
//Output metadata and prepare for download
|
|
const availableSubs = playbackData.dash[0].subtitles.filter(a => a.format === 'vtt');
|
|
const showTitle = `${selectedEpisode.seriesTitle} S${selectedEpisode.episodeInformation.seasonNumber}`;
|
|
console.info(`[INFO] ${showTitle} - ${selectedEpisode.episodeInformation.episodeNumber}`);
|
|
console.info('[INFO] Available dubs and subtitles:');
|
|
console.info('\tAudios: ' + episodeData.offlinePlaybackLanguages.map(a => langsData.languages.find(b => b.code == a)?.name).join('\n\t\t'));
|
|
console.info('\tSubs : ' + availableSubs.map(a => langsData.languages.find(b => b.new_hd_locale == a.language)?.name).join('\n\t\t'));
|
|
console.info(`[INFO] Selected dub(s): ${options.dubLang.join(', ')}`);
|
|
const baseUrl = playbackData.dash[0].url.split('master')[0];
|
|
const parsedmpd = await parse(mpd, undefined, baseUrl);
|
|
const res = await this.downloadMPD(parsedmpd, availableSubs, selectedEpisode, options);
|
|
if (res === undefined || res.error) {
|
|
console.error('Failed to download media list');
|
|
return { isOk: false, reason: new Error('Failed to download media list') };
|
|
} else {
|
|
if (!options.skipmux) {
|
|
await this.muxStreams(res.data, res.mediaMap, { ...options, output: res.fileName }, false);
|
|
} else {
|
|
console.info('Skipping mux');
|
|
}
|
|
downloaded({
|
|
service: 'hidive',
|
|
type: 's'
|
|
}, selectedEpisode.titleId+'', [selectedEpisode.episodeInformation.episodeNumber+'']);
|
|
return { isOk: res, value: undefined };
|
|
}
|
|
}
|
|
|
|
public async downloadMPD(streamPlaylists: MPDParsed, subs: Subtitle[], selectedEpisode: NewHidiveEpisodeExtra, options: Record<any, any>) {
|
|
//let fileName: string;
|
|
const files: DownloadedMedia[] = [];
|
|
const mediaMap: DownloadedMediaMap[] = [];
|
|
const variables: Variable[] = [];
|
|
let dlFailed = false;
|
|
const subsMargin = 0;
|
|
const chosenFontSize = options.originalFontSize ? undefined : options.fontSize;
|
|
let encryptionKeys: KeyContainer[] = [];
|
|
if (!canDecrypt) console.warn('Decryption not enabled!');
|
|
|
|
const fileMap: DownloadedMediaMap = {
|
|
version: selectedEpisode.id.toString(),
|
|
files: []
|
|
};
|
|
|
|
if (!this.cfg.bin.ffmpeg)
|
|
this.cfg.bin = await yamlCfg.loadBinCfg();
|
|
|
|
variables.push(...([
|
|
['title', selectedEpisode.title, true],
|
|
['episode', selectedEpisode.episodeInformation.episodeNumber, false],
|
|
['service', 'HD', false],
|
|
['seriesTitle', selectedEpisode.seasonTitle, true],
|
|
['showTitle', selectedEpisode.seriesTitle, true],
|
|
['season', selectedEpisode.episodeInformation.seasonNumber, false]
|
|
] as [AvailableFilenameVars, string|number, boolean][]).map((a): Variable => {
|
|
return {
|
|
name: a[0],
|
|
replaceWith: a[1],
|
|
type: typeof a[1],
|
|
sanitize: a[2]
|
|
} as Variable;
|
|
}));
|
|
|
|
//Get name of CDNs/Servers
|
|
const streamServers = Object.keys(streamPlaylists);
|
|
|
|
options.x = options.x > streamServers.length ? 1 : options.x;
|
|
|
|
const selectedServer = streamServers[options.x - 1];
|
|
const selectedList = streamPlaylists[selectedServer];
|
|
|
|
//set Video Qualities
|
|
const videos = selectedList.video.map(item => {
|
|
return {
|
|
...item,
|
|
resolutionText: `${item.quality.width}x${item.quality.height} (${Math.round(item.bandwidth/1024)}KiB/s)`
|
|
};
|
|
});
|
|
|
|
const audios = selectedList.audio.map(item => {
|
|
return {
|
|
...item,
|
|
resolutionText: `${Math.round(item.bandwidth/1000)}kB/s`
|
|
};
|
|
});
|
|
|
|
|
|
videos.sort((a, b) => {
|
|
return a.bandwidth - b.bandwidth;
|
|
});
|
|
|
|
videos.sort((a, b) => {
|
|
return a.quality.width - b.quality.width;
|
|
});
|
|
|
|
audios.sort((a, b) => {
|
|
return a.bandwidth - b.bandwidth;
|
|
});
|
|
|
|
let chosenVideoQuality = options.q === 0 ? videos.length : options.q;
|
|
if(chosenVideoQuality > videos.length) {
|
|
console.warn(`The requested quality of ${options.q} is greater than the maximum ${videos.length}.\n[WARN] Therefor the maximum will be capped at ${videos.length}.`);
|
|
chosenVideoQuality = videos.length;
|
|
}
|
|
chosenVideoQuality--;
|
|
|
|
const chosenVideoSegments = videos[chosenVideoQuality];
|
|
|
|
console.info(`Servers available:\n\t${streamServers.join('\n\t')}`);
|
|
console.info(`Available Video Qualities:\n\t${videos.map((a, ind) => `[${ind+1}] ${a.resolutionText}`).join('\n\t')}`);
|
|
console.info(`Available Audio Qualities:\n\t${audios.map((a, ind) => `[${ind+1}] ${a.resolutionText}`).join('\n\t')}`);
|
|
|
|
variables.push({
|
|
name: 'height',
|
|
type: 'number',
|
|
replaceWith: chosenVideoSegments.quality.height
|
|
}, {
|
|
name: 'width',
|
|
type: 'number',
|
|
replaceWith: chosenVideoSegments.quality.width
|
|
});
|
|
|
|
const chosenAudios: typeof audios[0][] = [];
|
|
const audioByLanguage: Record<string,typeof audios[0][]> = {};
|
|
for (const audio of audios) {
|
|
if (!audioByLanguage[audio.language.code]) audioByLanguage[audio.language.code] = [];
|
|
audioByLanguage[audio.language.code].push(audio);
|
|
}
|
|
for (const dubLang of options.dubLang as string[]) {
|
|
if (audioByLanguage[dubLang]) {
|
|
let chosenAudioQuality = options.q === 0 ? audios.length : options.q;
|
|
if(chosenAudioQuality > audioByLanguage[dubLang].length) {
|
|
chosenAudioQuality = audioByLanguage[dubLang].length;
|
|
}
|
|
chosenAudioQuality--;
|
|
chosenAudios.push(audioByLanguage[dubLang][chosenAudioQuality]);
|
|
}
|
|
}
|
|
if (chosenAudios.length == 0) {
|
|
console.error(`Chosen audio language(s) does not exist for episode ${selectedEpisode.episodeInformation.episodeNumber}`);
|
|
return undefined;
|
|
}
|
|
|
|
const fileName = parseFileName(options.fileName, variables, options.numbers, options.override).join(path.sep);
|
|
|
|
console.info(`Selected quality: \n\tVideo: ${chosenVideoSegments.resolutionText}\n\tAudio: ${chosenAudios[0].resolutionText}\n\tServer: ${selectedServer}`);
|
|
console.info(`Selected (Available) Audio Languages: ${chosenAudios.map(a => a.language.name).join(', ')}`);
|
|
console.info('Stream URL:', chosenVideoSegments.segments[0].map.uri.split('/init.mp4')[0]);
|
|
|
|
if (chosenAudios[0].pssh || chosenVideoSegments.pssh) {
|
|
encryptionKeys = await getKeys(chosenVideoSegments.pssh, 'https://shield-drm.imggaming.com/api/v2/license', {
|
|
'Authorization': `Bearer ${selectedEpisode.jwtToken}`,
|
|
'X-Drm-Info': 'eyJzeXN0ZW0iOiJjb20ud2lkZXZpbmUuYWxwaGEifQ==',
|
|
});
|
|
}
|
|
|
|
if (!options.novids) {
|
|
//Download Video
|
|
const totalParts = chosenVideoSegments.segments.length;
|
|
const mathParts = Math.ceil(totalParts / options.partsize);
|
|
const mathMsg = `(${mathParts}*${options.partsize})`;
|
|
console.info('Total parts in video stream:', totalParts, mathMsg);
|
|
const tsFile = path.isAbsolute(fileName) ? fileName : path.join(this.cfg.dir.content, fileName);
|
|
const tempFile = parseFileName(`temp-${selectedEpisode.id}`, variables, options.numbers, options.override).join(path.sep);
|
|
const tempTsFile = path.isAbsolute(tempFile as string) ? tempFile : path.join(this.cfg.dir.content, tempFile);
|
|
const split = fileName.split(path.sep).slice(0, -1);
|
|
split.forEach((val, ind, arr) => {
|
|
const isAbsolut = path.isAbsolute(fileName);
|
|
if (!fs.existsSync(path.join(isAbsolut ? '' : this.cfg.dir.content, ...arr.slice(0, ind), val)))
|
|
fs.mkdirSync(path.join(isAbsolut ? '' : this.cfg.dir.content, ...arr.slice(0, ind), val));
|
|
});
|
|
const videoJson: M3U8Json = {
|
|
segments: chosenVideoSegments.segments
|
|
};
|
|
const videoDownload = await new streamdl({
|
|
output: `${tempTsFile}.video.enc.m4s`,
|
|
timeout: options.timeout,
|
|
m3u8json: videoJson,
|
|
// baseurl: chunkPlaylist.baseUrl,
|
|
threads: options.partsize,
|
|
fsRetryTime: options.fsRetryTime * 1000,
|
|
override: options.force,
|
|
callback: options.callbackMaker ? options.callbackMaker({
|
|
fileName: `${path.isAbsolute(fileName) ? fileName.slice(this.cfg.dir.content.length) : fileName}`,
|
|
image: selectedEpisode.thumbnailUrl,
|
|
parent: {
|
|
title: selectedEpisode.seriesTitle
|
|
},
|
|
title: selectedEpisode.title,
|
|
language: chosenAudios[0].language
|
|
}) : undefined
|
|
}).download();
|
|
if(!videoDownload.ok){
|
|
console.error(`DL Stats: ${JSON.stringify(videoDownload.parts)}\n`);
|
|
dlFailed = true;
|
|
} else {
|
|
if (chosenVideoSegments.pssh) {
|
|
console.info('Decryption Needed, attempting to decrypt');
|
|
if (encryptionKeys.length == 0) {
|
|
console.error('Failed to get encryption keys');
|
|
return undefined;
|
|
}
|
|
if (this.cfg.bin.mp4decrypt) {
|
|
const commandBase = `--show-progress --key ${encryptionKeys[1].kid}:${encryptionKeys[1].key} `;
|
|
const commandVideo = commandBase+`"${tempTsFile}.video.enc.m4s" "${tempTsFile}.video.m4s"`;
|
|
|
|
console.info('Started decrypting video');
|
|
const decryptVideo = exec('mp4decrypt', `"${this.cfg.bin.mp4decrypt}"`, commandVideo);
|
|
if (!decryptVideo.isOk) {
|
|
console.error(decryptVideo.err);
|
|
console.error(`Decryption failed with exit code ${decryptVideo.err.code}`);
|
|
fs.renameSync(`${tempTsFile}.video.enc.m4s`, `${tsFile}.video.enc.m4s`);
|
|
return undefined;
|
|
} else {
|
|
console.info('Decryption done for video');
|
|
if (!options.nocleanup) {
|
|
fs.removeSync(`${tempTsFile}.video.enc.m4s`);
|
|
}
|
|
fs.renameSync(`${tempTsFile}.video.m4s`, `${tsFile}.video.m4s`);
|
|
files.push({
|
|
type: 'Video',
|
|
path: `${tsFile}.video.m4s`,
|
|
lang: chosenAudios[0].language,
|
|
isPrimary: true
|
|
});
|
|
fileMap.files.push({
|
|
type: 'Video',
|
|
path: `${tsFile}.video.m4s`,
|
|
lang: chosenAudios[0].language,
|
|
isPrimary: true
|
|
});
|
|
}
|
|
} else {
|
|
console.warn('mp4decrypt not found, files need decryption. Decryption Keys:', encryptionKeys);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
console.info('Skipping Video');
|
|
}
|
|
|
|
if (!options.noaudio) {
|
|
for (const audio of chosenAudios) {
|
|
const chosenAudioSegments = audio;
|
|
//Download Audio (if available)
|
|
const totalParts = chosenAudioSegments.segments.length;
|
|
const mathParts = Math.ceil(totalParts / options.partsize);
|
|
const mathMsg = `(${mathParts}*${options.partsize})`;
|
|
console.info('Total parts in audio stream:', totalParts, mathMsg);
|
|
const tempFile = parseFileName(`temp-${selectedEpisode.id}.${chosenAudioSegments.language.name}`, variables, options.numbers, options.override).join(path.sep);
|
|
const tempTsFile = path.isAbsolute(tempFile as string) ? tempFile : path.join(this.cfg.dir.content, tempFile);
|
|
const outFile = parseFileName(options.fileName + '.' + (chosenAudioSegments.language.name), variables, options.numbers, options.override).join(path.sep);
|
|
const tsFile = path.isAbsolute(outFile as string) ? outFile : path.join(this.cfg.dir.content, outFile);
|
|
const split = outFile.split(path.sep).slice(0, -1);
|
|
split.forEach((val, ind, arr) => {
|
|
const isAbsolut = path.isAbsolute(outFile as string);
|
|
if (!fs.existsSync(path.join(isAbsolut ? '' : this.cfg.dir.content, ...arr.slice(0, ind), val)))
|
|
fs.mkdirSync(path.join(isAbsolut ? '' : this.cfg.dir.content, ...arr.slice(0, ind), val));
|
|
});
|
|
const audioJson: M3U8Json = {
|
|
segments: chosenAudioSegments.segments
|
|
};
|
|
const audioDownload = await new streamdl({
|
|
output: `${tempTsFile}.audio.enc.m4s`,
|
|
timeout: options.timeout,
|
|
m3u8json: audioJson,
|
|
// baseurl: chunkPlaylist.baseUrl,
|
|
threads: options.partsize,
|
|
fsRetryTime: options.fsRetryTime * 1000,
|
|
override: options.force,
|
|
callback: options.callbackMaker ? options.callbackMaker({
|
|
fileName: `${path.isAbsolute(outFile) ? outFile.slice(this.cfg.dir.content.length) : outFile}`,
|
|
image: selectedEpisode.thumbnailUrl,
|
|
parent: {
|
|
title: selectedEpisode.seriesTitle
|
|
},
|
|
title: selectedEpisode.title,
|
|
language: chosenAudioSegments.language
|
|
}) : undefined
|
|
}).download();
|
|
if(!audioDownload.ok){
|
|
console.error(`DL Stats: ${JSON.stringify(audioDownload.parts)}\n`);
|
|
dlFailed = true;
|
|
}
|
|
if (chosenAudioSegments.pssh) {
|
|
console.info('Decryption Needed, attempting to decrypt');
|
|
if (encryptionKeys.length == 0) {
|
|
console.error('Failed to get encryption keys');
|
|
return undefined;
|
|
}
|
|
if (this.cfg.bin.mp4decrypt) {
|
|
const commandBase = `--show-progress --key ${encryptionKeys[1].kid}:${encryptionKeys[1].key} `;
|
|
const commandAudio = commandBase+`"${tempTsFile}.audio.enc.m4s" "${tempTsFile}.audio.m4s"`;
|
|
|
|
console.info('Started decrypting audio');
|
|
const decryptAudio = exec('mp4decrypt', `"${this.cfg.bin.mp4decrypt}"`, commandAudio);
|
|
if (!decryptAudio.isOk) {
|
|
console.error(decryptAudio.err);
|
|
console.error(`Decryption failed with exit code ${decryptAudio.err.code}`);
|
|
fs.renameSync(`${tempTsFile}.audio.enc.m4s`, `${tsFile}.audio.enc.m4s`);
|
|
return undefined;
|
|
} else {
|
|
if (!options.nocleanup) {
|
|
fs.removeSync(`${tempTsFile}.audio.enc.m4s`);
|
|
}
|
|
fs.renameSync(`${tempTsFile}.audio.m4s`, `${tsFile}.audio.m4s`);
|
|
files.push({
|
|
type: 'Audio',
|
|
path: `${tsFile}.audio.m4s`,
|
|
lang: chosenAudioSegments.language,
|
|
isPrimary: chosenAudioSegments.default
|
|
});
|
|
fileMap.files.push({
|
|
type: 'Audio',
|
|
path: `${tsFile}.audio.m4s`,
|
|
lang: chosenAudioSegments.language,
|
|
isPrimary: chosenAudioSegments.default
|
|
});
|
|
console.info('Decryption done for audio');
|
|
}
|
|
} else {
|
|
console.warn('mp4decrypt not found, files need decryption. Decryption Keys:', encryptionKeys);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
console.info('Skipping Audio');
|
|
}
|
|
|
|
if(options.dlsubs.indexOf('all') > -1){
|
|
options.dlsubs = ['all'];
|
|
}
|
|
|
|
if (options.nosubs) {
|
|
console.info('Subtitles downloading disabled from nosubs flag.');
|
|
options.skipsubs = true;
|
|
}
|
|
|
|
if(!options.skipsubs && options.dlsubs.indexOf('none') == -1) {
|
|
if(subs.length > 0) {
|
|
let subIndex = 0;
|
|
for(const sub of subs) {
|
|
const subLang = langsData.languages.find(a => a.new_hd_locale === sub.language);
|
|
if (!subLang) {
|
|
console.warn(`Language not found for subtitle language: ${sub.language}, Skipping`);
|
|
continue;
|
|
}
|
|
const sxData: Partial<sxItem> = {};
|
|
sxData.file = langsData.subsFile(fileName as string, subIndex+'', subLang, false, options.ccTag);
|
|
sxData.path = path.join(this.cfg.dir.content, sxData.file);
|
|
sxData.language = subLang;
|
|
if(options.dlsubs.includes('all') || options.dlsubs.includes(subLang.locale)) {
|
|
const getVttContent = await this.req.getData(sub.url);
|
|
if (getVttContent.ok && getVttContent.res) {
|
|
console.info(`Subtitle Downloaded: ${sub.url}`);
|
|
//vttConvert(getVttContent.res.body, false, subLang.name, fontSize);
|
|
const sBody = vtt2ass(undefined, chosenFontSize, getVttContent.res.body, '', subsMargin, options.fontName, options.combineLines);
|
|
sxData.title = `${subLang.language} / ${sxData.title}`;
|
|
sxData.fonts = fontsData.assFonts(sBody) as Font[];
|
|
fs.writeFileSync(sxData.path, sBody);
|
|
console.info(`Subtitle converted: ${sxData.file}`);
|
|
files.push({
|
|
type: 'Subtitle',
|
|
...sxData as sxItem,
|
|
cc: false
|
|
});
|
|
fileMap.files.push({
|
|
type: 'Subtitle',
|
|
...sxData as sxItem,
|
|
cc: false
|
|
});
|
|
} else{
|
|
console.warn(`Failed to download subtitle: ${sxData.file}`);
|
|
}
|
|
}
|
|
subIndex++;
|
|
}
|
|
} else{
|
|
console.warn('Can\'t find urls for subtitles!');
|
|
}
|
|
} else{
|
|
console.info('Subtitles downloading skipped!');
|
|
}
|
|
|
|
mediaMap.push(fileMap);
|
|
|
|
return {
|
|
error: dlFailed,
|
|
data: files,
|
|
mediaMap,
|
|
fileName: fileName ? (path.isAbsolute(fileName) ? fileName : path.join(this.cfg.dir.content, fileName)) || './unknown' : './unknown'
|
|
};
|
|
}
|
|
|
|
public async muxStreams(data: DownloadedMedia[], mediaMap: DownloadedMediaMap[], options: Record<any, any>, inverseTrackOrder: boolean = true) {
|
|
this.cfg.bin = await yamlCfg.loadBinCfg();
|
|
let hasAudioStreams = false;
|
|
if (options.novids || data.filter(a => a.type === 'Video').length === 0)
|
|
return console.info('Skip muxing since no vids are downloaded');
|
|
if (data.some(a => a.type === 'Audio')) {
|
|
hasAudioStreams = true;
|
|
}
|
|
const merger = new Merger({
|
|
mediaMap,
|
|
onlyVid: hasAudioStreams ? data.filter(a => a.type === 'Video').map((a) : MergerInput => {
|
|
return {
|
|
lang: a.lang,
|
|
path: a.path,
|
|
};
|
|
}) : [],
|
|
skipSubMux: options.skipSubMux,
|
|
inverseTrackOrder: inverseTrackOrder,
|
|
keepAllVideos: options.keepAllVideos,
|
|
onlyAudio: hasAudioStreams ? data.filter(a => a.type === 'Audio').map((a) : MergerInput => {
|
|
return {
|
|
lang: a.lang,
|
|
path: a.path,
|
|
};
|
|
}) : [],
|
|
output: `${options.output}.${options.mp4 ? 'mp4' : 'mkv'}`,
|
|
subtitles: data.filter(a => a.type === 'Subtitle').map((a) : SubtitleInput => {
|
|
return {
|
|
file: a.path,
|
|
language: a.language,
|
|
closedCaption: a.cc
|
|
};
|
|
}),
|
|
simul: data.filter(a => a.type === 'Video').map((a) : boolean => {
|
|
return !a.uncut as boolean;
|
|
})[0],
|
|
fonts: Merger.makeFontsList(this.cfg.dir.fonts, data.filter(a => a.type === 'Subtitle') as sxItem[]),
|
|
videoAndAudio: hasAudioStreams ? [] : data.filter(a => a.type === 'Video').map((a) : MergerInput => {
|
|
return {
|
|
lang: a.lang,
|
|
path: a.path,
|
|
};
|
|
}),
|
|
videoTitle: options.videoTitle,
|
|
options: {
|
|
ffmpeg: options.ffmpegOptions,
|
|
mkvmerge: options.mkvmergeOptions
|
|
},
|
|
defaults: {
|
|
audio: options.defaultAudio,
|
|
sub: options.defaultSub
|
|
},
|
|
ccTag: options.ccTag
|
|
});
|
|
const bin = Merger.checkMerger(this.cfg.bin, options.mp4, options.forceMuxer);
|
|
// collect fonts info
|
|
// mergers
|
|
let isMuxed = false;
|
|
if (options.syncTiming) {
|
|
await merger.createDelays();
|
|
}
|
|
if (bin.MKVmerge) {
|
|
await merger.merge('mkvmerge', bin.MKVmerge);
|
|
isMuxed = true;
|
|
} else if (bin.FFmpeg) {
|
|
await merger.merge('ffmpeg', bin.FFmpeg);
|
|
isMuxed = true;
|
|
} else{
|
|
console.info('\nDone!\n');
|
|
return;
|
|
}
|
|
if (isMuxed && !options.nocleanup)
|
|
merger.cleanUp();
|
|
}
|
|
|
|
public sleep(ms: number) {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
}
|