multi-downloader-nx/adn.ts
2024-08-15 10:12:21 -07:00

924 lines
No EOL
36 KiB
TypeScript

// Package Info
import packageJson from './package.json';
// Node
import path from 'path';
import fs from 'fs-extra';
import crypto from 'crypto';
// Plugins
import shlp from 'sei-helper';
import m3u8 from 'm3u8-parsed';
// Modules
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 * as reqModule from './modules/module.fetch';
import Merger, { Font, MergerInput, SubtitleInput } from './modules/module.merger';
import streamdl from './modules/hls-download';
import { console } from './modules/log';
import { domain } from './modules/module.api-urls';
import { downloaded } from './modules/module.downloadArchive';
import parseSelect from './modules/module.parseSelect';
import parseFileName, { Variable } from './modules/module.filename';
import { AvailableFilenameVars } from './modules/module.args';
// Types
import { ServiceClass } from './@types/serviceClassInterface';
import { AuthData, AuthResponse, SearchData, SearchResponse, SearchResponseItem } from './@types/messageHandler';
import { sxItem } from './crunchy';
import { DownloadedMedia } from './@types/hidiveTypes';
import { ADNSearch, ADNSearchShow } from './@types/adnSearch';
import { ADNVideo, ADNVideos } from './@types/adnVideos';
import { ADNPlayerConfig } from './@types/adnPlayerConfig';
import { ADNStreams } from './@types/adnStreams';
import { ADNSubtitles } from './@types/adnSubtitles';
export default class AnimationDigitalNetwork implements ServiceClass {
public cfg: yamlCfg.ConfigObject;
public locale: string;
private token: Record<string, any>;
private req: reqModule.Req;
private posAlignMap: { [key: string]: number } = {
'start': 1,
'end': 3
};
private lineAlignMap: { [key: string]: number } = {
'middle': 8,
'end': 4
};
private jpnStrings: string[] = [
'vostf',
'vostde'
];
private deuStrings: string[] = [
'vde'
];
private fraStrings: string[] = [
'vf'
];
private deuSubStrings: string[] = [
'vde',
'vostde'
];
private fraSubStrings: string[] = [
'vf',
'vostf'
];
constructor(private debug = false) {
this.cfg = yamlCfg.loadCfg();
this.token = yamlCfg.loadADNToken();
this.req = new reqModule.Req(domain, debug, false, 'adn');
this.locale = 'fr';
}
public async cli() {
console.info(`\n=== Multi Downloader NX ${packageJson.version} ===\n`);
const argv = yargs.appArgv(this.cfg.cli);
if (['fr', 'de'].includes(argv.locale))
this.locale = argv.locale;
if (argv.debug)
this.debug = true;
// 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) {
//Search
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.selectShow(parseInt(argv.s), argv.e, argv.but, argv.all);
if (selected.isOk) {
for (const select of selected.value) {
if (!(await this.getEpisode(select, {...argv, skipsubs: false}))) {
console.error(`Unable to download selected episode ${select.shortNumber}`);
return false;
}
}
}
return true;
} else {
console.info('No option selected or invalid value entered. Try --help.');
}
}
private generateRandomString(length: number) {
const characters = '0123456789abcdef';
let result = '';
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
return result;
}
private parseCookies(cookiesString: string | null): Record<string, string> {
const cookies: Record<string, string> = {};
if (cookiesString) {
cookiesString.split(';').forEach(cookie => {
const parts = cookie.split('=');
const name = parts.shift()?.trim();
const value = decodeURIComponent(parts.join('='));
if (name) {
cookies[name] = value;
}
});
}
return cookies;
}
private convertToSSATimestamp(timestamp: number): string {
const seconds = Math.floor(timestamp);
const centiseconds = Math.round((timestamp - seconds) * 100);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
}
public async doSearch(data: SearchData): Promise<SearchResponse> {
const limit = 12;
const offset = data.page ? data.page * limit : 0;
const searchReq = await this.req.getData(`https://gw.api.animationdigitalnetwork.fr/show/catalog?maxAgeCategory=18&offset=${offset}&limit=${limit}&search=${encodeURIComponent(data.search)}`, {
'headers': {
'X-Target-Distribution': this.locale
}
});
if (!searchReq.ok || !searchReq.res) {
console.error('Search FAILED!');
return { isOk: false, reason: new Error('Search failed. No more information provided') };
}
const searchData = await searchReq.res.json() as ADNSearch;
const searchItems: ADNSearchShow[] = [];
console.info('Search Results:');
for (const show of searchData.shows) {
searchItems.push(show);
let fullType: string;
if (show.type == 'EPS') {
fullType = `S.${show.id}`;
} else if (show.type == 'MOV' || show.type == 'OAV') {
fullType = `E.${show.id}`;
} else {
fullType = 'Unknown';
console.warn(`Unknown type ${show.type}, please report this.`);
}
console.log(`[${fullType}] ${show.title}`);
}
return { isOk: true, value: searchItems.flatMap((a): SearchResponseItem => {
return {
id: a.id+'',
image: a.image ?? '/notFound.png',
name: a.title,
rating: a.rating,
desc: a.summary
};
})};
}
public async doAuth(data: AuthData): Promise<AuthResponse> {
const authData = new URLSearchParams({
'username': data.username,
'password': data.password,
'source': 'Web',
'rememberMe': 'true'
}).toString();
const authReqOpts: reqModule.Params = {
method: 'POST',
body: authData
};
const authReq = await this.req.getData('https://gw.api.animationdigitalnetwork.fr/authentication/login', authReqOpts);
if(!authReq.ok || !authReq.res){
console.error('Authentication failed!');
return { isOk: false, reason: new Error('Authentication failed') };
}
this.token = await authReq.res.json();
yamlCfg.saveADNToken(this.token);
console.info('Authentication Success');
return { isOk: true, value: undefined };
}
public async refreshToken() {
const authReq = await this.req.getData('https://gw.api.animationdigitalnetwork.fr/authentication/refresh', {
method: 'POST',
headers: {
Authorization: `Bearer ${this.token.accessToken}`,
'X-Access-Token': this.token.accessToken,
'content-type': 'application/json'
},
body: JSON.stringify({refreshToken: this.token.refreshToken})
});
if(!authReq.ok || !authReq.res){
console.error('Token refresh failed!');
return { isOk: false, reason: new Error('Token refresh failed') };
}
this.token = await authReq.res.json();
yamlCfg.saveADNToken(this.token);
return { isOk: true, value: undefined };
}
public async getShow(id: number) {
const getShowData = await this.req.getData(`https://gw.api.animationdigitalnetwork.fr/video/show/${id}?maxAgeCategory=18&limit=-1&order=asc`, {
'headers': {
'X-Target-Distribution': this.locale
}
});
if (!getShowData.ok || !getShowData.res) {
console.error('Failed to get Series Data');
return { isOk: false };
}
const showData = await getShowData.res.json() as ADNVideos;
return { isOk: true, value: showData };
}
public async listShow(id: number) {
const show = await this.getShow(id);
if (!show.isOk || !show.value) {
console.error('Failed to list show data: Failed to get show');
return { isOk: false };
}
if (show.value.videos.length == 0) {
console.error('No episodes found!');
return { isOk: false };
}
const showData = show.value.videos[0].show;
console.info(`[S.${showData.id}] ${showData.title}`);
const specials: ADNVideo[] = [];
let episodeIndex = 0, specialIndex = 0;
for (const episode of show.value.videos) {
episode.season = episode.season+'';
const seasonNumberTitleParse = episode.season.match(/\d+/);
const seriesNumberTitleParse = episode.show.title.match(/\d+/);
const episodeNumber = parseInt(episode.shortNumber);
if (seasonNumberTitleParse && !isNaN(parseInt(seasonNumberTitleParse[0]))) {
episode.season = seasonNumberTitleParse[0];
} else if (seriesNumberTitleParse && !isNaN(parseInt(seriesNumberTitleParse[0]))) {
episode.season = seriesNumberTitleParse[0];
} else {
episode.season = '1';
}
show.value.videos[episodeIndex].season = episode.season;
if (!episodeNumber) {
specialIndex++;
const special = show.value.videos.splice(episodeIndex, 1);
special[0].shortNumber = 'S'+specialIndex;
specials.push(...special);
episodeIndex--;
} else {
console.info(` (${episode.id}) [E${episode.shortNumber}] ${episode.number} - ${episode.name}`);
}
episodeIndex++;
}
for (const special of specials) {
console.info(` (${special.id}) [${special.shortNumber}] ${special.number} - ${special.name}`);
}
show.value.videos.push(...specials);
return { isOk: true, value: show.value };
}
public async selectShow(id: number, e: string | undefined, but: boolean, all: boolean) {
const getShowData = await this.listShow(id);
if (!getShowData.isOk || !getShowData.value) {
return { isOk: false, value: [] };
}
console.info('');
console.info('-'.repeat(30));
console.info('');
const showData = getShowData.value;
const doEpsFilter = parseSelect(e as string);
const selEpsArr: ADNVideo[] = [];
for (const episode of showData.videos) {
if (
all ||
but && !doEpsFilter.isSelected([episode.shortNumber, episode.id+'']) ||
!but && doEpsFilter.isSelected([episode.shortNumber, episode.id+''])
) {
selEpsArr.push({ isSelected: true, ...episode });
console.info('%s[S%sE%s] %s',
'✓ ',
episode.season,
episode.shortNumber,
episode.name,
);
}
}
return { isOk: true, value: selEpsArr };
}
public async muxStreams(data: DownloadedMedia[], options: yargs.ArgvType) {
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({
onlyVid: hasAudioStreams ? data.filter(a => a.type === 'Video').map((a) : MergerInput => {
if (a.type === 'Subtitle')
throw new Error('Never');
return {
lang: a.lang,
path: a.path,
};
}) : [],
skipSubMux: options.skipSubMux,
inverseTrackOrder: false,
keepAllVideos: options.keepAllVideos,
onlyAudio: hasAudioStreams ? data.filter(a => a.type === 'Audio').map((a) : MergerInput => {
if (a.type === 'Subtitle')
throw new Error('Never');
return {
lang: a.lang,
path: a.path,
};
}) : [],
output: `${options.output}.${options.mp4 ? 'mp4' : 'mkv'}`,
subtitles: data.filter(a => a.type === 'Subtitle').map((a) : SubtitleInput => {
if (a.type === 'Video')
throw new Error('Never');
if (a.type === 'Audio')
throw new Error('Never');
return {
file: a.path,
language: a.language,
closedCaption: a.cc
};
}),
simul: data.filter(a => a.type === 'Video').map((a) : boolean => {
if (a.type === 'Subtitle')
throw new Error('Never');
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 => {
if (a.type === 'Subtitle')
throw new Error('Never');
return {
lang: a.lang,
path: a.path,
};
}),
chapters: data.filter(a => a.type === 'Chapters').map((a) : MergerInput => {
return {
path: a.path,
lang: a.lang
};
}),
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 async getEpisode(data: ADNVideo, options: yargs.ArgvType) {
//TODO: Move all the requests for getting the m3u8 here
const res = await this.downloadEpisode(data, 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, { ...options, output: res.fileName });
} else {
console.info('Skipping mux');
}
downloaded({
service: 'adn',
type: 's'
}, data.id+'', [data.shortNumber]);
return { isOk: res, value: undefined };
}
}
public async downloadEpisode(data: ADNVideo, options: yargs.ArgvType) {
if(!this.token.accessToken){
console.error('Authentication required!');
return;
}
if (!this.cfg.bin.ffmpeg)
this.cfg.bin = await yamlCfg.loadBinCfg();
let mediaName = '...';
let fileName;
const variables: Variable[] = [];
if(data.show.title && data.shortNumber && data.title){
mediaName = `${data.show.shortTitle ?? data.show.title} - ${data.shortNumber} - ${data.title}`;
}
const files: DownloadedMedia[] = [];
let dlFailed = false;
let dlVideoOnce = false; // Variable to save if best selected video quality was downloaded
const refreshToken = await this.refreshToken();
if (!refreshToken.isOk) {
console.error('Failed to refresh token');
return undefined;
}
const configReq = await this.req.getData(`https://gw.api.animationdigitalnetwork.fr/player/video/${data.id}/configuration`, {
headers: {
Authorization: `Bearer ${this.token.accessToken}`,
'X-Target-Distribution': this.locale
}
});
if(!configReq.ok || !configReq.res){
console.error('Player Config Request failed!');
return undefined;
}
const configuration = await configReq.res.json() as ADNPlayerConfig;
if (!configuration.player.options.user.hasAccess) {
console.error('You don\'t have access to this video!');
return undefined;
}
const tokenReq = await this.req.getData(configuration.player.options.user.refreshTokenUrl || 'https://gw.api.animationdigitalnetwork.fr/player/refresh/token', {
method: 'POST',
headers: {
'X-Player-Refresh-Token': `${configuration.player.options.user.refreshToken}`
}
});
if(!tokenReq.ok || !tokenReq.res){
console.error('Player Token Request failed!');
return undefined;
}
const token = await tokenReq.res.json() as {
refreshToken: string,
accessToken: string,
token: string
};
const linksUrl = configuration.player.options.video.url || `https://gw.api.animationdigitalnetwork.fr/player/video/${data.id}/link`;
const key = this.generateRandomString(16);
const decryptionKey = key + '7fac1178830cfe0c';
const authorization = crypto.publicEncrypt({
'key': '-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCbQrCJBRmaXM4gJidDmcpWDssg\nnumHinCLHAgS4buMtdH7dEGGEUfBofLzoEdt1jqcrCDT6YNhM0aFCqbLOPFtx9cg\n/X2G/G5bPVu8cuFM0L+ehp8s6izK1kjx3OOPH/kWzvstM5tkqgJkNyNEvHdeJl6\nKhS+IFEqwvZqgbBpKuwIDAQAB\n-----END PUBLIC KEY-----',
padding: crypto.constants.RSA_PKCS1_PADDING
}, Buffer.from(JSON.stringify({
k: key,
t: token.token
}), 'utf-8')).toString('base64');
//TODO: Add chapter support
const streamsRequest = await this.req.getData(linksUrl+'?freeWithAds=true&adaptive=true&withMetadata=true&source=Web', {
'headers': {
'X-Player-Token': authorization,
'X-Target-Distribution': this.locale
}
});
if(!streamsRequest.ok || !streamsRequest.res){
if (streamsRequest.error?.res.status == 403 || streamsRequest.res?.status == 403) {
console.error('Georestricted!');
} else {
console.error('Streams request failed!');
}
return undefined;
}
const streams = await streamsRequest.res.json() as ADNStreams;
for (const streamName in streams.links.streaming) {
let audDub: langsData.LanguageItem;
if (this.jpnStrings.includes(streamName)) {
audDub = langsData.languages.find(a=>a.code == 'jpn') as langsData.LanguageItem;
} else if (this.deuStrings.includes(streamName)) {
audDub = langsData.languages.find(a=>a.code == 'deu') as langsData.LanguageItem;
} else if (this.fraStrings.includes(streamName)) {
audDub = langsData.languages.find(a=>a.code == 'fra') as langsData.LanguageItem;
} else {
console.error(`Language ${streamName} not recognized, please report this.`);
continue;
}
if (!options.dubLang.includes(audDub.code)) {
continue;
}
console.info(`Requesting: [${data.id}] ${mediaName} (${audDub.name})`);
variables.push(...([
['title', data.title, true],
['episode', isNaN(parseFloat(data.shortNumber)) ? data.shortNumber : parseFloat(data.shortNumber), false],
['service', 'ADN', false],
['seriesTitle', data.show.shortTitle ?? data.show.title, true],
['showTitle', data.show.title, true],
['season', isNaN(parseFloat(data.season)) ? data.season : parseFloat(data.season), 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;
}));
console.info('Playlists URL: %s', streams.links.streaming[streamName].auto);
let tsFile = undefined;
if (!dlFailed && !options.novids) {
const streamPlaylistsLocationReq = await this.req.getData(streams.links.streaming[streamName].auto);
if (!streamPlaylistsLocationReq.ok || !streamPlaylistsLocationReq.res) {
console.error('CAN\'T FETCH VIDEO PLAYLIST LOCATION!');
return undefined;
}
const streamPlaylistLocation = await streamPlaylistsLocationReq.res.json() as {'location': string};
const streamPlaylistsReq = await this.req.getData(streamPlaylistLocation.location);
if (!streamPlaylistsReq.ok || !streamPlaylistsReq.res) {
console.error('CAN\'T FETCH VIDEO PLAYLISTS!');
dlFailed = true;
} else {
const streamPlaylistBody = await streamPlaylistsReq.res.text();
const streamPlaylists = m3u8(streamPlaylistBody);
const plServerList: string[] = [],
plStreams: Record<string, Record<string, string>> = {},
plQuality: {
str: string,
dim: string,
CODECS: string,
RESOLUTION: {
width: number,
height: number
}
}[] = [];
for(const pl of streamPlaylists.playlists){
// set quality
const plResolution = pl.attributes.RESOLUTION;
const plResolutionText = `${plResolution.width}x${plResolution.height}`;
// set codecs
const plCodecs = pl.attributes.CODECS;
// parse uri
const plUri = new URL(pl.uri);
let plServer = plUri.hostname;
// set server list
if (plUri.searchParams.get('cdn')){
plServer += ` (${plUri.searchParams.get('cdn')})`;
}
if (!plServerList.includes(plServer)){
plServerList.push(plServer);
}
// add to server
if (!Object.keys(plStreams).includes(plServer)){
plStreams[plServer] = {};
}
if(
plStreams[plServer][plResolutionText]
&& plStreams[plServer][plResolutionText] != pl.uri
&& typeof plStreams[plServer][plResolutionText] != 'undefined'
) {
console.error(`Non duplicate url for ${plServer} detected, please report to developer!`);
} else{
plStreams[plServer][plResolutionText] = pl.uri;
}
// set plQualityStr
const plBandwidth = Math.round(pl.attributes.BANDWIDTH/1024);
const qualityStrAdd = `${plResolutionText} (${plBandwidth}KiB/s)`;
const qualityStrRegx = new RegExp(qualityStrAdd.replace(/([:()/])/g, '\\$1'), 'm');
const qualityStrMatch = !plQuality.map(a => a.str).join('\r\n').match(qualityStrRegx);
if(qualityStrMatch){
plQuality.push({
str: qualityStrAdd,
dim: plResolutionText,
CODECS: plCodecs,
RESOLUTION: plResolution
});
}
}
options.x = options.x > plServerList.length ? 1 : options.x;
const plSelectedServer = plServerList[options.x - 1];
const plSelectedList = plStreams[plSelectedServer];
plQuality.sort((a, b) => {
const aMatch: RegExpMatchArray | never[] = a.dim.match(/[0-9]+/) || [];
const bMatch: RegExpMatchArray | never[] = b.dim.match(/[0-9]+/) || [];
return parseInt(aMatch[0]) - parseInt(bMatch[0]);
});
let quality = options.q === 0 ? plQuality.length : options.q;
if(quality > plQuality.length) {
console.warn(`The requested quality of ${options.q} is greater than the maximum ${plQuality.length}.\n[WARN] Therefor the maximum will be capped at ${plQuality.length}.`);
quality = plQuality.length;
}
// When best selected video quality is already downloaded
if(dlVideoOnce && options.dlVideoOnce) {
// Select the lowest resolution with the same codecs
while(quality !=1 && plQuality[quality - 1].CODECS == plQuality[quality - 2].CODECS) {
quality--;
}
}
const selPlUrl = plSelectedList[plQuality.map(a => a.dim)[quality - 1]] ? plSelectedList[plQuality.map(a => a.dim)[quality - 1]] : '';
console.info(`Servers available:\n\t${plServerList.join('\n\t')}`);
console.info(`Available qualities:\n\t${plQuality.map((a, ind) => `[${ind+1}] ${a.str}`).join('\n\t')}`);
if(selPlUrl != ''){
variables.push({
name: 'height',
type: 'number',
replaceWith: quality === 0 ? plQuality[plQuality.length - 1].RESOLUTION.height as number : plQuality[quality - 1].RESOLUTION.height
}, {
name: 'width',
type: 'number',
replaceWith: quality === 0 ? plQuality[plQuality.length - 1].RESOLUTION.width as number : plQuality[quality - 1].RESOLUTION.width
});
console.info(`Selected quality: ${Object.keys(plSelectedList).find(a => plSelectedList[a] === selPlUrl)} @ ${plSelectedServer}`);
console.info('Stream URL:', selPlUrl);
// TODO check filename
fileName = parseFileName(options.fileName, variables, options.numbers, options.override).join(path.sep);
const outFile = parseFileName(options.fileName + '.' + audDub.name, variables, options.numbers, options.override).join(path.sep);
console.info(`Output filename: ${outFile}`);
const chunkPage = await this.req.getData(selPlUrl);
if(!chunkPage.ok || !chunkPage.res){
console.error('CAN\'T FETCH VIDEO PLAYLIST!');
dlFailed = true;
} else {
const chunkPageBody = await chunkPage.res.text();
const chunkPlaylist = m3u8(chunkPageBody);
const totalParts = chunkPlaylist.segments.length;
const mathParts = Math.ceil(totalParts / options.partsize);
const mathMsg = `(${mathParts}*${options.partsize})`;
console.info('Total parts in stream:', totalParts, mathMsg);
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 dlStreamByPl = await new streamdl({
output: `${tsFile}.ts`,
timeout: options.timeout,
m3u8json: chunkPlaylist,
baseurl: selPlUrl.replace('playlist.m3u8',''),
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: data.image,
parent: {
title: data.show.title
},
title: data.title,
language: audDub
}) : undefined
}).download();
if (!dlStreamByPl.ok) {
console.error(`DL Stats: ${JSON.stringify(dlStreamByPl.parts)}\n`);
dlFailed = true;
}
files.push({
type: 'Video',
path: `${tsFile}.ts`,
lang: audDub
});
dlVideoOnce = true;
}
} else{
console.error('Quality not selected!\n');
dlFailed = true;
}
}
} else if (options.novids) {
console.info('Downloading skipped!');
fileName = parseFileName(options.fileName, variables, options.numbers, options.override).join(path.sep);
}
await this.sleep(options.waittime);
}
const compiledChapters: string[] = [];
if (options.chapters) {
if (streams.video.tcIntroStart) {
if (streams.video.tcIntroStart != '00:00:00') {
compiledChapters.push(
`CHAPTER${(compiledChapters.length/2)+1}=00:00:00.00`,
`CHAPTER${(compiledChapters.length/2)+1}NAME=Prologue`
);
}
compiledChapters.push(
`CHAPTER${(compiledChapters.length/2)+1}=${streams.video.tcIntroStart+'.00'}`,
`CHAPTER${(compiledChapters.length/2)+1}NAME=Opening`
);
compiledChapters.push(
`CHAPTER${(compiledChapters.length/2)+1}=${streams.video.tcIntroEnd+'.00'}`,
`CHAPTER${(compiledChapters.length/2)+1}NAME=Episode`
);
} else {
compiledChapters.push(
`CHAPTER${(compiledChapters.length/2)+1}=00:00:00.00`,
`CHAPTER${(compiledChapters.length/2)+1}NAME=Episode`
);
}
if (streams.video.tcEndingStart) {
compiledChapters.push(
`CHAPTER${(compiledChapters.length/2)+1}=${streams.video.tcEndingStart+'.00'}`,
`CHAPTER${(compiledChapters.length/2)+1}NAME=Ending Start`
);
compiledChapters.push(
`CHAPTER${(compiledChapters.length/2)+1}=${streams.video.tcEndingEnd+'.00'}`,
`CHAPTER${(compiledChapters.length/2)+1}NAME=Ending End`
);
}
if (compiledChapters.length > 0) {
try {
fileName = parseFileName(options.fileName, variables, options.numbers, options.override).join(path.sep);
const outFile = parseFileName(options.fileName, 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));
});
fs.writeFileSync(`${tsFile}.txt`, compiledChapters.join('\r\n'));
files.push({
path: `${tsFile}.txt`,
lang: langsData.languages.find(a=>a.code=='jpn'),
type: 'Chapters'
});
} catch {
console.error('Failed to write chapter file');
}
}
}
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 (Object.keys(streams.links.subtitles).length !== 0) {
const subtitlesUrlReq = await this.req.getData(streams.links.subtitles.all);
if(!subtitlesUrlReq.ok || !subtitlesUrlReq.res){
console.error('Subtitle location request failed!');
return undefined;
}
const subtitleUrl = await subtitlesUrlReq.res.json() as {'location': string};
const encryptedSubtitlesReq = await this.req.getData(subtitleUrl.location);
if(!encryptedSubtitlesReq.ok || !encryptedSubtitlesReq.res){
console.error('Subtitle request failed!');
return undefined;
}
const encryptedSubtitles = await encryptedSubtitlesReq.res.text();
const iv = Buffer.from(encryptedSubtitles.slice(0, 24), 'base64');
const derivedKey = Buffer.from(decryptionKey, 'hex');
const encryptedData = Buffer.from(encryptedSubtitles.slice(24), 'base64');
const decipher = crypto.createDecipheriv('aes-128-cbc', derivedKey, iv);
const decryptedData = Buffer.concat([decipher.update(encryptedData), decipher.final()]).toString('utf8');
let subIndex = 0;
const subtitles = JSON.parse(decryptedData) as ADNSubtitles;
if (Object.keys(subtitles).length === 0) {
console.warn('No subtitles found.');
}
for (const subName in subtitles) {
let subLang: langsData.LanguageItem;
if (this.deuSubStrings.includes(subName)) {
subLang = langsData.languages.find(a=>a.code == 'deu') as langsData.LanguageItem;
} else if (this.fraSubStrings.includes(subName)) {
subLang = langsData.languages.find(a=>a.code == 'fra') as langsData.LanguageItem;
} else {
console.error(`Language ${subName} not recognized, please report this.`);
continue;
}
if (!options.dlsubs.includes(subLang.locale) && !options.dlsubs.includes('all')) {
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);
const split = sxData.path.split(path.sep).slice(0, -1);
split.forEach((val, ind, arr) => {
const isAbsolut = path.isAbsolute(sxData.path 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));
});
sxData.language = subLang;
if(options.dlsubs.includes('all') || options.dlsubs.includes(subLang.locale)) {
let subBody = '[Script Info]'
+ '\nScriptType:V4.00+'
+ '\nWrapStyle: 0'
+ '\nPlayResX: 1280'
+ '\nPlayResY: 720'
+ '\nScaledBorderAndShadow: yes'
+ ''
+ '\n[V4+ Styles]'
+ '\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding'
+ `\nStyle: Default,${options.fontName ?? 'Arial'},${options.fontSize ?? 50},&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,1.95,0,2,0,0,70,0`
+ '\n[Events]'
+ '\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text';
for (const sub of subtitles[subName]) {
const [start, end, text, lineAlign, positionAlign] =
[sub.startTime, sub.endTime, sub.text, sub.lineAlign, sub.positionAlign];
for (const subProp in sub) {
switch (subProp) {
case 'startTime':
case 'endTime':
case 'text':
case 'lineAlign':
case 'positionAlign':
break;
default:
console.warn(`json2ass: Unknown style: ${subProp}`);
}
}
const alignment = (this.posAlignMap[positionAlign] || 2) + (this.lineAlignMap[lineAlign] || 0);
const xtext = text
.replace(/ \\N$/g, '\\N')
.replace(/\\N$/, '')
.replace(/\r/g, '')
.replace(/\n/g, '\\N')
.replace(/\\N +/g, '\\N')
.replace(/ +\\N/g, '\\N')
.replace(/(\\N)+/g, '\\N')
.replace(/<b[^>]*>([^<]*)<\/b>/g, '{\\b1}$1{\\b0}')
.replace(/<i[^>]*>([^<]*)<\/i>/g, '{\\i1}$1{\\i0}')
.replace(/<u[^>]*>([^<]*)<\/u>/g, '{\\u1}$1{\\u0}')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/<[^>]>/g, '')
.replace(/\\N$/, '')
.replace(/ +$/, '');
subBody += `\nDialogue: 0,${this.convertToSSATimestamp(start)},${this.convertToSSATimestamp(end)},Default,,0,0,0,,${(alignment !== 2 ? `{\\a${alignment}}` : '')}${xtext}`;
}
sxData.title = `${subLang.language}`;
sxData.fonts = fontsData.assFonts(subBody) as Font[];
fs.writeFileSync(sxData.path, subBody);
console.info(`Subtitle converted: ${sxData.file}`);
files.push({
type: 'Subtitle',
...sxData as sxItem,
cc: false
});
}
subIndex++;
}
} else {
console.warn('Couldn\'t find subtitles.');
}
} else{
console.info('Subtitles downloading skipped!');
}
return {
error: dlFailed,
data: files,
fileName: fileName ? (path.isAbsolute(fileName) ? fileName : path.join(this.cfg.dir.content, fileName)) || './unknown' : './unknown'
};
}
public sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
}