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",