diff --git a/.gitignore b/.gitignore index 2a83dc4..9ac97e5 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ cr_token.yml hd_profile.yml hd_sess.yml hd_token.yml +hd_new_token.yml archive.json guistate.json fonts diff --git a/@types/newHidiveEpisode.d.ts b/@types/newHidiveEpisode.d.ts new file mode 100644 index 0000000..90fe2c7 --- /dev/null +++ b/@types/newHidiveEpisode.d.ts @@ -0,0 +1,43 @@ +export interface NewHidiveEpisode { + description: string; + duration: number; + title: string; + categories: string[]; + contentDownload: ContentDownload; + favourite: boolean; + subEvents: any[]; + thumbnailUrl: string; + longDescription: string; + posterUrl: string; + offlinePlaybackLanguages: string[]; + externalAssetId: string; + maxHeight: number; + rating: Rating; + episodeInformation: EpisodeInformation; + id: number; + accessLevel: string; + playerUrlCallback: string; + thumbnailsPreview: string; + displayableTags: any[]; + plugins: any[]; + watchStatus: string; + computedReleases: any[]; + licences: any[]; + type: string; +} + +export interface ContentDownload { + permission: string; + period: string; +} + +export interface EpisodeInformation { + seasonNumber: number; + episodeNumber: number; + season: number; +} + +export interface Rating { + rating: string; + descriptors: any[]; +} \ No newline at end of file diff --git a/@types/newHidivePlayback.d.ts b/@types/newHidivePlayback.d.ts new file mode 100644 index 0000000..f25c863 --- /dev/null +++ b/@types/newHidivePlayback.d.ts @@ -0,0 +1,33 @@ +export interface NewHidivePlayback { + watermark: null; + skipMarkers: any[]; + annotations: null; + dash: Format[]; + hls: Format[]; +} + +export interface Format { + subtitles: Subtitle[]; + url: string; + drm: DRM; +} + +export interface DRM { + encryptionMode: string; + containerType: string; + jwtToken: string; + url: string; + keySystems: string[]; +} + +export interface Subtitle { + format: Formats; + language: string; + url: string; +} + +export enum Formats { + Scc = 'scc', + Srt = 'srt', + Vtt = 'vtt', +} diff --git a/@types/newHidiveSearch.d.ts b/@types/newHidiveSearch.d.ts new file mode 100644 index 0000000..60c70c2 --- /dev/null +++ b/@types/newHidiveSearch.d.ts @@ -0,0 +1,91 @@ +export interface NewHidiveSearch { + results: Result[]; +} + +export interface Result { + hits: Hit[]; + nbHits: number; + page: number; + nbPages: number; + hitsPerPage: number; + exhaustiveNbHits: boolean; + exhaustiveTypo: boolean; + exhaustive: Exhaustive; + query: string; + params: string; + index: string; + renderingContent: RenderingContent; + processingTimeMS: number; + processingTimingsMS: ProcessingTimingsMS; + serverTimeMS: number; +} + +export interface Exhaustive { + nbHits: boolean; + typo: boolean; +} + +export interface Hit { + type: string; + weight: number; + id: number; + name: string; + description: string; + meta: RenderingContent; + coverUrl: string; + smallCoverUrl: string; + seasonsCount: number; + tags: string[]; + localisations: HitLocalisations; + ratings: Ratings; + objectID: string; + _highlightResult: HighlightResult; +} + +export interface HighlightResult { + name: Description; + description: Description; + tags: Description[]; + localisations: HighlightResultLocalisations; +} + +export interface Description { + value: string; + matchLevel: string; + matchedWords: string[]; + fullyHighlighted?: boolean; +} + +export interface HighlightResultLocalisations { + en_US: PurpleEnUS; +} + +export interface PurpleEnUS { + title: Description; + description: Description; +} + +export interface HitLocalisations { + [language: string]: HitLocalization; +} + +export interface HitLocalization { + title: string; + description: string; +} + +export interface RenderingContent { +} + +export interface Ratings { + US: string[]; +} + +export interface ProcessingTimingsMS { + _request: Request; +} + +export interface Request { + queue: number; + roundTrip: number; +} diff --git a/@types/newHidiveSeason.d.ts b/@types/newHidiveSeason.d.ts new file mode 100644 index 0000000..0ed1b37 --- /dev/null +++ b/@types/newHidiveSeason.d.ts @@ -0,0 +1,89 @@ +export interface NewHidiveSeason { + title: string; + description: string; + longDescription: string; + smallCoverUrl: string; + coverUrl: string; + titleUrl: string; + posterUrl: string; + seasonNumber: number; + episodeCount: number; + displayableTags: any[]; + rating: Rating; + contentRating: Rating; + id: number; + series: Series; + episodes: Episode[]; + paging: Paging; + licences: any[]; +} + +export interface Rating { + rating: string; + descriptors: any[]; +} + +export interface Episode { + accessLevel: string; + availablePurchases?: any[]; + licenceIds?: any[]; + type: string; + id: number; + title: string; + description: string; + thumbnailUrl: string; + posterUrl: string; + duration: number; + favourite: boolean; + contentDownload: ContentDownload; + offlinePlaybackLanguages: string[]; + externalAssetId: string; + subEvents: any[]; + maxHeight: number; + thumbnailsPreview: string; + longDescription: string; + episodeInformation: EpisodeInformation; + categories: string[]; + displayableTags: any[]; + watchStatus: string; + computedReleases: any[]; +} + +export interface ContentDownload { + permission: string; +} + +export interface EpisodeInformation { + seasonNumber: number; + episodeNumber: number; + season: number; +} + +export interface Paging { + moreDataAvailable: boolean; + lastSeen: number; +} + +export interface Series { + seriesId: number; + title: string; + description: string; + longDescription: string; + displayableTags: any[]; + rating: Rating; + contentRating: Rating; +} + +export interface NewHidiveSeriesExtra extends Series { + season: NewHidiveSeason; +} + +export interface NewHidiveEpisodeExtra extends Episode { + titleId: number; + nameLong: string; + seasonTitle: string; + seriesTitle: string; + seriesId?: number; + isSelected: boolean; + jwtToken?: string; +} \ No newline at end of file diff --git a/@types/newHidiveSeries.d.ts b/@types/newHidiveSeries.d.ts new file mode 100644 index 0000000..4391406 --- /dev/null +++ b/@types/newHidiveSeries.d.ts @@ -0,0 +1,35 @@ +export interface NewHidiveSeries { + id: number; + title: string; + description: string; + longDescription: string; + smallCoverUrl: string; + coverUrl: string; + titleUrl: string; + posterUrl: string; + seasons: Season[]; + rating: Rating; + contentRating: Rating; + displayableTags: any[]; + paging: Paging; +} + +export interface Rating { + rating: string; + descriptors: any[]; +} + +export interface Paging { + moreDataAvailable: boolean; + lastSeen: number; +} + +export interface Season { + title: string; + description: string; + longDescription: string; + seasonNumber: number; + episodeCount: number; + displayableTags: any[]; + id: number; +} diff --git a/gui/react/src/provider/ServiceProvider.tsx b/gui/react/src/provider/ServiceProvider.tsx index c254353..eb770c5 100644 --- a/gui/react/src/provider/ServiceProvider.tsx +++ b/gui/react/src/provider/ServiceProvider.tsx @@ -23,7 +23,7 @@ const ServiceProvider: FCWithChildren = ({ children }) => { - + : diff --git a/gui/server/services/hidive.ts b/gui/server/services/hidive.ts index e07eea2..a61dd72 100644 --- a/gui/server/services/hidive.ts +++ b/gui/server/services/hidive.ts @@ -26,7 +26,13 @@ class HidiveHandler extends Base implements MessageHandler { return { isOk: true, value: undefined }; } + public async getAPIVersion() { + const _default = yargs.appArgv(this.hidive.cfg.cli, true); + this.hidive.api = _default.hdapi; + } + public async search(data: SearchData): Promise { + await this.getAPIVersion(); console.debug(`Got search options: ${JSON.stringify(data)}`); const hidiveSearch = await this.hidive.doSearch(data); if (!hidiveSearch.isOk) { @@ -42,7 +48,7 @@ class HidiveHandler extends Base implements MessageHandler { public async availableDubCodes(): Promise { const dubLanguageCodesArray: string[] = []; for(const language of languages){ - if (language.hd_locale) + if (language.new_hd_locale) dubLanguageCodesArray.push(language.code); } return [...new Set(dubLanguageCodesArray)]; @@ -51,7 +57,7 @@ class HidiveHandler extends Base implements MessageHandler { public async availableSubCodes(): Promise { const subLanguageCodesArray: string[] = []; for(const language of languages){ - if (language.hd_locale) + if (language.new_hd_locale) subLanguageCodesArray.push(language.locale); } return ['all', 'none', ...new Set(subLanguageCodesArray)]; @@ -62,63 +68,120 @@ class HidiveHandler extends Base implements MessageHandler { if (isNaN(parse) || parse <= 0) return false; console.debug(`Got resolve options: ${JSON.stringify(data)}`); - const res = await this.hidive.getShow(parseInt(data.id), data.e, data.but, data.all); - if (!res.isOk || !res.value) - return res.isOk; - this.addToQueue(res.value.map(item => { - return { - ...data, - ids: [item.Id], - title: item.Name, - parent: { - title: item.seriesTitle, - season: parseFloat(item.SeasonNumberValue+'')+'' - }, - image: item.ScreenShotSmallUrl, - e: parseFloat(item.EpisodeNumberValue+'')+'', - episode: parseFloat(item.EpisodeNumberValue+'')+'', - }; - })); - return true; + await this.getAPIVersion(); + if (this.hidive.api == 'old') { + const res = await this.hidive.getShow(parseInt(data.id), data.e, data.but, data.all); + if (!res.isOk || !res.value) + return res.isOk; + this.addToQueue(res.value.map(item => { + return { + ...data, + ids: [item.Id], + title: item.Name, + parent: { + title: item.seriesTitle, + season: parseFloat(item.SeasonNumberValue+'')+'' + }, + image: item.ScreenShotSmallUrl, + e: parseFloat(item.EpisodeNumberValue+'')+'', + episode: parseFloat(item.EpisodeNumberValue+'')+'', + }; + })); + return true; + } else { + const res = await this.hidive.selectSeries(parseInt(data.id), data.e, data.but, data.all); + if (!res.isOk || !res.value) + return res.isOk; + this.addToQueue(res.value.map(item => { + return { + ...data, + ids: [item.id], + title: item.title, + parent: { + title: item.seriesTitle, + season: item.episodeInformation.seasonNumber+'' + }, + image: item.thumbnailUrl, + e: item.episodeInformation.episodeNumber+'', + episode: item.episodeInformation.episodeNumber+'', + }; + })); + return true; + } } public async listEpisodes(id: string): Promise { const parse = parseInt(id); if (isNaN(parse) || parse <= 0) return { isOk: false, reason: new Error('The ID is invalid') }; - const request = await this.hidive.listShow(parse); - if (!request.isOk || !request.value) - return {isOk: false, reason: new Error('Unknown upstream error, check for additional logs')}; - return { isOk: true, value: request.value.Episodes.map(function(item) { - const language = item.Summary.match(/^Audio: (.*)/m); - language?.shift(); - const description = item.Summary.split('\r\n'); - return { - e: parseFloat(item.EpisodeNumberValue+'')+'', - lang: language ? language[0].split(', ') : [], - name: item.Name, - season: parseFloat(item.SeasonNumberValue+'')+'', - seasonTitle: request.value.Name, - episode: parseFloat(item.EpisodeNumberValue+'')+'', - id: item.Id+'', - img: item.ScreenShotSmallUrl, - description: description ? description[0] : '', - time: '' - }; - })}; + await this.getAPIVersion(); + if (this.hidive.api == 'old') { + const request = await this.hidive.listShow(parse); + if (!request.isOk || !request.value) + return {isOk: false, reason: new Error('Unknown upstream error, check for additional logs')}; + + return { isOk: true, value: request.value.Episodes.map(function(item) { + const language = item.Summary.match(/^Audio: (.*)/m); + language?.shift(); + const description = item.Summary.split('\r\n'); + return { + e: parseFloat(item.EpisodeNumberValue+'')+'', + lang: language ? language[0].split(', ') : [], + name: item.Name, + season: parseFloat(item.SeasonNumberValue+'')+'', + seasonTitle: request.value.Name, + episode: parseFloat(item.EpisodeNumberValue+'')+'', + id: item.Id+'', + img: item.ScreenShotSmallUrl, + description: description ? description[0] : '', + time: '' + }; + })}; + } else { + const request = await this.hidive.listSeries(parse); + if (!request.isOk || !request.value) + return {isOk: false, reason: new Error('Unknown upstream error, check for additional logs')}; + + return { isOk: true, value: request.value.map(function(item) { + const description = item.description.split('\r\n'); + return { + e: item.episodeInformation.episodeNumber+'', + lang: [], + name: item.title, + season: item.episodeInformation.seasonNumber+'', + seasonTitle: request.series.seasons[item.episodeInformation.seasonNumber-1].title, + episode: item.episodeInformation.episodeNumber+'', + id: item.id+'', + img: item.thumbnailUrl, + description: description ? description[0] : '', + time: '' + }; + })}; + } } public async downloadItem(data: DownloadData) { this.setDownloading(true); console.debug(`Got download options: ${JSON.stringify(data)}`); const _default = yargs.appArgv(this.hidive.cfg.cli, true); - const res = await this.hidive.getShow(parseInt(data.id), data.e, false, false); - if (!res.isOk || !res.showData) - return this.alertError(new Error('Download failed upstream, check for additional logs')); + this.hidive.api = _default.hdapi; + if (this.hidive.api == 'old') { + const res = await this.hidive.getShow(parseInt(data.id), data.e, false, false); + if (!res.isOk || !res.showData) + return this.alertError(new Error('Download failed upstream, check for additional logs')); - for (const ep of res.value) { - await this.hidive.getEpisode(ep, {..._default, callbackMaker: this.makeProgressHandler.bind(this), dubLang: data.dubLang, dlsubs: data.dlsubs, fileName: data.fileName, q: data.q, force: 'y', noaudio: data.noaudio, novids: data.novids }); + for (const ep of res.value) { + await this.hidive.getEpisode(ep, {..._default, callbackMaker: this.makeProgressHandler.bind(this), dubLang: data.dubLang, dlsubs: data.dlsubs, fileName: data.fileName, q: data.q, force: 'y', noaudio: data.noaudio, novids: data.novids }); + } + } else { + const res = await this.hidive.selectSeries(parseInt(data.id), data.e, false, false); + if (!res.isOk || !res.showData) + return this.alertError(new Error('Download failed upstream, check for additional logs')); + + for (const ep of res.value) { + await this.hidive.downloadEpisode(ep, {..._default, callbackMaker: this.makeProgressHandler.bind(this), dubLang: data.dubLang, dlsubs: data.dlsubs, fileName: data.fileName, q: data.q, force: 'y', noaudio: data.noaudio, novids: data.novids }); + } } this.sendMessage({ name: 'finish', data: undefined }); this.setDownloading(false); diff --git a/hidive.ts b/hidive.ts index 1681d11..f36ccc6 100644 --- a/hidive.ts +++ b/hidive.ts @@ -10,7 +10,7 @@ import packageJson from './package.json'; import { console } from './modules/log'; import shlp from 'sei-helper'; import m3u8 from 'm3u8-parsed'; -import streamdl from './modules/hls-download'; +import streamdl, { M3U8Json } from './modules/hls-download'; // custom modules import * as fontsData from './modules/module.fontsData'; @@ -34,12 +34,23 @@ import { ServiceClass } from './@types/serviceClassInterface'; import { sxItem } from './crunchy'; import { HidiveSearch } from './@types/hidiveSearch'; import { HidiveDashboard } from './@types/hidiveDashboard'; +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 getKeys, { canDecrypt } from './modules/widevine'; +import { exec } from './modules/sei-helper-fixes'; +import { KeyContainer } from './modules/license'; export default class Hidive implements ServiceClass { public cfg: yamlCfg.ConfigObject; private session: Record; + private tokenOld: Record; private token: Record; private req: reqModule.Req; + public api: 'old' | 'new'; private client: { // base ipAddress: string, @@ -58,34 +69,17 @@ export default class Hidive implements ServiceClass { constructor(private debug = false) { this.cfg = yamlCfg.loadCfg(); this.session = yamlCfg.loadHDSession(); - this.token = yamlCfg.loadHDToken(); + this.tokenOld = yamlCfg.loadHDToken(); + this.token = yamlCfg.loadNewHDToken(); this.client = yamlCfg.loadHDProfile() as {ipAddress: string, xNonce: string, xSignature: string, visitId: string, profile: {userId: number, profileId: number, deviceId : string}}; this.req = new reqModule.Req(domain, debug, false, 'hd'); - } - - public async doInit() { - //get client ip - const newIp = await this.reqData('Ping', ''); - if (!newIp.ok || !newIp.res) return false; - this.client.ipAddress = JSON.parse(newIp.res.body).IPAddress; - //get device id - const newDevice = await this.reqData('InitDevice', { 'DeviceName': api.hd_devName }); - if (!newDevice.ok || !newDevice.res) return false; - this.client.profile = Object.assign(this.client.profile, { - deviceId: JSON.parse(newDevice.res.body).Data.DeviceId, - }); - //get visit id - const newVisitId = await this.reqData('InitVisit', {}); - if (!newVisitId.ok || !newVisitId.res) return false; - this.client.visitId = JSON.parse(newVisitId.res.body).Data.VisitId; - //save client - yamlCfg.saveHDProfile(this.client); - return true; + this.api = 'old'; } public async cli() { console.info(`\n=== Multi Downloader NX ${packageJson.version} ===\n`); const argv = yargs.appArgv(this.cfg.cli); + this.api = argv.hdapi; if (argv.debug) this.debug = true; @@ -96,6 +90,16 @@ export default class Hidive implements ServiceClass { console.info(searchItems.res.body); fs.writeFileSync('apitest.json', JSON.stringify(JSON.parse(searchItems.res.body), null, 2));*/ + //new api testing + /*if (this.api == 'new') { + await this.doInit(); + 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) { @@ -115,30 +119,92 @@ export default class Hidive implements ServiceClass { //Search await this.doSearch({ ...argv, search: argv.search as string }); } else if (argv.s && !isNaN(parseInt(argv.s,10)) && parseInt(argv.s,10) > 0) { - //Initilize session - await this.doInit(); - //get selected episodes - const selected = await this.getShow(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.getEpisode(select, {...argv}))) { - console.error(`Unable to download selected episode ${parseFloat(select.EpisodeNumberValue+'')}`); - return false; + if (this.api == 'old') { + //Initilize session + await this.doInit(); + //get selected episodes + const selected = await this.getShow(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.getEpisode(select, {...argv}))) { + console.error(`Unable to download selected episode ${parseFloat(select.EpisodeNumberValue+'')}`); + return false; + } + } + } + } else { + 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) { - //Initilize session - await this.doInit(); - //Get Newly Added - await this.getNewlyAdded(argv.page); + if (this.api == 'old') { + //Initilize session + await this.doInit(); + //Get Newly Added + await this.getNewlyAdded(argv.page); + } else { + console.error('--new is not yet implemented in the new API'); + } + } else if(argv.e) { + if (this.api == 'new') { + if (!(await this.downloadSingleEpisode(parseInt(argv.e), {...argv}))) { + console.error(`Unable to download selected episode ${argv.e}`); + return false; + } + } else { + console.error('-e is not supported in the old API'); + } } else { console.info('No option selected or invalid value entered. Try --help.'); } } + public async doInit() { + if (this.api == 'old') { + //get client ip + const newIp = await this.reqData('Ping', ''); + if (!newIp.ok || !newIp.res) return false; + this.client.ipAddress = JSON.parse(newIp.res.body).IPAddress; + //get device id + const newDevice = await this.reqData('InitDevice', { 'DeviceName': api.hd_devName }); + if (!newDevice.ok || !newDevice.res) return false; + this.client.profile = Object.assign(this.client.profile, { + deviceId: JSON.parse(newDevice.res.body).Data.DeviceId, + }); + //get visit id + const newVisitId = await this.reqData('InitVisit', {}); + if (!newVisitId.ok || !newVisitId.res) return false; + this.client.visitId = JSON.parse(newVisitId.res.body).Data.VisitId; + //save client + yamlCfg.saveHDProfile(this.client); + return true; + } else { + //this.refreshToken(); + return true; + } + } + // Generate Nonce public generateNonce(){ const initDate = new Date(); @@ -296,22 +362,182 @@ export default class Hidive implements ServiceClass { } } - public async doAuth(data: AuthData): Promise { - const auth = await this.reqData('Authenticate', {'Email':data.username,'Password':data.password}); - if(!auth.ok || !auth.res) { - console.error('Authentication failed!'); - return { isOk: false, reason: new Error('Authentication failed') }; + 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: true + }; + // get request type + const isGet = method == 'GET' ? true : false; + if(!isGet){ + options.body = body == '' ? body : JSON.stringify(body); + options.headers['Content-Type'] = 'application/json'; } - const authData = JSON.parse(auth.res.body).Data; - this.client.profile = Object.assign(this.client.profile, { - userId: authData.User.Id, - profileId: authData.Profiles[0].Id, - }); - yamlCfg.saveHDProfile(this.client); - yamlCfg.saveHDToken(authData); - console.info('[INFO] Auth complete!'); - console.info(`[INFO] Service level for "${data.username}" is ${authData.User.ServiceLevel}`); - return { isOk: true, value: undefined }; + 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}`; + } + if (this.debug) { + console.debug('[DEBUG] Request params:'); + console.debug(options); + } + const apiReqOpts: reqModule.Params = { + method: options.method, + headers: options.headers as Record, + body: options.body as string + }; + let apiReq = await this.req.getData(options.url, apiReqOpts); + if(!apiReq.ok || !apiReq.res){ + if (apiReq.error && apiReq.error.res.statusCode == 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.api == 'old') { + const auth = await this.reqData('Authenticate', {'Email':data.username,'Password':data.password}); + if(!auth.ok || !auth.res) { + console.error('Authentication failed!'); + return { isOk: false, reason: new Error('Authentication failed') }; + } + const authData = JSON.parse(auth.res.body).Data; + this.client.profile = Object.assign(this.client.profile, { + userId: authData.User.Id, + profileId: authData.Profiles[0].Id, + }); + yamlCfg.saveHDProfile(this.client); + yamlCfg.saveHDToken(authData); + console.info('Auth complete!'); + console.info(`Service level for "${data.username}" is ${authData.User.ServiceLevel}`); + return { isOk: true, value: undefined }; + } else { + 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(authReq.res.body); + 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(authReq.res.body); + 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...'); + if (!this.initSession()) { + return false; + } else { + return true; + } + } + const tokens: Record = JSON.parse(authReq.res.body); + 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(authReq.res.body).authentication; + for (const token in tokens) { + this.token[token] = tokens[token]; + } + yamlCfg.saveNewHDToken(this.token); + return true; } public async genSubsUrl(type: string, file: string) { @@ -323,30 +549,74 @@ export default class Hidive implements ServiceClass { } public async doSearch(data: SearchData): Promise { - const searchReq = await this.reqData('Search', {'Query':data.search}); - 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(searchReq.res.body) as HidiveSearch; - const searchItems = searchData.Data.TitleResults; - if(searchItems.length>0) { - console.info('[INFO] Search Results:'); - for(let i=0;i0) { + console.info('[INFO] Search Results:'); + for(let i=0;i { + return { + id: a.Id+'', + image: a.KeyArtUrl ?? '/notFound.png', + name: a.Name, + rating: a.OverallRating, + desc: a.LongSynopsis + }; + })}; + } else { + 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(searchReq.res.body) 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 + }; + })}; } - return { isOk: true, value: searchItems.map((a): SearchResponseItem => { - return { - id: a.Id+'', - image: a.KeyArtUrl ?? '/notFound.png', - name: a.Name, - rating: a.OverallRating, - desc: a.LongSynopsis - }; - })}; } public async getNewlyAdded(page?: number) { @@ -376,11 +646,190 @@ export default class Hidive implements ServiceClass { } } + 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(getSeriesData.res.body) 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(getSeasonData.res.body) 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)`); + 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; + } + const episodes: Episode[] = []; + for (const episode of season.value.episodes) { + 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} - + episodes.push(episode); + console.info(` [E.${episode.id}] ${episode.title}`); + } + return { isOk: true, value: episodes, series: series.value }; + } + console.info(' No Seasons found!'); + return { isOk: false }; + } + + 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) { + 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} - + 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; + 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 }; + } + + /** + * 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 listShow(id: number) { const getShowData = await this.reqData('GetTitle', { 'Id': id }); if (!getShowData.ok || !getShowData.res) { console.error('Failed to get show data'); - return { isOk: false}; + return { isOk: false }; } const rawShowData = JSON.parse(getShowData.res.body) as HidiveEpisodeList; const showData = rawShowData.Data.Title; @@ -434,14 +883,14 @@ export default class Hidive implements ServiceClass { sumSub ); } - return { isOk: true, value: selEpsArr, showData: showData } ; + return { isOk: true, value: selEpsArr, showData: showData }; } public async getEpisode(selectedEpisode: HidiveEpisodeExtra, options: Record) { const getVideoData = await this.reqData('GetVideos', { 'VideoKey': selectedEpisode.epKey, 'TitleId': selectedEpisode.titleId }); if (getVideoData.ok && getVideoData.res) { const videoData = JSON.parse(getVideoData.res.body) as HidiveVideoList; - const showTitle = `${selectedEpisode.seriesTitle} S${parseFloat(selectedEpisode.SeasonNumberValue+'')}}`; + const showTitle = `${selectedEpisode.seriesTitle} S${parseFloat(selectedEpisode.SeasonNumberValue+'')}`; console.info(`[INFO] ${showTitle} - ${parseFloat(selectedEpisode.EpisodeNumberValue+'')}`); const videoList = videoData.Data.VideoLanguages; const subsList = videoData.Data.CaptionLanguages; @@ -522,6 +971,470 @@ export default class Hidive implements ServiceClass { return { isOk: false, reason: new Error('Unknown download error') }; } + 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(episodeDataReq.res.body) 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(playbackReq.res.body) 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 = mpdRequest.res.body 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 = 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(episodeDataReq.res.body) as NewHidiveEpisode; + + if (episodeData.title.includes(' - ')) { + 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') }; + } + + const 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') }; + } + + //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(playbackReq.res.body) 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 = mpdRequest.res.body as string; + + const selectedEpisode: NewHidiveEpisodeExtra = { + ...episodeData, + nameLong: episodeData.title, + titleId: episodeData.id, + seasonTitle: seasonData.value.title, + seriesTitle: seasonData.value.series.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 = 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[] | undefined = undefined; + if (!canDecrypt) console.warn('Decryption not enabled!'); + + 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]); + } + } + + 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 (!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 split = fileName.split(path.sep).slice(0, -1); + split.forEach((val, ind, arr) => { + const isAbsolut = path.isAbsolute(fileName); + 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 + }; + const videoDownload = await new streamdl({ + output: `${tsFile}.video.enc.ts`, + 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) { + console.info('Decryption Needed, attempting to decrypt'); + encryptionKeys = await getKeys(chosenVideoSegments.pssh, 'https://shield-drm.imggaming.com/api/v2/license', { + 'Authorization': `Bearer ${selectedEpisode.jwtToken}`, + 'X-Drm-Info': 'eyJzeXN0ZW0iOiJjb20ud2lkZXZpbmUuYWxwaGEifQ==', + }); + if (encryptionKeys.length == 0) { + console.error('Failed to get encryption keys'); + return undefined; + } + if (this.cfg.bin.mp4decrypt) { + const commandBase = `--show-progress --key ${encryptionKeys[1].kid}:${encryptionKeys[1].key} `; + const commandVideo = commandBase+`"${tsFile}.video.enc.ts" "${tsFile}.video.ts"`; + + 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}`); + return undefined; + } else { + console.info('Decryption done for video'); + if (!options.nocleanup) { + fs.removeSync(`${tsFile}.video.enc.ts`); + } + files.push({ + type: 'Video', + path: `${tsFile}.video.ts`, + lang: chosenAudios[0].language, + isPrimary: true + }); + } + } else { + console.warn('mp4decrypt 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 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 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 + }; + const audioDownload = await new streamdl({ + output: `${tsFile}.audio.enc.ts`, + 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) { + console.info('Decryption Needed, attempting to decrypt'); + if (!encryptionKeys) { + encryptionKeys = await getKeys(chosenVideoSegments.pssh, 'https://shield-drm.imggaming.com/api/v2/license', { + 'Authorization': `Bearer ${selectedEpisode.jwtToken}`, + 'X-Drm-Info': 'eyJzeXN0ZW0iOiJjb20ud2lkZXZpbmUuYWxwaGEifQ==', + }); + } + if (this.cfg.bin.mp4decrypt) { + const commandBase = `--show-progress --key ${encryptionKeys[1].kid}:${encryptionKeys[1].key} `; + const commandAudio = commandBase+`"${tsFile}.audio.enc.ts" "${tsFile}.audio.ts"`; + + 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}`); + return undefined; + } else { + if (!options.nocleanup) { + fs.removeSync(`${tsFile}.audio.enc.ts`); + } + files.push({ + type: 'Audio', + path: `${tsFile}.audio.ts`, + 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); + sxData.path = path.join(this.cfg.dir.content, sxData.file); + 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) { + console.info(`Subtitle Downloaded: ${sub.url}`); + //vttConvert(getVttContent.res.body, false, subLang.name, fontSize); + const sBody = vtt(undefined, chosenFontSize, getVttContent.res.body, '', subsMargin, options.fontName); + sxData.title = `${subLang.language} / ${sxData.title}`; + sxData.fonts = fontsData.assFonts(sBody) as Font[]; + fs.writeFileSync(sxData.path, sBody); + console.info(`Subtitle converted: ${sxData.file}`); + 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!'); + } + + return { + error: dlFailed, + data: files, + fileName: fileName ? (path.isAbsolute(fileName) ? fileName : path.join(this.cfg.dir.content, fileName)) || './unknown' : './unknown' + }; + } + public async downloadMediaList(videoUrls: HidiveStreamInfo[], subUrls: HidiveSubtitleInfo[], fontSize: number, options: Record) { let mediaName = '...'; let fileName; @@ -744,12 +1657,13 @@ export default class Hidive implements ServiceClass { const getCssContent = await this.req.getData(await this.genSubsUrl('css', subsXUrl)); const getVttContent = await this.req.getData(await this.genSubsUrl('vtt', subsXUrl)); if (getCssContent.ok && getVttContent.ok && getCssContent.res && getVttContent.res) { + console.info(`Subtitle Downloaded: ${await this.genSubsUrl('vtt', subsXUrl)}`); //vttConvert(getVttContent.res.body, false, subLang.name, fontSize); const sBody = vtt(undefined, chosenFontSize, getVttContent.res.body, getCssContent.res.body, subsMargin, options.fontName); sxData.title = `${subLang.language} / ${sxData.title}`; sxData.fonts = fontsData.assFonts(sBody) as Font[]; fs.writeFileSync(sxData.path, sBody); - console.info(`Subtitle downloaded: ${sxData.file}`); + console.info(`Subtitle Converted: ${sxData.file}`); files.push({ type: 'Subtitle', ...sxData as sxItem, @@ -775,20 +1689,40 @@ export default class Hidive implements ServiceClass { }; } - public async muxStreams(data: DownloadedMedia[], options: Record) { + 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 (data.some(a => a.type === 'Audio')) { + hasAudioStreams = true; + } const merger = new Merger({ - onlyVid: [], + 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: true, + inverseTrackOrder: inverseTrackOrder, keepAllVideos: options.keepAllVideos, - onlyAudio: [], + 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, @@ -801,7 +1735,7 @@ export default class Hidive implements ServiceClass { return !a.uncut as boolean; })[0], fonts: Merger.makeFontsList(this.cfg.dir.fonts, data.filter(a => a.type === 'Subtitle') as sxItem[]), - videoAndAudio: data.filter(a => a.type === 'Video').map((a) : MergerInput => { + videoAndAudio: hasAudioStreams ? [] : data.filter(a => a.type === 'Video').map((a) : MergerInput => { if (a.type === 'Subtitle') throw new Error('Never'); return { diff --git a/modules/module.api-urls.ts b/modules/module.api-urls.ts index 603f25b..6dffd45 100644 --- a/modules/module.api-urls.ts +++ b/modules/module.api-urls.ts @@ -7,7 +7,8 @@ const domain = { www_beta: 'https://beta.crunchyroll.com', api_beta: 'https://beta-api.crunchyroll.com', hd_www: 'https://www.hidive.com', - hd_api: 'https://api.hidive.com' + hd_api: 'https://api.hidive.com', + hd_new: 'https://dce-frontoffice.imggaming.com' }; export type APIType = { @@ -41,6 +42,9 @@ export type APIType = { hd_clientWeb: string, hd_clientExo: string, hd_api: string, + hd_new_api: string, + hd_new_apiKey: string, + hd_new_version: string, } // api urls @@ -77,6 +81,10 @@ const api: APIType = { hd_clientWeb: 'okhttp/3.4.1', hd_clientExo: 'smartexoplayer/1.6.0.R (Linux;Android 6.0) ExoPlayerLib/2.6.0', hd_api: `${domain.hd_api}/api/v1`, + //Hidive New API + hd_new_api: `${domain.hd_new}/api`, + hd_new_apiKey: '857a1e5d-e35e-4fdf-805b-a87b6f8364bf', + hd_new_version: '6.0.1.bbf09a2' }; // set header diff --git a/modules/module.app-args.ts b/modules/module.app-args.ts index a06d0ae..7e7629e 100644 --- a/modules/module.app-args.ts +++ b/modules/module.app-args.ts @@ -31,7 +31,8 @@ let argvC: { new: boolean | undefined; 'movie-listing': string | undefined; series: string | undefined; - s: string | undefined; + s: string | undefined; + srz: string | undefined; e: string | undefined; extid: string | undefined; q: number; @@ -66,6 +67,7 @@ let argvC: { dlVideoOnce: boolean; chapters: boolean; crapi: 'android' | 'web'; + hdapi: 'old' | 'new'; removeBumpers: boolean; originalFontSize: boolean; keepAllVideos: boolean; diff --git a/modules/module.args.ts b/modules/module.args.ts index ffedb2d..3540483 100644 --- a/modules/module.args.ts +++ b/modules/module.args.ts @@ -138,8 +138,7 @@ const args: TAppArg[] = [ group: 'dl', alias: 'srz', describe: 'Get season list by series ID', - docDescribe: 'This command is used only for crunchyroll.' - + '\n Requested is the ID of a show not a season.', + docDescribe: 'Requested is the ID of a show not a season.', service: ['crunchy'], type: 'string', usage: '${ID}' @@ -230,6 +229,20 @@ const args: TAppArg[] = [ default: 'android' } }, + { + name: 'hdapi', + describe: 'Selects the API type for Hidive', + type: 'string', + group: 'dl', + service: ['hidive'], + docDescribe: 'If set to Old, it has lower quality, but Non-DRM streams, but some people can\'t use it,' + + '\nIf set to New, it has a higher quality stream, but everything is DRM.', + usage: '', + choices: ['old', 'new'], + default: { + default: 'old' + } + }, { name: 'removeBumpers', describe: 'Remove bumpers from final video', diff --git a/modules/module.cfg-loader.ts b/modules/module.cfg-loader.ts index b488500..6f39d8c 100644 --- a/modules/module.cfg-loader.ts +++ b/modules/module.cfg-loader.ts @@ -26,7 +26,8 @@ const stateFile = path.join(workingDir, 'config', 'guistate'); const tokenFile = { funi: path.join(workingDir, 'config', 'funi_token'), cr: path.join(workingDir, 'config', 'cr_token'), - hd: path.join(workingDir, 'config', 'hd_token') + hd: path.join(workingDir, 'config', 'hd_token'), + hdNew: path.join(workingDir, 'config', 'hd_new_token') }; export const ensureConfig = () => { @@ -242,7 +243,7 @@ const saveHDSession = (data: Record) => { const loadHDToken = () => { - let token = loadYamlCfgFile(tokenFile.cr, true); + let token = loadYamlCfgFile(tokenFile.hd, true); if(typeof token !== 'object' || token === null || Array.isArray(token)){ token = {}; } @@ -292,6 +293,25 @@ const loadHDProfile = () => { return profile; }; +const loadNewHDToken = () => { + let token = loadYamlCfgFile(tokenFile.hdNew, true); + if(typeof token !== 'object' || token === null || Array.isArray(token)){ + token = {}; + } + return token; +}; + +const saveNewHDToken = (data: Record) => { + const cfgFolder = path.dirname(tokenFile.hdNew); + try{ + fs.ensureDirSync(cfgFolder); + fs.writeFileSync(`${tokenFile.hdNew}.yml`, yaml.stringify(data)); + } + catch(e){ + console.error('Can\'t save token file to disk!'); + } +}; + const loadFuniToken = () => { const loadedToken = loadYamlCfgFile<{ token?: string @@ -363,6 +383,8 @@ export { loadHDSession, saveHDToken, loadHDToken, + saveNewHDToken, + loadNewHDToken, saveHDProfile, loadHDProfile, getState, diff --git a/modules/module.langsData.ts b/modules/module.langsData.ts index e6453ef..56ca413 100644 --- a/modules/module.langsData.ts +++ b/modules/module.langsData.ts @@ -3,6 +3,7 @@ export type LanguageItem = { cr_locale?: string, hd_locale?: string, + new_hd_locale?: string, locale: string, code: string, name: string, @@ -13,12 +14,12 @@ export type LanguageItem = { } const languages: LanguageItem[] = [ - { cr_locale: 'en-US', hd_locale: 'English', funi_locale: 'enUS', locale: 'en', code: 'eng', name: 'English' }, + { cr_locale: 'en-US', new_hd_locale: 'en-US', hd_locale: 'English', funi_locale: 'enUS', locale: 'en', code: 'eng', name: 'English' }, { cr_locale: 'en-IN', locale: 'en-IN', code: 'eng', name: 'English (India)', }, - { cr_locale: 'es-LA', hd_locale: 'Spanish LatAm', funi_name: 'Spanish (LAS)', funi_name_lagacy: 'Spanish (Latin Am)', funi_locale: 'esLA', locale: 'es-419', code: 'spa', name: 'Spanish', language: 'Latin American Spanish' }, + { cr_locale: 'es-LA', new_hd_locale: 'es-MX', hd_locale: 'Spanish LatAm', funi_name: 'Spanish (LAS)', funi_name_lagacy: 'Spanish (Latin Am)', funi_locale: 'esLA', locale: 'es-419', code: 'spa', name: 'Spanish', language: 'Latin American Spanish' }, { cr_locale: 'es-419',hd_locale: 'Spanish', locale: 'es-419', code: 'spa-419', name: 'Spanish', language: 'Latin American Spanish' }, { cr_locale: 'es-ES', hd_locale: 'Spanish Europe', locale: 'es-ES', code: 'spa-ES', name: 'Castilian', language: 'European Spanish' }, - { cr_locale: 'pt-BR', hd_locale: 'Portuguese', funi_name: 'Portuguese (Brazil)', funi_locale: 'ptBR', locale: 'pt-BR', code: 'por', name: 'Portuguese', language: 'Brazilian Portuguese' }, + { cr_locale: 'pt-BR', new_hd_locale: 'pt-BR', hd_locale: 'Portuguese', funi_name: 'Portuguese (Brazil)', funi_locale: 'ptBR', locale: 'pt-BR', code: 'por', name: 'Portuguese', language: 'Brazilian Portuguese' }, { cr_locale: 'pt-PT', locale: 'pt-PT', code: 'por', name: 'Portuguese (Portugal)', language: 'Portugues (Portugal)' }, { cr_locale: 'fr-FR', hd_locale: 'French', locale: 'fr', code: 'fra', name: 'French' }, { cr_locale: 'de-DE', hd_locale: 'German', locale: 'de', code: 'deu', name: 'German' }, diff --git a/modules/module.req.ts b/modules/module.req.ts index 31dbcc4..232abe0 100644 --- a/modules/module.req.ts +++ b/modules/module.req.ts @@ -61,7 +61,9 @@ class Req { options.headers = {...options.headers, ...params.headers}; } if(options.method == 'POST'){ - (options.headers as Headers)['Content-Type'] = 'application/x-www-form-urlencoded'; + if (!(options.headers as Headers)['Content-Type']) { + (options.headers as Headers)['Content-Type'] = 'application/x-www-form-urlencoded'; + } } if(params.body){ options.body = params.body; diff --git a/modules/module.transform-mpd.ts b/modules/module.transform-mpd.ts index b3c5419..a56a42c 100644 --- a/modules/module.transform-mpd.ts +++ b/modules/module.transform-mpd.ts @@ -1,5 +1,5 @@ -import { Playlist, parse as mpdParse } from 'mpd-parser'; -import { LanguageItem } from './module.langsData'; +import { parse as mpdParse } from 'mpd-parser'; +import { LanguageItem, findLang, languages } from './module.langsData'; type Segment = { uri: string; @@ -20,7 +20,8 @@ export type PlaylistItem = { type AudioPlayList = { - language: LanguageItem + language: LanguageItem, + default: boolean } & PlaylistItem type VideoPlayList = { @@ -37,9 +38,9 @@ export type MPDParsed = { } } -export function parse(manifest: string, language: LanguageItem, url?: string) { +export function parse(manifest: string, language?: LanguageItem, url?: string) { if (!manifest.includes('BaseURL') && url) { - manifest = manifest.replace(/()/gm, `$1${url}`); + manifest = manifest.replace(/(]*>)/gm, `$1${url}`); } const parsed = mpdParse(manifest); const ret: MPDParsed = {}; @@ -50,9 +51,18 @@ export function parse(manifest: string, language: LanguageItem, url?: string) { if (!Object.prototype.hasOwnProperty.call(ret, host)) ret[host] = { audio: [], video: [] }; + //Find and add audio language if it is found in the MPD + let audiolang: LanguageItem; + const foundlanguage = findLang(languages.find(a => a.code === item.language)?.cr_locale ?? 'unknown'); + if (item.language) { + audiolang = foundlanguage; + } else { + audiolang = language ? language : foundlanguage; + } const pItem: AudioPlayList = { bandwidth: playlist.attributes.BANDWIDTH, - language: language, + language: audiolang, + default: item.default, segments: playlist.segments.map((segment): Segment => { const uri = segment.resolvedUri; const map_uri = segment.map.resolvedUri; diff --git a/modules/module.vtt2ass.ts b/modules/module.vtt2ass.ts index a661fa3..9c5f7ea 100644 --- a/modules/module.vtt2ass.ts +++ b/modules/module.vtt2ass.ts @@ -69,7 +69,7 @@ function loadCSS(cssStr: string): Css { function parseStyle(stylegroup: string, line: string, style: any) { const defaultSFont = rFont == '' ? defaultStyleFont : rFont; //redeclare cause of let - if (stylegroup.startsWith('Subtitle') || stylegroup.startsWith('Song')) { //base for dialog, everything else use defaultStyle + if (stylegroup.startsWith('Subtitle') || stylegroup.startsWith('Song') || stylegroup.startsWith('Q0') || stylegroup.startsWith('Q1')) { //base for dialog, everything else use defaultStyle style = `${defaultSFont},${fontSize},&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2.6,0,2,20,20,46,1`; } @@ -261,6 +261,7 @@ function convert(css: Css, vtt: Vtt[]) { song_cap: [], }; const linesMap: Record = {}; + let previousLine: ReturnType | undefined = undefined; for (const l in vtt) { const x = convertLine(stylesMap, vtt[l]); if (x.ind !== '' && linesMap[x.ind] !== undefined) { @@ -278,7 +279,17 @@ function convert(css: Css, vtt: Vtt[]) { linesMap[x.ind] = events[x.type as keyof typeof events].length - 1; } } - + /** + * What cursed code have I brought upon this land? + * This checks if a subtitle should be multi-line, and if it is, pops the just inserted + * subtitle and the previous subtitle, and merges them into a single subtitle. + */ + if (previousLine?.start == x.start && previousLine.type == x.type && previousLine.style == x.style) { + events[x.type as keyof typeof events].pop(); + const previousLinePop = events[x.type as keyof typeof events].pop(); + events[x.type as keyof typeof events].push(previousLinePop + '\\N'+x.text); + } + previousLine = x; } if (events.subtitle.length > 0) { ass = ass.concat( @@ -399,6 +410,23 @@ function vtt(group: string | undefined, xFontSize: number | undefined, vttStr: s fontSize = xFontSize && xFontSize > 0 ? xFontSize : 34; // 1em to pix tmMrg = timeMargin ? timeMargin : 0; // rFont = replaceFont ? replaceFont : rFont; + if (vttStr.match(/::cue(?:.(.+)\))?{([^}]+)}/g)) { + const cssLines = []; + let defaultCss = ''; + const cssGroups = vttStr.matchAll(/::cue(?:.(.+)\))?{([^}]+)}/g); + for (const cssGroup of cssGroups) { + //Below code will bulldoze defined sizes for custom ones + /*if (!options.originalFontSize) { + cssGroup[2] = cssGroup[2].replace(/( font-size:.+?;)/g, '').replace(/(font-size:.+?;)/g, ''); + }*/ + if (cssGroup[1]) { + cssLines.push(`${cssGroup[1]}{${defaultCss}${cssGroup[2]}}`); + } else { + defaultCss = cssGroup[2]; + } + } + cssStr += cssLines.join('\r\n'); + } return convert( loadCSS(cssStr), loadVTT(vttStr) diff --git a/package.json b/package.json index 2d19b40..a403b93 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "multi-downloader-nx", "short_name": "aniDL", - "version": "4.5.0", + "version": "4.5.0rc2", "description": "Downloader for Crunchyroll, Funimation, or Hidive via CLI or GUI", "keywords": [ "download",