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
847 lines
No EOL
34 KiB
TypeScript
847 lines
No EOL
34 KiB
TypeScript
// Package Info
|
|
import packageJson from './package.json';
|
|
|
|
// Node
|
|
import path from 'path';
|
|
import fs from 'fs-extra';
|
|
|
|
// Plugins
|
|
import shlp from 'sei-helper';
|
|
|
|
// 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 getKeys, { canDecrypt } from './modules/widevine';
|
|
import streamdl, { M3U8Json } from './modules/hls-download';
|
|
import { exec } from './modules/sei-helper-fixes';
|
|
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';
|
|
import { parse } from './modules/module.transform-mpd';
|
|
|
|
// Types
|
|
import type { ServiceClass } from './@types/serviceClassInterface';
|
|
import type { AuthData, AuthResponse, SearchData, SearchResponse, SearchResponseItem } from './@types/messageHandler';
|
|
import type { AOSearchResult, AnimeOnegaiSearch } from './@types/animeOnegaiSearch';
|
|
import type { AnimeOnegaiSeries } from './@types/animeOnegaiSeries';
|
|
import type { AnimeOnegaiSeasons, Episode } from './@types/animeOnegaiSeasons';
|
|
import type { DownloadedMedia, DownloadedMediaMap, sxItem } from './@types/downloaderTypes';
|
|
import type { AnimeOnegaiStream } from './@types/animeOnegaiStream';
|
|
|
|
type parsedMultiDubDownload = {
|
|
data: {
|
|
lang: string,
|
|
videoId: string
|
|
episode: Episode
|
|
}[],
|
|
seriesTitle: string,
|
|
seasonTitle: string,
|
|
episodeTitle: string,
|
|
episodeNumber: number,
|
|
seasonNumber: number,
|
|
seriesID: number,
|
|
seasonID: number,
|
|
image: string,
|
|
}
|
|
|
|
export default class AnimeOnegai implements ServiceClass {
|
|
public cfg: yamlCfg.ConfigObject;
|
|
private token: Record<string, any>;
|
|
private req: reqModule.Req;
|
|
public locale: string;
|
|
public jpnStrings: string[] = [
|
|
'Japonés con Subtítulos en Español',
|
|
'Japonés con Subtítulos en Portugués',
|
|
'Japonês com legendas em espanhol',
|
|
'Japonês com legendas em português',
|
|
'Japonés'
|
|
];
|
|
public spaStrings: string[] = [
|
|
'Doblaje en Español',
|
|
'Dublagem em espanhol',
|
|
'Español',
|
|
];
|
|
public porStrings: string[] = [
|
|
'Doblaje en Portugués',
|
|
'Dublagem em português'
|
|
];
|
|
private defaultOptions: RequestInit = {
|
|
'headers': {
|
|
'origin': 'https://www.animeonegai.com',
|
|
'referer': 'https://www.animeonegai.com/',
|
|
}
|
|
};
|
|
|
|
constructor(private debug = false) {
|
|
this.cfg = yamlCfg.loadCfg();
|
|
this.token = yamlCfg.loadAOToken();
|
|
this.req = new reqModule.Req(domain, debug, false, 'ao');
|
|
this.locale = 'es';
|
|
}
|
|
|
|
public async cli() {
|
|
console.info(`\n=== Multi Downloader NX ${packageJson.version} ===\n`);
|
|
const argv = yargs.appArgv(this.cfg.cli);
|
|
if (['pt', 'es'].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, argv);
|
|
if (selected.isOk) {
|
|
for (const select of selected.value) {
|
|
if (!(await this.downloadEpisode(select, {...argv, skipsubs: false}))) {
|
|
console.error(`Unable to download selected episode ${select.episodeNumber}`);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
} else if (argv.token) {
|
|
this.token = {token: argv.token};
|
|
yamlCfg.saveAOToken(this.token);
|
|
console.info('Saved token');
|
|
} else {
|
|
console.info('No option selected or invalid value entered. Try --help.');
|
|
}
|
|
}
|
|
|
|
public async doSearch(data: SearchData): Promise<SearchResponse> {
|
|
const searchReq = await this.req.getData(`https://api.animeonegai.com/v1/search/algolia/${encodeURIComponent(data.search)}?lang=${this.locale}`, this.defaultOptions);
|
|
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 AnimeOnegaiSearch;
|
|
const searchItems: AOSearchResult[] = [];
|
|
console.info('Search Results:');
|
|
for (const hit of searchData.list) {
|
|
searchItems.push(hit);
|
|
let fullType: string;
|
|
if (hit.asset_type == 2) {
|
|
fullType = `S.${hit.ID}`;
|
|
} else if (hit.asset_type == 1) {
|
|
fullType = `E.${hit.ID}`;
|
|
} else {
|
|
fullType = 'Unknown';
|
|
console.warn(`Unknown asset type ${hit.asset_type}, please report this.`);
|
|
}
|
|
console.log(`[${fullType}] ${hit.title}`);
|
|
}
|
|
return { isOk: true, value: searchItems.filter(a => a.asset_type == 2).flatMap((a): SearchResponseItem => {
|
|
return {
|
|
id: a.ID+'',
|
|
image: a.poster ?? '/notFound.png',
|
|
name: a.title,
|
|
rating: a.likes,
|
|
desc: a.description
|
|
};
|
|
})};
|
|
}
|
|
|
|
public async doAuth(data: AuthData): Promise<AuthResponse> {
|
|
data;
|
|
console.error('Authentication not possible, manual authentication required due to recaptcha. In order to login use the --token flag. You can get the token by logging into the website, and opening the dev console and running the command "localStorage.ott_token"');
|
|
return { isOk: false, reason: new Error('Authentication not possible, manual authentication required do to recaptcha.') };
|
|
}
|
|
|
|
public async getShow(id: number) {
|
|
const getSeriesData = await this.req.getData(`https://api.animeonegai.com/v1/asset/${id}?lang=${this.locale}`, this.defaultOptions);
|
|
if (!getSeriesData.ok || !getSeriesData.res) {
|
|
console.error('Failed to get Show Data');
|
|
return { isOk: false };
|
|
}
|
|
const seriesData = await getSeriesData.res.json() as AnimeOnegaiSeries;
|
|
|
|
const getSeasonData = await this.req.getData(`https://api.animeonegai.com/v1/asset/content/${id}?lang=${this.locale}`, this.defaultOptions);
|
|
if (!getSeasonData.ok || !getSeasonData.res) {
|
|
console.error('Failed to get Show Data');
|
|
return { isOk: false };
|
|
}
|
|
const seasonData = await getSeasonData.res.json() as AnimeOnegaiSeasons[];
|
|
|
|
return { isOk: true, data: seriesData, seasons: seasonData };
|
|
}
|
|
|
|
public async listShow(id: number, outputEpisode: boolean = true) {
|
|
const series = await this.getShow(id);
|
|
if (!series.isOk || !series.data) {
|
|
console.error('Failed to list series data: Failed to get series');
|
|
return { isOk: false };
|
|
}
|
|
console.info(`[S.${series.data.ID}] ${series.data.title} (${series.seasons.length} Seasons)`);
|
|
if (series.seasons.length === 0 && series.data.asset_type !== 1) {
|
|
console.info(' No Seasons found!');
|
|
return { isOk: false };
|
|
}
|
|
const episodes: { [key: string]: (Episode & { lang?: string })[] } = {};
|
|
for (const season of series.seasons) {
|
|
let lang: string | undefined = undefined;
|
|
if (this.jpnStrings.includes(season.name.trim())) lang = 'ja';
|
|
else if (this.porStrings.includes(season.name.trim())) lang = 'pt';
|
|
else if (this.spaStrings.includes(season.name.trim())) lang = 'es';
|
|
else {lang = 'unknown';console.error(`Language (${season.name.trim()}) not known, please report this!`);}
|
|
for (const episode of season.list) {
|
|
if (!episodes[episode.number]) {
|
|
episodes[episode.number] = [];
|
|
}
|
|
/*if (!episodes[episode.number].find(a=>a.lang == lang))*/ episodes[episode.number].push({...episode, lang});
|
|
}
|
|
}
|
|
//Item is movie, lets define it manually
|
|
if (series.data.asset_type === 1 && series.seasons.length === 0) {
|
|
let lang: string | undefined;
|
|
if (this.jpnStrings.some(str => series.data.title.includes(str))) lang = 'ja';
|
|
else if (this.porStrings.some(str => series.data.title.includes(str))) lang = 'pt';
|
|
else if (this.spaStrings.some(str => series.data.title.includes(str))) lang = 'es';
|
|
else {lang = 'unknown';console.error('Language could not be parsed from movie title, please report this!');}
|
|
episodes[1] = [{
|
|
'video_entry': series.data.video_entry,
|
|
'number': 1,
|
|
'season_id': 1,
|
|
'name': series.data.title,
|
|
'ID': series.data.ID,
|
|
'CreatedAt': series.data.CreatedAt,
|
|
'DeletedAt': series.data.DeletedAt,
|
|
'UpdatedAt': series.data.UpdatedAt,
|
|
'active': series.data.active,
|
|
'description': series.data.description,
|
|
'age_restriction': series.data.age_restriction,
|
|
'asset_id': series.data.ID,
|
|
'ending': null,
|
|
'entry': series.data.entry,
|
|
'stream_url': series.data.stream_url,
|
|
'skip_intro': null,
|
|
'thumbnail': series.data.bg,
|
|
'open_free': false,
|
|
lang
|
|
}]; // as unknown as (Episode & { lang?: string })[];
|
|
// The above needs to be uncommented if the episode number should be M1 instead of 1
|
|
}
|
|
//Enable to output episodes seperate from selection
|
|
if (outputEpisode) {
|
|
for (const episodeKey in episodes) {
|
|
const episode = episodes[episodeKey][0];
|
|
const langs = Array.from(new Set(episodes[episodeKey].map(a=>a.lang)));
|
|
console.info(` [E.${episode.ID}] E${episode.number} - ${episode.name} (${langs.map(a=>{
|
|
if (a) return langsData.languages.find(b=>b.ao_locale === a)?.name;
|
|
return 'Unknown';
|
|
}).join(', ')})`);
|
|
}
|
|
}
|
|
return { isOk: true, value: episodes, series: series };
|
|
}
|
|
|
|
public async selectShow(id: number, e: string | undefined, but: boolean, all: boolean, options: yargs.ArgvType) {
|
|
const getShowData = await this.listShow(id, false);
|
|
if (!getShowData.isOk || !getShowData.value) {
|
|
return { isOk: false, value: [] };
|
|
}
|
|
//const showData = getShowData.value;
|
|
const doEpsFilter = parseSelect(e as string);
|
|
// build selected episodes
|
|
const selEpsArr: parsedMultiDubDownload[] = [];
|
|
const episodes = getShowData.value;
|
|
const seasonNumberTitleParse = getShowData.series.data.title.match(/\d+/);
|
|
const seasonNumber = seasonNumberTitleParse ? parseInt(seasonNumberTitleParse[0]) : 1;
|
|
for (const episodeKey in getShowData.value) {
|
|
const episode = episodes[episodeKey][0];
|
|
const selectedLangs: string[] = [];
|
|
const selected: {
|
|
lang: string,
|
|
videoId: string
|
|
episode: Episode
|
|
}[] = [];
|
|
for (const episode of episodes[episodeKey]) {
|
|
const lang = langsData.languages.find(a=>a.ao_locale === episode.lang);
|
|
let isSelected = false;
|
|
if (typeof selected.find(a=>a.lang == episode.lang) == 'undefined') {
|
|
if (options.dubLang.includes(lang?.code ?? 'Unknown')) {
|
|
if ((but && !doEpsFilter.isSelected([episode.number+'', episode.ID+''])) || all || (!but && doEpsFilter.isSelected([episode.number+'', episode.ID+'']))) {
|
|
isSelected = true;
|
|
selected.push({lang: episode.lang as string, videoId: episode.video_entry, episode: episode });
|
|
}
|
|
}
|
|
const selectedLang = isSelected ? `✓ ${lang?.name ?? 'Unknown'}` : `${lang?.name ?? 'Unknown'}`;
|
|
if (!selectedLangs.includes(selectedLang)) {
|
|
selectedLangs.push(selectedLang);
|
|
}
|
|
}
|
|
}
|
|
if (selected.length > 0) {
|
|
selEpsArr.push({
|
|
'data': selected,
|
|
'seasonNumber': seasonNumber,
|
|
'episodeNumber': episode.number,
|
|
'episodeTitle': episode.name,
|
|
'image': episode.thumbnail,
|
|
'seasonID': episode.season_id,
|
|
'seasonTitle': getShowData.series.data.title,
|
|
'seriesTitle': getShowData.series.data.title,
|
|
'seriesID': getShowData.series.data.ID
|
|
});
|
|
}
|
|
console.info(` [S${seasonNumber}E${episode.number}] - ${episode.name} (${selectedLangs.join(', ')})`);
|
|
}
|
|
return { isOk: true, value: selEpsArr, showData: getShowData.series };
|
|
}
|
|
|
|
public async downloadEpisode(data: parsedMultiDubDownload, options: yargs.ArgvType): Promise<boolean> {
|
|
const res = await this.downloadMediaList(data, options);
|
|
if (res === undefined || res.error) {
|
|
return false;
|
|
} else {
|
|
if (!options.skipmux) {
|
|
await this.muxStreams(res.data, res.mediaMap, { ...options, output: res.fileName });
|
|
} else {
|
|
console.info('Skipping mux');
|
|
}
|
|
downloaded({
|
|
service: 'ao',
|
|
type: 's'
|
|
}, data.seasonID+'', [data.episodeNumber+'']);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
public async muxStreams(data: DownloadedMedia[], mediaMap: DownloadedMediaMap[], 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({
|
|
mediaMap,
|
|
onlyVid: hasAudioStreams ? data.filter(a => a.type === 'Video').map((a) : MergerInput => {
|
|
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 => {
|
|
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 async downloadMediaList(medias: parsedMultiDubDownload, options: yargs.ArgvType) : Promise<{
|
|
data: DownloadedMedia[],
|
|
mediaMap: DownloadedMediaMap[],
|
|
fileName: string,
|
|
error: boolean
|
|
} | undefined> {
|
|
if(!this.token.token){
|
|
console.error('Authentication required!');
|
|
return;
|
|
}
|
|
|
|
if (!this.cfg.bin.ffmpeg)
|
|
this.cfg.bin = await yamlCfg.loadBinCfg();
|
|
|
|
let mediaName = '...';
|
|
let fileName;
|
|
const variables: Variable[] = [];
|
|
if(medias.seasonTitle && medias.episodeNumber && medias.episodeTitle){
|
|
mediaName = `${medias.seasonTitle} - ${medias.episodeNumber} - ${medias.episodeTitle}`;
|
|
}
|
|
|
|
const files: DownloadedMedia[] = [];
|
|
const mediaMap: DownloadedMediaMap[] = [];
|
|
|
|
let subIndex = 0;
|
|
let dlFailed = false;
|
|
let dlVideoOnce = false; // Variable to save if best selected video quality was downloaded
|
|
|
|
for (const media of medias.data) {
|
|
console.info(`Requesting: [E.${media.episode.ID}] ${mediaName}`);
|
|
|
|
const fileMap: DownloadedMediaMap = {
|
|
version: media.episode.ID.toString(),
|
|
files: []
|
|
};
|
|
|
|
const AuthHeaders = {
|
|
headers: {
|
|
Authorization: `Bearer ${this.token.token}`,
|
|
'Referer': 'https://www.animeonegai.com/',
|
|
'Origin': 'https://www.animeonegai.com',
|
|
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
'Content-Type': 'application/json'
|
|
}
|
|
};
|
|
|
|
const playbackReq = await this.req.getData(`https://api.animeonegai.com/v1/media/${media.videoId}?lang=${this.locale}`, AuthHeaders);
|
|
if(!playbackReq.ok || !playbackReq.res){
|
|
console.error('Request Stream URLs FAILED!');
|
|
return undefined;
|
|
}
|
|
const streamData = await playbackReq.res.json() as AnimeOnegaiStream;
|
|
|
|
variables.push(...([
|
|
['title', medias.episodeTitle, true],
|
|
['episode', medias.episodeNumber, false],
|
|
['service', 'AO', false],
|
|
['seriesTitle', medias.seriesTitle, true],
|
|
['showTitle', medias.seasonTitle, true],
|
|
['season', medias.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;
|
|
}));
|
|
|
|
if (!canDecrypt) {
|
|
console.warn('Decryption not enabled!');
|
|
}
|
|
|
|
const lang = langsData.languages.find(a=>a.ao_locale == media.lang) as langsData.LanguageItem;
|
|
if (!lang) {
|
|
console.error(`Unable to find language for code ${media.lang}`);
|
|
return;
|
|
}
|
|
let tsFile = undefined;
|
|
|
|
if (!streamData.dash) {
|
|
console.error('You don\'t have access to download this content');
|
|
continue;
|
|
}
|
|
|
|
console.info('Playlists URL: %s', streamData.dash);
|
|
|
|
if(!dlFailed && !(options.novids && options.noaudio)){
|
|
const streamPlaylistsReq = await this.req.getData(streamData.dash, AuthHeaders);
|
|
if(!streamPlaylistsReq.ok || !streamPlaylistsReq.res){
|
|
console.error('CAN\'T FETCH VIDEO PLAYLISTS!');
|
|
dlFailed = true;
|
|
} else {
|
|
const streamPlaylistBody = (await streamPlaylistsReq.res.text()).replace(/<BaseURL>(.*?)<\/BaseURL>/g, `<BaseURL>${streamData.dash.split('/dash/')[0]}/dash/$1</BaseURL>`);
|
|
//Parse MPD Playlists
|
|
const streamPlaylists = await parse(streamPlaylistBody, lang as langsData.LanguageItem, streamData.dash.split('/dash/')[0]+'/dash/');
|
|
|
|
//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/1024)}kB/s`
|
|
};
|
|
});
|
|
|
|
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--;
|
|
|
|
let chosenAudioQuality = options.q === 0 ? audios.length : options.q;
|
|
if(chosenAudioQuality > audios.length) {
|
|
chosenAudioQuality = audios.length;
|
|
}
|
|
chosenAudioQuality--;
|
|
|
|
const chosenVideoSegments = videos[chosenVideoQuality];
|
|
const chosenAudioSegments = audios[chosenAudioQuality];
|
|
|
|
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
|
|
});
|
|
|
|
console.info(`Selected quality: \n\tVideo: ${chosenVideoSegments.resolutionText}\n\tAudio: ${chosenAudioSegments.resolutionText}\n\tServer: ${selectedServer}`);
|
|
//console.info('Stream URL:', chosenVideoSegments.segments[0].uri);
|
|
// TODO check filename
|
|
fileName = parseFileName(options.fileName, variables, options.numbers, options.override).join(path.sep);
|
|
const outFile = parseFileName(options.fileName + '.' + lang.name, variables, options.numbers, options.override).join(path.sep);
|
|
const tempFile = parseFileName(`temp-${media.videoId}`, variables, options.numbers, options.override).join(path.sep);
|
|
const tempTsFile = path.isAbsolute(tempFile as string) ? tempFile : path.join(this.cfg.dir.content, tempFile);
|
|
|
|
let [audioDownloaded, videoDownloaded] = [false, false];
|
|
|
|
// When best selected video quality is already downloaded
|
|
if(dlVideoOnce && options.dlVideoOnce) {
|
|
console.info('Already downloaded video, skipping video download...');
|
|
} else if (options.novids) {
|
|
console.info('Skipping video download...');
|
|
} else {
|
|
//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);
|
|
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 videoJson: M3U8Json = {
|
|
segments: chosenVideoSegments.segments
|
|
};
|
|
try {
|
|
const videoDownload = await new streamdl({
|
|
output: chosenVideoSegments.pssh ? `${tempTsFile}.video.enc.mp4` : `${tsFile}.video.mp4`,
|
|
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(outFile) ? outFile.slice(this.cfg.dir.content.length) : outFile}`,
|
|
image: medias.image,
|
|
parent: {
|
|
title: medias.seasonTitle
|
|
},
|
|
title: medias.episodeTitle,
|
|
language: lang
|
|
}) : undefined
|
|
}).download();
|
|
if(!videoDownload.ok){
|
|
console.error(`DL Stats: ${JSON.stringify(videoDownload.parts)}\n`);
|
|
dlFailed = true;
|
|
} else {
|
|
dlVideoOnce = true;
|
|
videoDownloaded = true;
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
dlFailed = true;
|
|
}
|
|
}
|
|
|
|
if (chosenAudioSegments && !options.noaudio) {
|
|
//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);
|
|
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
|
|
};
|
|
try {
|
|
const audioDownload = await new streamdl({
|
|
output: chosenAudioSegments.pssh ? `${tempTsFile}.audio.enc.mp4` : `${tsFile}.audio.mp4`,
|
|
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: medias.image,
|
|
parent: {
|
|
title: medias.seasonTitle
|
|
},
|
|
title: medias.episodeTitle,
|
|
language: lang
|
|
}) : undefined
|
|
}).download();
|
|
if(!audioDownload.ok){
|
|
console.error(`DL Stats: ${JSON.stringify(audioDownload.parts)}\n`);
|
|
dlFailed = true;
|
|
} else {
|
|
audioDownloaded = true;
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
dlFailed = true;
|
|
}
|
|
} else if (options.noaudio) {
|
|
console.info('Skipping audio download...');
|
|
}
|
|
|
|
//Handle Decryption if needed
|
|
if ((chosenVideoSegments.pssh || chosenAudioSegments.pssh) && (videoDownloaded || audioDownloaded)) {
|
|
console.info('Decryption Needed, attempting to decrypt');
|
|
const encryptionKeys = await getKeys(chosenVideoSegments.pssh, streamData.widevine_proxy, {});
|
|
if (encryptionKeys.length == 0) {
|
|
console.error('Failed to get encryption keys');
|
|
return undefined;
|
|
}
|
|
/*const keys = {} as Record<string, string>;
|
|
encryptionKeys.forEach(function(key) {
|
|
keys[key.kid] = key.key;
|
|
});*/
|
|
|
|
if (this.cfg.bin.mp4decrypt) {
|
|
const commandBase = `--show-progress --key ${encryptionKeys[1].kid}:${encryptionKeys[1].key} `;
|
|
const commandVideo = commandBase+`"${tempTsFile}.video.enc.mp4" "${tempTsFile}.video.mp4"`;
|
|
const commandAudio = commandBase+`"${tempTsFile}.audio.enc.mp4" "${tempTsFile}.audio.mp4"`;
|
|
|
|
if (videoDownloaded) {
|
|
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.mp4`, `${tsFile}.video.enc.mp4`);
|
|
return undefined;
|
|
} else {
|
|
console.info('Decryption done for video');
|
|
if (!options.nocleanup) {
|
|
fs.removeSync(`${tempTsFile}.video.enc.mp4`);
|
|
}
|
|
fs.renameSync(`${tempTsFile}.video.mp4`, `${tsFile}.video.mp4`);
|
|
files.push({
|
|
type: 'Video',
|
|
path: `${tsFile}.video.mp4`,
|
|
lang: lang
|
|
});
|
|
fileMap.files.push({
|
|
type: 'Video',
|
|
path: `${tsFile}.video.mp4`,
|
|
lang: lang
|
|
});
|
|
}
|
|
}
|
|
|
|
if (audioDownloaded) {
|
|
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.mp4`, `${tsFile}.audio.enc.mp4`);
|
|
return undefined;
|
|
} else {
|
|
if (!options.nocleanup) {
|
|
fs.removeSync(`${tempTsFile}.audio.enc.mp4`);
|
|
}
|
|
fs.renameSync(`${tempTsFile}.audio.mp4`, `${tsFile}.audio.mp4`);
|
|
files.push({
|
|
type: 'Audio',
|
|
path: `${tsFile}.audio.mp4`,
|
|
lang: lang
|
|
});
|
|
fileMap.files.push({
|
|
type: 'Audio',
|
|
path: `${tsFile}.audio.mp4`,
|
|
lang: lang
|
|
});
|
|
console.info('Decryption done for audio');
|
|
}
|
|
}
|
|
} else {
|
|
console.warn('mp4decrypt not found, files need decryption. Decryption Keys:', encryptionKeys);
|
|
}
|
|
} else {
|
|
if (videoDownloaded) {
|
|
files.push({
|
|
type: 'Video',
|
|
path: `${tsFile}.video.mp4`,
|
|
lang: lang
|
|
});
|
|
fileMap.files.push({
|
|
type: 'Video',
|
|
path: `${tsFile}.video.mp4`,
|
|
lang: lang
|
|
});
|
|
}
|
|
if (audioDownloaded) {
|
|
files.push({
|
|
type: 'Audio',
|
|
path: `${tsFile}.audio.mp4`,
|
|
lang: lang
|
|
});
|
|
fileMap.files.push({
|
|
type: 'Audio',
|
|
path: `${tsFile}.audio.mp4`,
|
|
lang: lang
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} else if (options.novids && options.noaudio) {
|
|
fileName = parseFileName(options.fileName, variables, options.numbers, options.override).join(path.sep);
|
|
}
|
|
|
|
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(streamData.subtitles.length > 0) {
|
|
for(const sub of streamData.subtitles) {
|
|
const subLang = langsData.languages.find(a => a.ao_locale === sub.lang);
|
|
if (!subLang) {
|
|
console.warn(`Language not found for subtitle language: ${sub.lang}, 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)) && sub.url.includes('.ass')) {
|
|
const getSubtitle = await this.req.getData(sub.url, AuthHeaders);
|
|
if (getSubtitle.ok && getSubtitle.res) {
|
|
console.info(`Subtitle Downloaded: ${sub.url}`);
|
|
const sBody = await getSubtitle.res.text();
|
|
sxData.title = `${subLang.language}`;
|
|
sxData.fonts = fontsData.assFonts(sBody) as Font[];
|
|
fs.writeFileSync(sxData.path, sBody);
|
|
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);
|
|
await this.sleep(options.waittime);
|
|
}
|
|
return {
|
|
error: dlFailed,
|
|
data: files,
|
|
mediaMap,
|
|
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);
|
|
});
|
|
}
|
|
} |