mirror of
https://github.com/anidl/multi-downloader-nx.git
synced 2026-01-11 12:00:22 +00:00
974 lines
34 KiB
TypeScript
974 lines
34 KiB
TypeScript
// Package Info
|
|
import packageJson from './package.json';
|
|
|
|
// Node
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import crypto from 'crypto';
|
|
|
|
// Plugins
|
|
import { Parser } from 'm3u8-parser';
|
|
|
|
// 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 { downloaded } from './modules/module.downloadArchive';
|
|
import parseSelect from './modules/module.parseSelect';
|
|
import parseFileName, { Variable } from './modules/module.filename';
|
|
import { AvailableFilenameVars } from './modules/module.args';
|
|
import Helper from './modules/module.helper';
|
|
|
|
// 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';
|
|
import { FetchParams } from './modules/module.fetch';
|
|
|
|
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();
|
|
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 Helper.question('[Q] LOGIN/EMAIL: ')),
|
|
password: argv.password ?? (await Helper.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.com/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 = JSON.stringify({
|
|
username: data.username,
|
|
password: data.password,
|
|
source: 'Web'
|
|
});
|
|
const authReqOpts: FetchParams = {
|
|
method: 'POST',
|
|
body: authData,
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-target-distribution': this.locale
|
|
},
|
|
useProxy: true
|
|
};
|
|
const authReq = await this.req.getData('https://gw.api.animationdigitalnetwork.com/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.com/authentication/refresh', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${this.token.accessToken}`,
|
|
'X-Access-Token': this.token.accessToken,
|
|
'content-type': 'application/json',
|
|
'x-target-distribution': this.locale
|
|
},
|
|
body: JSON.stringify({ refreshToken: this.token.refreshToken }),
|
|
useProxy: true
|
|
});
|
|
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.com/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[] = [];
|
|
const ncs: ADNVideo[] = [];
|
|
let episodeIndex = 0,
|
|
specialIndex = 0,
|
|
ncIndex = 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;
|
|
show.value.videos[episodeIndex].shortNumber = episodeIndex + '';
|
|
if (!episodeNumber) {
|
|
specialIndex++;
|
|
episode.shortNumber = 'S' + specialIndex;
|
|
specials.push(episode);
|
|
episodeIndex--;
|
|
} else if (episode.number.includes('(NC)')) {
|
|
ncIndex++;
|
|
episode.shortNumber = 'NC' + ncIndex;
|
|
ncs.push(episode);
|
|
episodeIndex--;
|
|
} else {
|
|
console.info(` (${episode.id}) [E${episode.shortNumber}] ${episode.number} - ${episode.name}`);
|
|
}
|
|
episodeIndex++;
|
|
}
|
|
for (const special of specials) {
|
|
console.info(` (Special) (${special.id}) [${special.shortNumber}] ${special.number} - ${special.name}`);
|
|
show.value.videos.splice(
|
|
show.value.videos.findIndex((i) => i.id === special.id),
|
|
1
|
|
);
|
|
}
|
|
for (const nc of ncs) {
|
|
console.info(` (NC) (${nc.id}) [${nc.shortNumber}] ${nc.number} - ${nc.name}`);
|
|
show.value.videos.splice(
|
|
show.value.videos.findIndex((i) => i.id === nc.id),
|
|
1
|
|
);
|
|
}
|
|
show.value.videos.push(...specials);
|
|
show.value.videos.push(...ncs);
|
|
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 (options.subdlfailed && options.skipMuxOnSubFail) return console.info('Skip muxing since some subtitles failed to download');
|
|
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: boolean = 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.com/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.com/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.com/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.name ?? 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();
|
|
|
|
// Init parser
|
|
const parser = new Parser();
|
|
|
|
// Parse M3U8
|
|
parser.push(streamPlaylistBody);
|
|
parser.end();
|
|
|
|
const streamPlaylists = parser.manifest;
|
|
if (!streamPlaylists) throw Error('Failed to parse M3U8');
|
|
|
|
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 ?? 0 / 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 as number)
|
|
},
|
|
{
|
|
name: 'width',
|
|
type: 'number',
|
|
replaceWith: quality === 0 ? (plQuality[plQuality.length - 1].RESOLUTION?.width as number) : (plQuality[quality - 1].RESOLUTION?.width as number)
|
|
}
|
|
);
|
|
|
|
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();
|
|
|
|
// Init parser
|
|
const parser = new Parser();
|
|
|
|
// Parse M3U8
|
|
parser.push(chunkPageBody);
|
|
parser.end();
|
|
|
|
const chunkPlaylist = parser.manifest;
|
|
if (!chunkPlaylist) throw Error('Failed to parse M3U8');
|
|
|
|
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 dirName = path.dirname(tsFile);
|
|
if (!fs.existsSync(dirName)) {
|
|
fs.mkdirSync(dirName, { recursive: true });
|
|
}
|
|
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 dirName = path.dirname(tsFile);
|
|
if (!fs.existsSync(dirName)) {
|
|
fs.mkdirSync(dirName, { recursive: true });
|
|
}
|
|
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!');
|
|
options.subdlfailed = true;
|
|
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!');
|
|
options.subdlfailed = true;
|
|
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.');
|
|
options.subdlfailed = true;
|
|
}
|
|
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);
|
|
if (path.isAbsolute(sxData.file)) {
|
|
sxData.path = sxData.file;
|
|
} else {
|
|
sxData.path = path.join(this.cfg.dir.content, sxData.file);
|
|
}
|
|
const dirName = path.dirname(sxData.path);
|
|
if (!fs.existsSync(dirName)) {
|
|
fs.mkdirSync(dirName, { recursive: true });
|
|
}
|
|
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(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/&/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.");
|
|
options.subdlfailed = true;
|
|
}
|
|
} 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);
|
|
});
|
|
}
|
|
}
|