// 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; 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 { const cookies: Record = {}; 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 { 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 { 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> = {}, 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 = {}; 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>/g, '{\\b1}$1{\\b0}') .replace(/]*>([^<]*)<\/i>/g, '{\\i1}$1{\\i0}') .replace(/]*>([^<]*)<\/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); }); } }