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