Merge branch 'v5' into animeonegai

This commit is contained in:
AnimeDL 2024-04-13 07:50:20 -07:00 committed by GitHub
commit 01abffa85f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 153 additions and 2220 deletions

View file

@ -47,7 +47,6 @@ body:
label: Service label: Service
description: "Please tell us what service the bug occured in." description: "Please tell us what service the bug occured in."
options: options:
- Funimation
- Crunchyroll - Crunchyroll
- Hidive - Hidive
- All - All

10
.gitignore vendored
View file

@ -21,13 +21,9 @@ token.yml
lib lib
test.* test.*
updates.json updates.json
funi_token.yml *_token.yml
cr_token.yml *_profile.yml
hd_profile.yml *_sess.yml
hd_sess.yml
hd_token.yml
hd_new_token.yml
ao_token.yml
archive.json archive.json
guistate.json guistate.json
fonts fonts

View file

@ -1,34 +0,0 @@
// Generated by https://quicktype.io
export interface FunimationSearch {
count: number;
items: Items;
limit: string;
offset: string;
}
export interface Items {
hits: Hit[];
}
export interface Hit {
ratings: string;
description: string;
title: string;
image: {
showThumbnail: string,
[key: string]: string
};
starRating: number;
slug: string;
languages: string[];
synopsis: string;
quality: Quality;
id: string;
txDate: number;
}
export interface Quality {
quality: string;
height: number;
}

View file

@ -1,75 +0,0 @@
// Generated by https://quicktype.io
export interface SubtitleRequest {
primary: Primary;
fallback: Primary[];
}
export interface Primary {
venueVideoId: string;
alphaPackageId: string;
versionContentId: VersionContentID;
manifestPath: string;
fileExt: PrimaryFileEXT;
subtitles: Subtitle[];
accessType: AccessType;
sessionId: string;
audioLanguage: AudioLanguage;
version: Version;
aips: Aip[];
drmToken: string;
drmType: string;
}
export enum AccessType {
Subscription = 'subscription',
}
export interface Aip {
in: number;
out: number;
}
export enum AudioLanguage {
En = 'en',
Ja = 'ja',
}
export enum PrimaryFileEXT {
M3U8 = 'm3u8',
Mp4 = 'mp4',
}
export interface Subtitle {
filePath: string;
fileExt: SubtitleFileEXT;
contentType: ContentType;
languageCode: LanguageCode;
}
export enum ContentType {
Cc = 'cc',
Full = 'full',
}
export enum SubtitleFileEXT {
Dfxp = 'dfxp',
Srt = 'srt',
Vtt = 'vtt',
}
export enum LanguageCode {
En = 'en',
Es = 'es',
Pt = 'pt',
}
export enum Version {
Simulcast = 'simulcast',
Uncut = 'uncut',
}
export enum VersionContentID {
Akusim0012 = 'AKUSIM0012',
Akuunc0012 = 'AKUUNC0012',
}

16
@types/funiTypes.d.ts vendored
View file

@ -1,16 +0,0 @@
import { LanguageItem } from '../modules/module.langsData';
export type FunimationMediaDownload = {
id: string,
title: string,
showTitle: string,
image: string
}
export type Subtitle = {
url: string,
lang: LanguageItem,
ext: string,
out?: string,
closedCaption?: boolean
}

4
@types/ws.d.ts vendored
View file

@ -30,8 +30,8 @@ export type MessageTypes = {
'isDownloading': [undefined, boolean], 'isDownloading': [undefined, boolean],
'openFolder': [FolderTypes, undefined], 'openFolder': [FolderTypes, undefined],
'changeProvider': [undefined, boolean], 'changeProvider': [undefined, boolean],
'type': [undefined, 'funi'|'crunchy'|'hidive'|'ao'|undefined], 'type': [undefined, 'crunchy'|'hidive'|'ao'||undefined],
'setup': ['funi'|'crunchy'|'hidive'|'ao'|undefined, undefined], 'setup': ['crunchy'|'hidive'|'ao'|undefined, undefined],
'openFile': [[FolderTypes, string], undefined], 'openFile': [[FolderTypes, string], undefined],
'openURL': [string, undefined], 'openURL': [string, undefined],
'isSetup': [undefined, boolean], 'isSetup': [undefined, boolean],

View file

@ -1,4 +1,4 @@
# multi-downloader-nx (4.7.0v) # multi-downloader-nx (4.7.1v)
If you find any bugs in this documentation or in the program itself please report it [over on GitHub](https://github.com/anidl/multi-downloader-nx/issues). If you find any bugs in this documentation or in the program itself please report it [over on GitHub](https://github.com/anidl/multi-downloader-nx/issues).

View file

@ -2,11 +2,11 @@
[![Discord Shield](https://discord.com/api/guilds/884479461997805568/widget.png?style=banner2)](https://discord.gg/qEpbWen5vq) [![Discord Shield](https://discord.com/api/guilds/884479461997805568/widget.png?style=banner2)](https://discord.gg/qEpbWen5vq)
This downloader can download anime from different sites. Currently supported are *Funimation*, *Crunchyroll*, *Hidive*, and *AnimeOnegai*. This downloader can download anime from different sites. Currently supported are *Crunchyroll*, *Hidive*, and *AnimeOnegai*.
## Legal Warning ## Legal Warning
This application is not endorsed by or affiliated with *Funimation*, *Crunchyroll*, *Hidive*, or *AnimeOnegai*. This application enables you to download videos for offline viewing which may be forbidden by law in your country. The usage of this application may also cause a violation of the *Terms of Service* between you and the stream provider. This tool is not responsible for your actions; please make an informed decision before using this application. This application is not endorsed by or affiliated with *Crunchyroll*, *Hidive*, or *AnimeOnegai*. This application enables you to download videos for offline viewing which may be forbidden by law in your country. The usage of this application may also cause a violation of the *Terms of Service* between you and the stream provider. This tool is not responsible for your actions; please make an informed decision before using this application.
## Dependencies ## Dependencies

923
funi.ts
View file

@ -1,923 +0,0 @@
// modules build-in
import fs from 'fs';
import path from 'path';
// package json
import packageJson from './package.json';
// modules extra
import { console } from './modules/log';
import * as shlp from 'sei-helper';
import m3u8 from 'm3u8-parsed';
import hlsDownload, { HLSCallback } from './modules/hls-download';
// extra
import * as appYargs from './modules/module.app-args';
import * as yamlCfg from './modules/module.cfg-loader';
import vttConvert from './modules/module.vttconvert';
// types
import type { Item } from './@types/items.js';
// params
// Import modules after argv has been exported
import getData from './modules/module.getdata';
import merger from './modules/module.merger';
import parseSelect from './modules/module.parseSelect';
import { EpisodeData, MediaChild } from './@types/episode';
import { Subtitle } from './@types/funiTypes';
import { StreamData } from './@types/streamData';
import { DownloadedFile } from './@types/downloadedFile';
import parseFileName, { Variable } from './modules/module.filename';
import { downloaded } from './modules/module.downloadArchive';
import { FunimationMediaDownload } from './@types/funiTypes';
import * as langsData from './modules/module.langsData';
import { TitleElement } from './@types/episode';
import { AvailableFilenameVars } from './modules/module.args';
import { AuthData, AuthResponse, CheckTokenResponse, FuniGetEpisodeData, FuniGetEpisodeResponse, FuniGetShowData, SearchData, FuniSearchReponse, FuniShowResponse, FuniStreamData, FuniSubsData, FuniEpisodeData, ResponseBase } from './@types/messageHandler';
import { ServiceClass } from './@types/serviceClassInterface';
import { SubtitleRequest } from './@types/funiSubtitleRequest';
// program name
const api_host = 'https://prod-api-funimationnow.dadcdigital.com/api';
// check page
// fn variables
let fnEpNum: string|number = 0,
fnOutput: string[] = [],
season = 0,
tsDlPath: {
path: string,
lang: langsData.LanguageItem
}[] = [],
stDlPath: Subtitle[] = [];
export default class Funi implements ServiceClass {
public static epIdLen = 4;
public static typeIdLen = 0;
public cfg: yamlCfg.ConfigObject;
private token: string | boolean;
constructor(private debug = false) {
this.cfg = yamlCfg.loadCfg();
this.token = yamlCfg.loadFuniToken();
}
public checkToken(): CheckTokenResponse {
const isOk = typeof this.token === 'string';
return isOk ? { isOk, value: undefined } : { isOk, reason: new Error('Not authenticated') };
}
public async cli() : Promise<boolean|undefined> {
const argv = appYargs.appArgv(this.cfg.cli);
if (argv.debug)
this.debug = true;
console.info(`\n=== Multi Downloader NX ${packageJson.version} ===\n`);
if (argv.allDubs) {
argv.dubLang = langsData.dubLanguageCodes;
}
// select mode
if (argv.silentAuth && !argv.auth) {
const data: AuthData = {
username: argv.username ?? await shlp.question('[Q] LOGIN/EMAIL'),
password: argv.password ?? await shlp.question('[Q] PASSWORD ')
};
await this.auth(data);
}
if(argv.auth){
const data: AuthData = {
username: argv.username ?? await shlp.question('[Q] LOGIN/EMAIL'),
password: argv.password ?? await shlp.question('[Q] PASSWORD ')
};
await this.auth(data);
}
else if(argv.search){
this.searchShow(true, { search: argv.search });
}
else if(argv.s && !isNaN(parseInt(argv.s)) && parseInt(argv.s) > 0){
const data = await this.getShow(true, { id: parseInt(argv.s), but: argv.but, all: argv.all, e: argv.e });
if (!data.isOk) {
console.error(`${data.reason.message}`);
return false;
}
let ok = true;
for (const episodeData of data.value) {
if ((await this.getEpisode(true, { subs: { dlsubs: argv.dlsubs, nosubs: argv.nosubs, sub: false, ccTag: argv.ccTag }, dubLang: argv.dubLang, fnSlug: episodeData, s: argv.s, simul: argv.simul }, {
ass: false,
...argv
})).isOk !== true)
ok = false;
}
return ok;
}
else{
console.info('No option selected or invalid value entered. Try --help.');
}
}
public async auth(data: AuthData): Promise<AuthResponse> {
const authOpts = {
user: data.username,
pass: data.password
};
const authData = await getData({
baseUrl: api_host,
url: '/auth/login/',
auth: authOpts,
debug: this.debug,
});
if(authData.ok && authData.res){
const resJSON = JSON.parse(authData.res.body);
if(resJSON.token){
console.info('Authentication success, your token: %s%s\n', resJSON.token.slice(0,8),'*'.repeat(32));
yamlCfg.saveFuniToken({'token': resJSON.token});
this.token = resJSON.token;
return { isOk: true, value: undefined };
} else {
console.info('[ERROR]%s\n', ' No token found');
if (this.debug) {
console.info(resJSON);
}
return { isOk: false, reason: new Error(resJSON) };
}
}
return { isOk: false, reason: new Error('Login request failed') };
}
public async searchShow(log: boolean, data: SearchData): Promise<FuniSearchReponse> {
const qs = {unique: true, limit: 100, q: data.search, offset: 0 };
const searchData = await getData({
baseUrl: api_host,
url: '/source/funimation/search/auto/',
querystring: qs,
token: this.token,
useToken: true,
debug: this.debug,
});
if(!searchData.ok || !searchData.res){
return { isOk: false, reason: new Error('Request is not ok') };
}
const searchDataJSON = JSON.parse(searchData.res.body);
if(searchDataJSON.detail){
console.error(`${searchDataJSON.detail}`);
return { isOk: false, reason: new Error(searchDataJSON.defail) };
}
if(searchDataJSON.items && searchDataJSON.items.hits && log){
const shows = searchDataJSON.items.hits;
console.info('Search Results:');
for(const ssn in shows){
console.info(`[#${shows[ssn].id}] ${shows[ssn].title}` + (shows[ssn].tx_date?` (${shows[ssn].tx_date})`:''));
}
}
if (log)
console.info('Total shows found: %s\n',searchDataJSON.count);
return { isOk: true, value: searchDataJSON };
}
public async listShowItems(id: number) : Promise<ResponseBase<Item[]>> {
const showData = await getData({
baseUrl: api_host,
url: `/source/catalog/title/${id}`,
token: this.token,
useToken: true,
debug: this.debug,
});
// check errors
if(!showData.ok || !showData.res){ return { isOk: false, reason: new Error('ShowData is not ok') }; }
const showDataJSON = JSON.parse(showData.res.body);
if(showDataJSON.status){
console.error('Error #%d: %s\n', showDataJSON.status, showDataJSON.data.errors[0].detail);
return { isOk: false, reason: new Error(showDataJSON.data.errors[0].detail) };
}
else if(!showDataJSON.items || showDataJSON.items.length<1){
console.error('Show not found\n');
return { isOk: false, reason: new Error('Show not found') };
}
const showDataItem = showDataJSON.items[0];
console.info('[#%s] %s (%s)',showDataItem.id,showDataItem.title,showDataItem.releaseYear);
// show episodes
const qs: {
limit: number,
sort: string,
sort_direction: string,
title_id: number,
language?: string
} = { limit: -1, sort: 'order', sort_direction: 'ASC', title_id: id };
const episodesData = await getData({
baseUrl: api_host,
url: '/funimation/episodes/',
querystring: qs,
token: this.token,
useToken: true,
debug: this.debug,
});
if(!episodesData.ok || !episodesData.res){ return { isOk: false, reason: new Error('episodesData is not ok') }; }
let epsDataArr: Item[] = JSON.parse(episodesData.res.body).items;
const epNumRegex = /^([A-Z0-9]*[A-Z])?(\d+)$/i;
const parseEpStr = (epStr: string) => {
const match = epStr.match(epNumRegex);
if (!match) {
console.error('No match found');
return ['', ''];
}
if(match.length > 2){
const spliced = [...match].splice(1);
spliced[0] = spliced[0] ? spliced[0] : '';
return spliced;
}
else return [ '', match[0] ];
};
epsDataArr = epsDataArr.map(e => {
const baseId = e.ids.externalAsianId ? e.ids.externalAsianId : e.ids.externalEpisodeId;
e.id = baseId.replace(new RegExp('^' + e.ids.externalShowId), '');
if(e.id.match(epNumRegex)){
const epMatch = parseEpStr(e.id);
Funi.epIdLen = epMatch[1].length > Funi.epIdLen ? epMatch[1].length : Funi.epIdLen;
Funi.typeIdLen = epMatch[0].length > Funi.typeIdLen ? epMatch[0].length : Funi.typeIdLen;
e.id_split = epMatch;
}
else{
Funi.typeIdLen = 3 > Funi.typeIdLen? 3 : Funi.typeIdLen;
console.error('FAILED TO PARSE: ', e.id);
e.id_split = [ 'ZZZ', 9999 ];
}
return e;
});
epsDataArr.sort((a, b) => {
if (a.item.seasonOrder < b.item.seasonOrder && a.id.localeCompare(b.id) < 0) {
return -1;
}
if (a.item.seasonOrder > b.item.seasonOrder && a.id.localeCompare(b.id) > 0) {
return 1;
}
return 0;
});
return { isOk: true, value: epsDataArr };
}
public async getShow(log: boolean, data: FuniGetShowData) : Promise<FuniShowResponse> {
const showList = await this.listShowItems(data.id);
if (!showList.isOk)
return showList;
const eps = showList.value;
const epSelList = parseSelect(data.e as string, data.but);
const fnSlug: FuniEpisodeData[] = [], epSelEpsTxt: string[] = []; let is_selected = false;
for(const e in eps){
eps[e].id_split[1] = parseInt(eps[e].id_split[1].toString()).toString().padStart(Funi.epIdLen, '0');
let epStrId = eps[e].id_split.join('');
// select
is_selected = false;
if (data.all || epSelList.isSelected(epStrId)) {
fnSlug.push({
title:eps[e].item.titleSlug,
episode:eps[e].item.episodeSlug,
episodeID:epStrId,
epsiodeNumber: eps[e].item.episodeNum,
seasonTitle: eps[e].item.seasonTitle,
seasonNumber: eps[e].item.seasonNum,
ids: {
episode: eps[e].ids.externalEpisodeId,
season: eps[e].ids.externalSeasonId,
show: eps[e].ids.externalShowId
},
image: eps[e].item.poster
});
epSelEpsTxt.push(epStrId);
is_selected = true;
}
// console vars
const tx_snum = eps[e].item.seasonNum=='1'?'':` S${eps[e].item.seasonNum}`;
const tx_type = eps[e].mediaCategory != 'episode' ? eps[e].mediaCategory : '';
const tx_enum = eps[e].item.episodeNum && eps[e].item.episodeNum !== '' ?
`#${(parseInt(eps[e].item.episodeNum) < 10 ? '0' : '')+eps[e].item.episodeNum}` : '#'+eps[e].item.episodeId;
const qua_str = eps[e].quality.height ? eps[e].quality.quality + eps[e].quality.height : 'UNK';
const aud_str = eps[e].audio.length > 0 ? `, ${eps[e].audio.join(', ')}` : '';
const rtm_str = eps[e].item.runtime !== '' ? eps[e].item.runtime : '??:??';
// console string
eps[e].id_split[0] = eps[e].id_split[0].toString().padStart(Funi.typeIdLen, ' ');
epStrId = eps[e].id_split.join('');
let conOut = `[${epStrId}] `;
conOut += `${eps[e].item.titleName+tx_snum} - ${tx_type+tx_enum} ${eps[e].item.episodeName} `;
conOut += `(${rtm_str}) [${qua_str+aud_str}]`;
conOut += is_selected ? ' (selected)' : '';
conOut += eps.length-1 == parseInt(e) ? '\n' : '';
console.info(conOut);
}
if(fnSlug.length < 1){
if (log)
console.info('Episodes not selected!\n');
return { isOk: true, value: [] } ;
}
else{
if (log)
console.info('Selected Episodes: %s\n',epSelEpsTxt.join(', '));
return { isOk: true, value: fnSlug };
}
}
public async getEpisode(log: boolean, data: FuniGetEpisodeData, downloadData: FuniStreamData) : Promise<FuniGetEpisodeResponse> {
const episodeData = await getData({
baseUrl: api_host,
url: `/source/catalog/episode/${data.fnSlug.title}/${data.fnSlug.episode}/`,
token: this.token,
useToken: true,
debug: this.debug,
});
if(!episodeData.ok || !episodeData.res){return { isOk: false, reason: new Error('Unable to get episodeData') }; }
const ep = JSON.parse(episodeData.res.body).items[0] as EpisodeData, streamIds: { id: number, lang: langsData.LanguageItem }[] = [];
// build fn
season = parseInt(ep.parent.seasonNumber);
if(ep.mediaCategory != 'Episode'){
ep.number = ep.number !== '' ? ep.mediaCategory+ep.number : ep.mediaCategory+'#'+ep.id;
}
fnEpNum = isNaN(parseInt(ep.number)) ? ep.number : parseInt(ep.number);
// is uncut
const uncut = {
Japanese: false,
English: false
};
// end
if (log) {
console.info(
'%s - S%sE%s - %s',
ep.parent.title,
(ep.parent.seasonNumber ? ep.parent.seasonNumber : '?'),
(ep.number ? ep.number : '?'),
ep.title
);
console.info('Available streams (Non-Encrypted):');
}
// map medias
const media = await Promise.all(ep.media.map(async (m) =>{
if(m.mediaType == 'experience'){
if(m.version.match(/uncut/i) && m.language){
uncut[m.language] = true;
}
return {
id: m.id,
language: m.language,
version: m.version,
type: m.experienceType,
subtitles: await this.getSubsUrl(m.mediaChildren, m.language, data.subs, ep.ids.externalEpisodeId, data.subs.ccTag)
};
}
else{
return { id: 0, type: '' };
}
}));
// select
stDlPath = [];
for(const m of media){
let selected = false;
if(m.id > 0 && m.type == 'Non-Encrypted'){
const dub_type = m.language;
if (!dub_type)
continue;
let localSubs: Subtitle[] = [];
const selUncut = !data.simul && uncut[dub_type] && m.version?.match(/uncut/i)
? true
: (!uncut[dub_type] || data.simul && m.version?.match(/simulcast/i) ? true : false);
for (const curDub of data.dubLang) {
const item = langsData.languages.find(a => a.code === curDub);
if(item && (dub_type === item.funi_name_lagacy || dub_type === (item.funi_name ?? item.name)) && selUncut){
streamIds.push({
id: m.id,
lang: item
});
stDlPath.push(...m.subtitles);
localSubs = m.subtitles;
selected = true;
}
}
if (log) {
const subsToDisplay: langsData.LanguageItem[] = [];
localSubs.forEach(a => {
if (!subsToDisplay.includes(a.lang))
subsToDisplay.push(a.lang);
});
console.info(`[#${m.id}] ${dub_type} [${m.version}]${(selected?' (selected)':'')}${
localSubs && localSubs.length > 0 && selected ? ` (using ${subsToDisplay.map(a => `'${a.name}'`).join(', ')} for subtitles)` : ''
}`);
}
}
}
const already: string[] = [];
stDlPath = stDlPath.filter(a => {
if (already.includes(`${a.closedCaption ? 'cc' : ''}-${a.lang.code}`)) {
return false;
} else {
already.push(`${a.closedCaption ? 'cc' : ''}-${a.lang.code}`);
return true;
}
});
if(streamIds.length < 1){
if (log)
console.error('Track not selected\n');
return { isOk: false, reason: new Error('Track not selected') };
}
else{
tsDlPath = [];
for (const streamId of streamIds) {
const streamData = await getData({
baseUrl: api_host,
url: `/source/catalog/video/${streamId.id}/signed`,
token: this.token,
dinstid: 'uuid',
useToken: true,
debug: this.debug,
});
if(!streamData.ok || !streamData.res){return { isOk: false, reason: new Error('Unable to get streamdata') };}
const streamDataRes = JSON.parse(streamData.res.body) as StreamData;
if(streamDataRes.errors){
if (log)
console.info('Error #%s: %s\n',streamDataRes.errors[0].code,streamDataRes.errors[0].detail);
return { isOk: false, reason: new Error(streamDataRes.errors[0].detail) };
}
else{
for(const u in streamDataRes.items){
if(streamDataRes.items[u].videoType == 'm3u8'){
tsDlPath.push({
path: streamDataRes.items[u].src,
lang: streamId.lang
});
break;
}
}
}
}
if(tsDlPath.length < 1){
if (log)
console.error('Unknown error\n');
return { isOk: false, reason: new Error('Unknown error') };
}
else{
const res = await this.downloadStreams(true, {
id: data.fnSlug.episodeID,
title: ep.title,
showTitle: ep.parent.title,
image: ep.thumb
}, downloadData);
if (res === true) {
downloaded({
service: 'funi',
type: 's'
}, data.s, [data.fnSlug.episodeID]);
return { isOk: res, value: undefined };
}
return { isOk: false, reason: new Error('Unknown download error') };
}
}
}
public async downloadStreams(log: boolean, episode: FunimationMediaDownload, data: FuniStreamData): Promise<boolean|void> {
// req playlist
const purvideo: DownloadedFile[] = [];
const puraudio: DownloadedFile[] = [];
const audioAndVideo: DownloadedFile[] = [];
for (const streamPath of tsDlPath) {
const plQualityReq = await getData({
url: streamPath.path,
debug: this.debug,
});
if(!plQualityReq.ok || !plQualityReq.res){return;}
const plQualityLinkList = m3u8(plQualityReq.res.body);
const mainServersList = [
'vmfst-api.prd.funimationsvc.com',
'd33et77evd9bgg.cloudfront.net',
'd132fumi6di1wa.cloudfront.net',
'funiprod.akamaized.net',
];
const plServerList: string[] = [],
plStreams: Record<string|number, {
[key: string]: string
}> = {},
plLayersStr: string[] = [],
plLayersRes: Record<string|number, {
width: number,
height: number
}> = {};
let plMaxLayer = 1,
plNewIds = 1,
plAud: undefined|{
uri: string
language: langsData.LanguageItem
};
// new uris
const vplReg = /streaming_video_(\d+)_(\d+)_(\d+)_index\.m3u8/;
if(plQualityLinkList.playlists[0].uri.match(vplReg)){
const audioKey = Object.keys(plQualityLinkList.mediaGroups.AUDIO).pop();
if (!audioKey)
return console.error('No audio key found');
if(plQualityLinkList.mediaGroups.AUDIO[audioKey]){
const audioDataParts = plQualityLinkList.mediaGroups.AUDIO[audioKey],
audioEl = Object.keys(audioDataParts);
const audioData = audioDataParts[audioEl[0]];
let language = langsData.languages.find(a => a.code === audioData.language || a.locale === audioData.language);
if (!language) {
language = langsData.languages.find(a => a.funi_name_lagacy === audioEl[0] || ((a.funi_name ?? a.name) === audioEl[0]));
if (!language) {
if (log)
console.error(`Unable to find language for locale ${audioData.language} or name ${audioEl[0]}`);
return;
}
}
plAud = {
uri: audioData.uri,
language: language
};
}
plQualityLinkList.playlists.sort((a, b) => {
const aMatch = a.uri.match(vplReg), bMatch = b.uri.match(vplReg);
if (!aMatch || !bMatch) {
console.info('Unable to match');
return 0;
}
const av = parseInt(aMatch[3]);
const bv = parseInt(bMatch[3]);
if(av > bv){
return 1;
}
if (av < bv) {
return -1;
}
return 0;
});
}
for(const s of plQualityLinkList.playlists){
if(s.uri.match(/_Layer(\d+)\.m3u8/) || s.uri.match(vplReg)){
// set layer and max layer
let plLayerId: number|string = 0;
const match = s.uri.match(/_Layer(\d+)\.m3u8/);
if(match){
plLayerId = parseInt(match[1]);
}
else{
plLayerId = plNewIds, plNewIds++;
}
plMaxLayer = plMaxLayer < plLayerId ? plLayerId : plMaxLayer;
// set urls and servers
const plUrlDl = s.uri;
const plServer = new URL(plUrlDl).host;
if(!plServerList.includes(plServer)){
plServerList.push(plServer);
}
if(!Object.keys(plStreams).includes(plServer)){
plStreams[plServer] = {};
}
if(plStreams[plServer][plLayerId] && plStreams[plServer][plLayerId] != plUrlDl){
console.warn(`Non duplicate url for ${plServer} detected, please report to developer!`);
}
else{
plStreams[plServer][plLayerId] = plUrlDl;
}
// set plLayersStr
const plResolution = s.attributes.RESOLUTION;
plLayersRes[plLayerId] = plResolution;
const plBandwidth = Math.round(s.attributes.BANDWIDTH/1024);
if(plLayerId<10){
plLayerId = plLayerId.toString().padStart(2,' ');
}
const qualityStrAdd = `${plLayerId}: ${plResolution.width}x${plResolution.height} (${plBandwidth}KiB/s)`;
const qualityStrRegx = new RegExp(qualityStrAdd.replace(/(:|\(|\)|\/)/g,'\\$1'),'m');
const qualityStrMatch = !plLayersStr.join('\r\n').match(qualityStrRegx);
if(qualityStrMatch){
plLayersStr.push(qualityStrAdd);
}
}
else {
console.info(s.uri);
}
}
for(const s of mainServersList){
if(plServerList.includes(s)){
plServerList.splice(plServerList.indexOf(s), 1);
plServerList.unshift(s);
break;
}
}
const plSelectedServer = plServerList[data.x-1];
const plSelectedList = plStreams[plSelectedServer];
plLayersStr.sort();
if (log) {
console.info(`Servers available:\n\t${plServerList.join('\n\t')}`);
console.info(`Available qualities:\n\t${plLayersStr.join('\n\t')}`);
}
const selectedQuality = data.q === 0 || data.q > Object.keys(plLayersRes).length
? Object.keys(plLayersRes).pop() as string
: data.q;
const videoUrl = data.x < plServerList.length+1 && plSelectedList[selectedQuality] ? plSelectedList[selectedQuality] : '';
if(videoUrl != ''){
if (log) {
console.info(`Selected layer: ${selectedQuality} (${plLayersRes[selectedQuality].width}x${plLayersRes[selectedQuality].height}) @ ${plSelectedServer}`);
console.info('Stream URL:',videoUrl);
}
fnOutput = parseFileName(data.fileName, ([
['episode', isNaN(parseInt(fnEpNum as string)) ? fnEpNum : parseInt(fnEpNum as string), true],
['title', episode.title, true],
['showTitle', episode.showTitle, true],
['season', season, false],
['width', plLayersRes[selectedQuality].width, false],
['height', plLayersRes[selectedQuality].height, false],
['service', 'Funimation', 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;
}), data.numbers, data.override);
if (fnOutput.length < 1)
throw new Error(`Invalid path generated for input ${data.fileName}`);
if (log)
console.info(`Output filename: ${fnOutput.join(path.sep)}.ts`);
}
else if(data.x > plServerList.length){
if (log)
console.error('Server not selected!\n');
return;
}
else{
if (log)
console.error('Layer not selected!\n');
return;
}
let dlFailed = false;
let dlFailedA = false;
await fs.promises.mkdir(path.join(this.cfg.dir.content, ...fnOutput.slice(0, -1)), { recursive: true });
video: if (!data.novids) {
if (plAud && (purvideo.length > 0 || audioAndVideo.length > 0)) {
break video;
} else if (!plAud && (audioAndVideo.some(a => a.lang === streamPath.lang) || puraudio.some(a => a.lang === streamPath.lang))) {
break video;
}
// download video
const reqVideo = await getData({
url: videoUrl,
debug: this.debug,
});
if (!reqVideo.ok || !reqVideo.res) { break video; }
const chunkList = m3u8(reqVideo.res.body);
const tsFile = path.join(this.cfg.dir.content, ...fnOutput.slice(0, -1), `${fnOutput.slice(-1)}.video${(plAud?.uri ? '' : '.' + streamPath.lang.code )}`);
dlFailed = !await this.downloadFile(tsFile, chunkList, data.timeout, data.partsize, data.fsRetryTime, data.force, data.callbackMaker ? data.callbackMaker({
fileName: `${fnOutput.slice(-1)}.video${(plAud?.uri ? '' : '.' + streamPath.lang.code )}.ts`,
parent: {
title: episode.showTitle
},
title: episode.title,
image: episode.image,
language: streamPath.lang,
}) : undefined);
if (!dlFailed) {
if (plAud) {
purvideo.push({
path: `${tsFile}.ts`,
lang: plAud.language
});
} else {
audioAndVideo.push({
path: `${tsFile}.ts`,
lang: streamPath.lang
});
}
}
}
else{
if (log)
console.info('Skip video downloading...\n');
}
audio: if (plAud && !data.noaudio) {
// download audio
if (audioAndVideo.some(a => a.lang === plAud?.language) || puraudio.some(a => a.lang === plAud?.language))
break audio;
const reqAudio = await getData({
url: plAud.uri,
debug: this.debug,
});
if (!reqAudio.ok || !reqAudio.res) { return; }
const chunkListA = m3u8(reqAudio.res.body);
const tsFileA = path.join(this.cfg.dir.content, ...fnOutput.slice(0, -1), `${fnOutput.slice(-1)}.audio.${plAud.language.code}`);
dlFailedA = !await this.downloadFile(tsFileA, chunkListA, data.timeout, data.partsize, data.fsRetryTime, data.force, data.callbackMaker ? data.callbackMaker({
fileName: `${fnOutput.slice(-1)}.audio.${plAud.language.code}.ts`,
parent: {
title: episode.showTitle
},
title: episode.title,
image: episode.image,
language: plAud.language
}) : undefined);
if (!dlFailedA)
puraudio.push({
path: `${tsFileA}.ts`,
lang: plAud.language
});
}
}
// add subs
const subsExt = !data.mp4 || data.mp4 && data.ass ? '.ass' : '.srt';
let addSubs = true;
// download subtitles
if(stDlPath.length > 0){
if (log)
console.info('Downloading subtitles...');
for (const subObject of stDlPath) {
const subsSrc = await getData({
url: subObject.url,
debug: this.debug,
});
if(subsSrc.ok && subsSrc.res){
const assData = vttConvert(subsSrc.res.body, (subsExt == '.srt' ? true : false), subObject.lang.name, data.fontSize, data.fontName);
subObject.out = path.join(this.cfg.dir.content, ...fnOutput.slice(0, -1), `${fnOutput.slice(-1)}.subtitle${subObject.ext}${subsExt}`);
fs.writeFileSync(subObject.out, assData);
}
else{
if (log)
console.error('Failed to download subtitles!');
addSubs = false;
break;
}
}
if (addSubs && log)
console.info('Subtitles downloaded!');
}
if((puraudio.length < 1 && audioAndVideo.length < 1) || (purvideo.length < 1 && audioAndVideo.length < 1)){
if (log)
console.info('\nUnable to locate a video AND audio file\n');
return;
}
if(data.skipmux){
if (log)
console.info('Skipping muxing...');
return;
}
// check exec
this.cfg.bin = await yamlCfg.loadBinCfg();
const mergerBin = merger.checkMerger(this.cfg.bin, data.mp4, data.forceMuxer);
if ( data.novids ){
if (log)
console.info('Video not downloaded. Skip muxing video.');
}
const ffext = !data.mp4 ? 'mkv' : 'mp4';
const mergeInstance = new merger({
onlyAudio: puraudio,
onlyVid: purvideo,
output: `${path.join(this.cfg.dir.content, ...fnOutput)}.${ffext}`,
subtitles: stDlPath.map(a => {
return {
file: a.out as string,
language: a.lang,
title: a.lang.name,
closedCaption: a.closedCaption
};
}),
videoAndAudio: audioAndVideo,
simul: data.simul,
skipSubMux: data.skipSubMux,
videoTitle: data.videoTitle,
options: {
ffmpeg: data.ffmpegOptions,
mkvmerge: data.mkvmergeOptions
},
defaults: {
audio: data.defaultAudio,
sub: data.defaultSub
},
ccTag: data.ccTag
});
if(mergerBin.MKVmerge){
await mergeInstance.merge('mkvmerge', mergerBin.MKVmerge);
}
else if(mergerBin.FFmpeg){
await mergeInstance.merge('ffmpeg', mergerBin.FFmpeg);
}
else{
if (log)
console.info('\nDone!\n');
return true;
}
if (data.nocleanup) {
return true;
}
mergeInstance.cleanUp();
if (log)
console.info('\nDone!\n');
return true;
}
public async downloadFile(filename: string, chunkList: {
segments: Record<string, unknown>[],
}, timeout: number, partsize: number, fsRetryTime: number, override?: 'Y' | 'y' | 'N' | 'n' | 'C' | 'c', callback?: HLSCallback) {
const downloadStatus = await new hlsDownload({
m3u8json: chunkList,
output: `${filename + '.ts'}`,
timeout: timeout,
threads: partsize,
fsRetryTime: fsRetryTime * 1000,
override,
callback
}).download();
return downloadStatus.ok;
}
public async getSubsUrl(m: MediaChild[], parentLanguage: TitleElement|undefined, data: FuniSubsData, episodeID: string, ccTag: string) : Promise<Subtitle[]> {
if((data.nosubs && !data.sub) || data.dlsubs.includes('none')){
return [];
}
const subs = await getData({
baseUrl: 'https://playback.prd.funimationsvc.com/v1/play',
url: `/${episodeID}`,
token: this.token,
useToken: true,
debug: this.debug,
querystring: { deviceType: 'web' }
});
if (!subs.ok || !subs.res || !subs.res.body) {
console.error('Subtitle Request failed.');
return [];
}
const parsed: SubtitleRequest = JSON.parse(subs.res.body);
const found: {
isCC: boolean;
url: string;
lang: langsData.LanguageItem;
}[] = parsed.primary.subtitles.filter(a => a.fileExt === 'vtt').map(subtitle => {
return {
isCC: subtitle.contentType === 'cc',
url: subtitle.filePath,
lang: langsData.languages.find(a => a.funi_locale === subtitle.languageCode || a.locale === subtitle.languageCode)
};
}).concat(m.filter(a => a.filePath.split('.').pop() === 'vtt').map(media => {
const lang = langsData.languages.find(a => media.language === a.funi_name_lagacy || media.language === (a.funi_name || a.name));
const pLang = langsData.languages.find(a => parentLanguage === a.funi_name_lagacy || (a.funi_name || a.name) === parentLanguage);
return {
isCC: pLang?.code === lang?.code,
url: media.filePath,
lang
};
})).filter((a) => a.lang !== undefined) as {
isCC: boolean;
url: string;
lang: langsData.LanguageItem;
}[];
const ret = found.filter(item => {
return data.dlsubs.includes('all') || data.dlsubs.some(a => a === item.lang.locale);
});
return ret.map(a => ({
ext: `.${a.lang.code}${a.isCC ? `.${ccTag}` : ''}`,
lang: a.lang,
url: a.url,
closedCaption: a.isCC
}));
}
}

View file

@ -23,8 +23,6 @@ const MenuBar: React.FC = () => {
switch(service) { switch(service) {
case 'crunchy': case 'crunchy':
return 'Crunchyroll'; return 'Crunchyroll';
case 'funi':
return 'Funimation';
case 'hidive': case 'hidive':
return 'Hidive'; return 'Hidive';
case 'ao': case 'ao':

View file

@ -3,7 +3,7 @@ import {Divider, Box, Button, Typography, Avatar} from '@mui/material';
import useStore from '../hooks/useStore'; import useStore from '../hooks/useStore';
import { StoreState } from './Store'; import { StoreState } from './Store';
type Services = 'funi'|'crunchy'|'hidive'|'ao'; type Services = 'crunchy'|'hidive'|'ao';
export const serviceContext = React.createContext<Services|undefined>(undefined); export const serviceContext = React.createContext<Services|undefined>(undefined);
@ -21,7 +21,6 @@ const ServiceProvider: FCWithChildren = ({ children }) => {
<Box sx={{ justifyContent: 'center', alignItems: 'center', display: 'flex', flexDirection: 'column', position: 'relative', top: '40vh'}}> <Box sx={{ justifyContent: 'center', alignItems: 'center', display: 'flex', flexDirection: 'column', position: 'relative', top: '40vh'}}>
<Typography color="text.primary" variant='h3' sx={{ textAlign: 'center', mb: 5 }}>Please select your service</Typography> <Typography color="text.primary" variant='h3' sx={{ textAlign: 'center', mb: 5 }}>Please select your service</Typography>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}> <Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
<Button size='large' variant="contained" onClick={() => setService('funi')} startIcon={<Avatar src={'https://static.funimation.com/static/img/favicon.ico'} />}>Funimation</Button>
<Button size='large' variant="contained" onClick={() => setService('crunchy')} startIcon={<Avatar src={'https://static.crunchyroll.com/cxweb/assets/img/favicons/favicon-32x32.png'} />}>Crunchyroll</Button> <Button size='large' variant="contained" onClick={() => setService('crunchy')} startIcon={<Avatar src={'https://static.crunchyroll.com/cxweb/assets/img/favicons/favicon-32x32.png'} />}>Crunchyroll</Button>
<Button size='large' variant="contained" onClick={() => setService('hidive')} startIcon={<Avatar src={'https://static.diceplatform.com/prod/original/dce.hidive/settings/HIDIVE_AppLogo_1024x1024.0G0vK.jpg'} />}>Hidive</Button> <Button size='large' variant="contained" onClick={() => setService('hidive')} startIcon={<Avatar src={'https://static.diceplatform.com/prod/original/dce.hidive/settings/HIDIVE_AppLogo_1024x1024.0G0vK.jpg'} />}>Hidive</Button>
<Button size='large' variant="contained" onClick={() => setService('ao')} startIcon={<Avatar src={'https://www.animeonegai.com/assets/img/anime/general/ao3-favicon.png'} />}>AnimeOnegai</Button> <Button size='large' variant="contained" onClick={() => setService('ao')} startIcon={<Avatar src={'https://www.animeonegai.com/assets/img/anime/general/ao3-favicon.png'} />}>AnimeOnegai</Button>

View file

@ -21,7 +21,7 @@ export type DownloadOptions = {
export type StoreState = { export type StoreState = {
episodeListing: Episode[]; episodeListing: Episode[];
downloadOptions: DownloadOptions, downloadOptions: DownloadOptions,
service: 'crunchy'|'funi'|'hidive'|'ao'|undefined, service: 'crunchy'|'hidive'|'ao'|undefined,
version: string, version: string,
} }

View file

@ -4,7 +4,6 @@ import { IncomingMessage } from 'http';
import { MessageHandler, GuiState } from '../../@types/messageHandler'; import { MessageHandler, GuiState } from '../../@types/messageHandler';
import { setState, getState, writeYamlCfgFile } from '../../modules/module.cfg-loader'; import { setState, getState, writeYamlCfgFile } from '../../modules/module.cfg-loader';
import CrunchyHandler from './services/crunchyroll'; import CrunchyHandler from './services/crunchyroll';
import FunimationHandler from './services/funimation';
import HidiveHandler from './services/hidive'; import HidiveHandler from './services/hidive';
import AnimeOnegaiHandler from './services/animeonegai'; import AnimeOnegaiHandler from './services/animeonegai';
import WebSocketHandler from './websocket'; import WebSocketHandler from './websocket';
@ -32,9 +31,7 @@ export default class ServiceHandler {
}); });
this.ws.events.on('setup', ({ data }) => { this.ws.events.on('setup', ({ data }) => {
if (data === 'funi') { if (data === 'crunchy') {
this.service = new FunimationHandler(this.ws);
} else if (data === 'crunchy') {
this.service = new CrunchyHandler(this.ws); this.service = new CrunchyHandler(this.ws);
} else if (data === 'hidive') { } else if (data === 'hidive') {
this.service = new HidiveHandler(this.ws); this.service = new HidiveHandler(this.ws);
@ -58,7 +55,7 @@ export default class ServiceHandler {
this.ws.events.on('version', async (_, respond) => { this.ws.events.on('version', async (_, respond) => {
respond(packageJson.version); respond(packageJson.version);
}); });
this.ws.events.on('type', async (_, respond) => respond(this.service === undefined ? undefined : this.service.name as 'hidive'|'crunchy'|'funi'|'ao')); this.ws.events.on('type', async (_, respond) => respond(this.service === undefined ? undefined : this.service.name as 'hidive'|'crunchy'|'ao'));
this.ws.events.on('checkToken', async (_, respond) => { this.ws.events.on('checkToken', async (_, respond) => {
if (this.service === undefined) if (this.service === undefined)
return respond({ isOk: false, reason: new Error('No service selected') }); return respond({ isOk: false, reason: new Error('No service selected') });

View file

@ -1,120 +0,0 @@
import { AuthData, CheckTokenResponse, EpisodeListResponse, MessageHandler, QueueItem, ResolveItemsData, SearchData, SearchResponse } from '../../../@types/messageHandler';
import Funimation from '../../../funi';
import { getDefault } from '../../../modules/module.args';
import { languages, subtitleLanguagesFilter } from '../../../modules/module.langsData';
import WebSocketHandler from '../websocket';
import Base from './base';
import { console } from '../../../modules/log';
import * as yargs from '../../../modules/module.app-args';
class FunimationHandler extends Base implements MessageHandler {
private funi: Funimation;
public name = 'funi';
constructor(ws: WebSocketHandler) {
super(ws);
this.funi = new Funimation();
this.initState();
}
public async listEpisodes (id: string) : Promise<EpisodeListResponse> {
const parse = parseInt(id);
if (isNaN(parse) || parse <= 0)
return { isOk: false, reason: new Error('The ID is invalid') };
const request = await this.funi.listShowItems(parse);
if (!request.isOk)
return request;
return { isOk: true, value: request.value.map(item => ({
e: item.id_split.join(''),
lang: item.audio ?? [],
name: item.title,
season: item.seasonNum ?? item.seasonTitle ?? item.item.seasonNum ?? item.item.seasonTitle,
seasonTitle: item.seasonTitle,
episode: item.episodeNum,
id: item.id,
img: item.thumb,
description: item.synopsis,
time: item.runtime ?? item.item.runtime
})) };
}
public async handleDefault(name: string) {
return getDefault(name, this.funi.cfg.cli);
}
public async availableDubCodes(): Promise<string[]> {
const dubLanguageCodesArray: string[] = [];
for(const language of languages){
if (language.funi_locale)
dubLanguageCodesArray.push(language.code);
}
return [...new Set(dubLanguageCodesArray)];
}
public async availableSubCodes(): Promise<string[]> {
return subtitleLanguagesFilter;
}
public async resolveItems(data: ResolveItemsData): Promise<boolean> {
console.debug(`Got resolve options: ${JSON.stringify(data)}`);
const res = await this.funi.getShow(false, { ...data, id: parseInt(data.id) });
if (!res.isOk)
return res.isOk;
this.addToQueue(res.value.map(a => {
return {
...data,
ids: [a.episodeID],
title: a.title,
parent: {
title: a.seasonTitle,
season: a.seasonNumber
},
image: a.image,
e: a.episodeID,
episode: a.epsiodeNumber
};
}));
return true;
}
public async search(data: SearchData): Promise<SearchResponse> {
console.debug(`Got search options: ${JSON.stringify(data)}`);
const funiSearch = await this.funi.searchShow(false, data);
if (!funiSearch.isOk)
return funiSearch;
return { isOk: true, value: funiSearch.value.items.hits.map(a => ({
image: a.image.showThumbnail,
name: a.title,
desc: a.description,
id: a.id,
lang: a.languages,
rating: a.starRating
})) };
}
public async checkToken(): Promise<CheckTokenResponse> {
return this.funi.checkToken();
}
public auth(data: AuthData) {
return this.funi.auth(data);
}
public async downloadItem(data: QueueItem) {
this.setDownloading(true);
console.debug(`Got download options: ${JSON.stringify(data)}`);
const res = await this.funi.getShow(false, { all: false, but: false, id: parseInt(data.id), e: data.e });
const _default = yargs.appArgv(this.funi.cfg.cli, true);
if (!res.isOk)
return this.alertError(res.reason);
for (const ep of res.value) {
await this.funi.getEpisode(false, { dubLang: data.dubLang, fnSlug: ep, s: data.id, subs: { dlsubs: data.dlsubs, sub: false, ccTag: _default.ccTag } }, { ..._default, callbackMaker: this.makeProgressHandler.bind(this), ass: true, fileName: data.fileName, q: data.q, force: 'y',
noaudio: data.noaudio, novids: data.novids });
}
this.sendMessage({ name: 'finish', data: undefined });
this.setDownloading(false);
this.onFinish();
}
}
export default FunimationHandler;

View file

@ -13,12 +13,10 @@ class HidiveHandler extends Base implements MessageHandler {
constructor(ws: WebSocketHandler) { constructor(ws: WebSocketHandler) {
super(ws); super(ws);
this.hidive = new Hidive(); this.hidive = new Hidive();
this.hidive.doInit();
this.initState(); this.initState();
} }
public async auth(data: AuthData) { public async auth(data: AuthData) {
await this.getAPIVersion();
return this.hidive.doAuth(data); return this.hidive.doAuth(data);
} }
@ -27,13 +25,7 @@ class HidiveHandler extends Base implements MessageHandler {
return { isOk: true, value: undefined }; 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<SearchResponse> { public async search(data: SearchData): Promise<SearchResponse> {
await this.getAPIVersion();
console.debug(`Got search options: ${JSON.stringify(data)}`); console.debug(`Got search options: ${JSON.stringify(data)}`);
const hidiveSearch = await this.hidive.doSearch(data); const hidiveSearch = await this.hidive.doSearch(data);
if (!hidiveSearch.isOk) { if (!hidiveSearch.isOk) {
@ -69,46 +61,24 @@ class HidiveHandler extends Base implements MessageHandler {
if (isNaN(parse) || parse <= 0) if (isNaN(parse) || parse <= 0)
return false; return false;
console.debug(`Got resolve options: ${JSON.stringify(data)}`); console.debug(`Got resolve options: ${JSON.stringify(data)}`);
await this.getAPIVersion(); const res = await this.hidive.selectSeries(parseInt(data.id), data.e, data.but, data.all);
if (this.hidive.api == 'old') { if (!res.isOk || !res.value)
const res = await this.hidive.getShow(parseInt(data.id), data.e, data.but, data.all); return res.isOk;
if (!res.isOk || !res.value) this.addToQueue(res.value.map(item => {
return res.isOk; return {
this.addToQueue(res.value.map(item => { ...data,
return { ids: [item.id],
...data, title: item.title,
ids: [item.Id], parent: {
title: item.Name, title: item.seriesTitle,
parent: { season: item.episodeInformation.seasonNumber+''
title: item.seriesTitle, },
season: parseFloat(item.SeasonNumberValue+'')+'' image: item.thumbnailUrl,
}, e: item.episodeInformation.episodeNumber+'',
image: item.ScreenShotSmallUrl, episode: item.episodeInformation.episodeNumber+'',
e: parseFloat(item.EpisodeNumberValue+'')+'', };
episode: parseFloat(item.EpisodeNumberValue+'')+'', }));
}; return true;
}));
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<EpisodeListResponse> { public async listEpisodes(id: string): Promise<EpisodeListResponse> {
@ -116,73 +86,37 @@ class HidiveHandler extends Base implements MessageHandler {
if (isNaN(parse) || parse <= 0) if (isNaN(parse) || parse <= 0)
return { isOk: false, reason: new Error('The ID is invalid') }; return { isOk: false, reason: new Error('The ID is invalid') };
await this.getAPIVersion(); const request = await this.hidive.listSeries(parse);
if (this.hidive.api == 'old') { if (!request.isOk || !request.value)
const request = await this.hidive.listShow(parse); return {isOk: false, reason: new Error('Unknown upstream error, check for additional logs')};
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) { return { isOk: true, value: request.value.map(function(item) {
const language = item.Summary.match(/^Audio: (.*)/m); const description = item.description.split('\r\n');
language?.shift(); return {
const description = item.Summary.split('\r\n'); e: item.episodeInformation.episodeNumber+'',
return { lang: [],
e: parseFloat(item.EpisodeNumberValue+'')+'', name: item.title,
lang: language ? language[0].split(', ') : [], season: item.episodeInformation.seasonNumber+'',
name: item.Name, seasonTitle: request.series.seasons[item.episodeInformation.seasonNumber-1].title,
season: parseFloat(item.SeasonNumberValue+'')+'', episode: item.episodeInformation.episodeNumber+'',
seasonTitle: request.value.Name, id: item.id+'',
episode: parseFloat(item.EpisodeNumberValue+'')+'', img: item.thumbnailUrl,
id: item.Id+'', description: description ? description[0] : '',
img: item.ScreenShotSmallUrl, time: ''
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) { public async downloadItem(data: DownloadData) {
this.setDownloading(true); this.setDownloading(true);
console.debug(`Got download options: ${JSON.stringify(data)}`); console.debug(`Got download options: ${JSON.stringify(data)}`);
const _default = yargs.appArgv(this.hidive.cfg.cli, true); const _default = yargs.appArgv(this.hidive.cfg.cli, true);
this.hidive.api = _default.hdapi; const res = await this.hidive.selectSeries(parseInt(data.id), data.e, false, false);
if (this.hidive.api == 'old') { if (!res.isOk || !res.showData)
const res = await this.hidive.getShow(parseInt(data.id), data.e, false, false); return this.alertError(new Error('Download failed upstream, check for additional logs'));
if (!res.isOk || !res.showData)
return this.alertError(new Error('Download failed upstream, check for additional logs'));
for (const ep of res.value) { 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 }); 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 });
}
} 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.sendMessage({ name: 'finish', data: undefined });
this.setDownloading(false); this.setDownloading(false);

868
hidive.ts
View file

@ -1,7 +1,6 @@
// build-in // build-in
import path from 'path'; import path from 'path';
import fs from 'fs-extra'; import fs from 'fs-extra';
import crypto from 'crypto';
// package program // package program
import packageJson from './package.json'; import packageJson from './package.json';
@ -9,7 +8,6 @@ import packageJson from './package.json';
// plugins // plugins
import { console } from './modules/log'; import { console } from './modules/log';
import shlp from 'sei-helper'; import shlp from 'sei-helper';
import m3u8 from 'm3u8-parsed';
import streamdl, { M3U8Json } from './modules/hls-download'; import streamdl, { M3U8Json } from './modules/hls-download';
// custom modules // custom modules
@ -23,8 +21,7 @@ import vtt2ass from './modules/module.vtt2ass';
// load req // load req
import { domain, api } from './modules/module.api-urls'; import { domain, api } from './modules/module.api-urls';
import * as reqModule from './modules/module.req'; import * as reqModule from './modules/module.req';
import { HidiveEpisodeList, HidiveEpisodeExtra } from './@types/hidiveEpisodeList'; import { DownloadedMedia } from './@types/hidiveTypes';
import { HidiveVideoList, HidiveStreamInfo, DownloadedMedia, HidiveSubtitleInfo } from './@types/hidiveTypes';
import parseFileName, { Variable } from './modules/module.filename'; import parseFileName, { Variable } from './modules/module.filename';
import { downloaded } from './modules/module.downloadArchive'; import { downloaded } from './modules/module.downloadArchive';
import parseSelect from './modules/module.parseSelect'; import parseSelect from './modules/module.parseSelect';
@ -32,8 +29,6 @@ import { AvailableFilenameVars } from './modules/module.args';
import { AuthData, AuthResponse, SearchData, SearchResponse, SearchResponseItem } from './@types/messageHandler'; import { AuthData, AuthResponse, SearchData, SearchResponse, SearchResponseItem } from './@types/messageHandler';
import { ServiceClass } from './@types/serviceClassInterface'; import { ServiceClass } from './@types/serviceClassInterface';
import { sxItem } from './crunchy'; import { sxItem } from './crunchy';
import { HidiveSearch } from './@types/hidiveSearch';
import { HidiveDashboard } from './@types/hidiveDashboard';
import { Hit, NewHidiveSearch } from './@types/newHidiveSearch'; import { Hit, NewHidiveSearch } from './@types/newHidiveSearch';
import { NewHidiveSeries } from './@types/newHidiveSeries'; import { NewHidiveSeries } from './@types/newHidiveSeries';
import { Episode, NewHidiveEpisodeExtra, NewHidiveSeason, NewHidiveSeriesExtra } from './@types/newHidiveSeason'; import { Episode, NewHidiveEpisodeExtra, NewHidiveSeason, NewHidiveSeriesExtra } from './@types/newHidiveSeason';
@ -46,59 +41,27 @@ import { KeyContainer } from './modules/license';
export default class Hidive implements ServiceClass { export default class Hidive implements ServiceClass {
public cfg: yamlCfg.ConfigObject; public cfg: yamlCfg.ConfigObject;
private session: Record<string, any>;
private tokenOld: Record<string, any>;
private token: Record<string, any>; private token: Record<string, any>;
private req: reqModule.Req; private req: reqModule.Req;
public api: 'old' | 'new';
private client: {
// base
ipAddress: string,
xNonce: string,
xSignature: string,
// personal
visitId: string,
// profile data
profile: {
userId: number,
profileId: number,
deviceId : string,
}
};
constructor(private debug = false) { constructor(private debug = false) {
this.cfg = yamlCfg.loadCfg(); this.cfg = yamlCfg.loadCfg();
this.session = yamlCfg.loadHDSession();
this.tokenOld = yamlCfg.loadHDToken();
this.token = yamlCfg.loadNewHDToken(); 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'); this.req = new reqModule.Req(domain, debug, false, 'hd');
this.api = 'old';
} }
public async cli() { public async cli() {
console.info(`\n=== Multi Downloader NX ${packageJson.version} ===\n`); console.info(`\n=== Multi Downloader NX ${packageJson.version} ===\n`);
const argv = yargs.appArgv(this.cfg.cli); const argv = yargs.appArgv(this.cfg.cli);
this.api = argv.hdapi;
if (argv.debug) if (argv.debug)
this.debug = true; this.debug = true;
//below is for quickly testing API calls //below is for quickly testing API calls
/*const searchItems = await this.reqData('GetTitles', {'Filter': 'recently-added', 'Pager': {'Number': 1, 'Size': 30}, 'Sort': 'Date', 'Verbose': false}); /*const apiTest = await this.apiReq('/v4/season/18871', '', 'auth', 'GET');
const searchItems = await this.reqData('GetTitles', {'Id': 492});
if(!searchItems.ok || !searchItems.res){return;}
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;} if(!apiTest.ok || !apiTest.res){return;}
console.info(apiTest.res.body); console.info(apiTest.res.body);
fs.writeFileSync('apitest.json', JSON.stringify(JSON.parse(apiTest.res.body), null, 2)); fs.writeFileSync('apitest.json', JSON.stringify(JSON.parse(apiTest.res.body), null, 2));
return console.info('test done'); return console.info('test done');*/
}*/
// load binaries // load binaries
this.cfg.bin = await yamlCfg.loadBinCfg(); this.cfg.bin = await yamlCfg.loadBinCfg();
@ -106,42 +69,21 @@ export default class Hidive implements ServiceClass {
argv.dubLang = langsData.dubLanguageCodes; argv.dubLang = langsData.dubLanguageCodes;
} }
if (argv.auth) { if (argv.auth) {
//Initilize session
await this.doInit();
//Authenticate //Authenticate
await this.doAuth({ await this.doAuth({
username: argv.username ?? await shlp.question('[Q] LOGIN/EMAIL'), username: argv.username ?? await shlp.question('[Q] LOGIN/EMAIL'),
password: argv.password ?? await shlp.question('[Q] PASSWORD ') password: argv.password ?? await shlp.question('[Q] PASSWORD ')
}); });
} else if (argv.search && argv.search.length > 2){ } else if (argv.search && argv.search.length > 2){
//Initilize session
await this.doInit();
//Search
await this.doSearch({ ...argv, search: argv.search as string }); await this.doSearch({ ...argv, search: argv.search as string });
} else if (argv.s && !isNaN(parseInt(argv.s,10)) && parseInt(argv.s,10) > 0) { } else if (argv.s && !isNaN(parseInt(argv.s,10)) && parseInt(argv.s,10) > 0) {
if (this.api == 'old') { const selected = await this.selectSeason(parseInt(argv.s), argv.e, argv.but, argv.all);
//Initilize session if (selected.isOk && selected.showData) {
await this.doInit(); for (const select of selected.value) {
//get selected episodes //download episode
const selected = await this.getShow(parseInt(argv.s), argv.e, argv.but, argv.all); if (!(await this.downloadEpisode(select, {...argv}))) {
if (selected.isOk && selected.showData) { console.error(`Unable to download selected episode ${select.episodeInformation.episodeNumber}`);
for (const select of selected.value) { return false;
//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;
}
} }
} }
} }
@ -158,209 +100,17 @@ export default class Hidive implements ServiceClass {
} }
} }
} else if (argv.new) { } else if (argv.new) {
if (this.api == 'old') { console.error('--new is not yet implemented in the new API');
//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) { } else if(argv.e) {
if (this.api == 'new') { if (!(await this.downloadSingleEpisode(parseInt(argv.e), {...argv}))) {
if (!(await this.downloadSingleEpisode(parseInt(argv.e), {...argv}))) { console.error(`Unable to download selected episode ${argv.e}`);
console.error(`Unable to download selected episode ${argv.e}`); return false;
return false;
}
} else {
console.error('-e is not supported in the old API');
} }
} else { } else {
console.info('No option selected or invalid value entered. Try --help.'); 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();
const nonceDate = [
initDate.getUTCFullYear().toString().slice(-2), // yy
('0'+(initDate.getUTCMonth()+1)).slice(-2), // MM
('0'+initDate.getUTCDate()).slice(-2), // dd
('0'+initDate.getUTCHours()).slice(-2), // HH
('0'+initDate.getUTCMinutes()).slice(-2) // mm
].join(''); // => "yyMMddHHmm" (UTC)
const nonceCleanStr = nonceDate + api.hd_apikey;
const nonceHash = crypto.createHash('sha256').update(nonceCleanStr).digest('hex');
return nonceHash;
}
// Generate Signature
public generateSignature(body: string|object, visitId: string, profile: Record<string, any>) {
const sigCleanStr = [
this.client.ipAddress,
api.hd_appId,
profile.deviceId,
visitId,
profile.userId,
profile.profileId,
body,
this.client.xNonce,
api.hd_apikey,
].join('');
return crypto.createHash('sha256').update(sigCleanStr).digest('hex');
}
public makeCookieList(data: Record<string, any>, keys: Array<string>) {
const res = [];
for (const key of keys) {
if (typeof data[key] !== 'object') continue;
res.push(`${key}=${data[key].value}`);
}
return res.join('; ');
}
public async reqData(method: string, body: string | object, type = 'POST') {
const options = {
headers: {} as Record<string, unknown>,
method: type as 'GET'|'POST',
url: '' as string,
body: body,
};
// get request type
const isGet = type == 'GET' ? true : false;
// set request type, url, user agent, referrer, and origin
options.method = isGet ? 'GET' : 'POST';
options.url = ( !isGet ? domain.hd_api + '/api/v1/' : '') + method;
options.headers['user-agent'] = isGet ? api.hd_clientExo : api.hd_clientWeb;
options.headers['referrer'] = 'https://www.hidive.com/';
options.headers['origin'] = 'https://www.hidive.com';
// set api data
if(!isGet){
options.body = body == '' ? body : JSON.stringify(body);
// set api headers
if(method != 'Ping'){
const visitId = this.client.visitId ? this.client.visitId : '';
const vprofile = {
userId: this.client.profile.userId || 0,
profileId: this.client.profile.profileId || 0,
deviceId: this.client.profile.deviceId || '',
};
this.client.xNonce = this.generateNonce();
this.client.xSignature = this.generateSignature(options.body, visitId, vprofile);
options.headers = Object.assign(options.headers, {
'X-VisitId' : visitId,
'X-UserId' : vprofile.userId,
'X-ProfileId' : vprofile.profileId,
'X-DeviceId' : vprofile.deviceId,
'X-Nonce' : this.client.xNonce,
'X-Signature' : this.client.xSignature,
});
}
options.headers = Object.assign({
'Content-Type' : 'application/x-www-form-urlencoded; charset=UTF-8',
'X-ApplicationId': api.hd_appId,
}, options.headers);
// cookies
const cookiesList = Object.keys(this.session);
if(cookiesList.length > 0 && method != 'Ping') {
options.headers.Cookie = this.makeCookieList(this.session, cookiesList);
}
} else if(isGet && !options.url.match(/\?/)){
this.client.xNonce = this.generateNonce();
this.client.xSignature = this.generateSignature(options.body, this.client.visitId, this.client.profile);
options.url = options.url + '?' + (new URLSearchParams({
'X-ApplicationId': api.hd_appId,
'X-DeviceId': this.client.profile.deviceId,
'X-VisitId': this.client.visitId,
'X-UserId': this.client.profile.userId+'',
'X-ProfileId': this.client.profile.profileId+'',
'X-Nonce': this.client.xNonce,
'X-Signature': this.client.xSignature,
})).toString();
}
try {
if (this.debug) {
console.debug('[DEBUG] Request params:');
console.debug(options);
}
const apiReqOpts: reqModule.Params = {
method: options.method,
headers: options.headers as Record<string, string>,
body: options.body as string
};
const apiReq = await this.req.getData(options.url, apiReqOpts);
if(!apiReq.ok || !apiReq.res){
console.error('API Request Failed!');
return {
ok: false,
res: apiReq.res,
};
}
if (!isGet && apiReq.res.headers && apiReq.res.headers['set-cookie']) {
const newReqCookies = shlp.cookie.parse(apiReq.res.headers['set-cookie'] as unknown as Record<string, string>);
this.session = Object.assign(this.session, newReqCookies);
yamlCfg.saveHDSession(this.session);
}
if (!isGet) {
const resJ = JSON.parse(apiReq.res.body);
if (resJ.Code > 0) {
console.error(`Code ${resJ.Code} (${resJ.Status}): ${resJ.Message}\n`);
if (resJ.Code == 81 || resJ.Code == 5) {
console.info('[NOTE] App was broken because of changes in official app.');
console.info('[NOTE] See: https://github.com/anidl/hidive-downloader-nx/issues/1\n');
}
if (resJ.Code == 55) {
console.info('[NOTE] You need premium account to view this video.');
}
return {
ok: false,
res: apiReq.res,
};
}
}
return {
ok: true,
res: apiReq.res,
};
} catch (error: any) {
if (error.statusCode && error.statusMessage) {
console.error(`\n ${error.name} ${error.statusCode}: ${error.statusMessage}\n`);
} else {
console.error(`\n ${error.name}: ${error.code}\n`);
}
return {
ok: false,
error,
};
}
}
public async apiReq(endpoint: string, body: string | object = '', authType: 'refresh' | 'auth' | 'both' | 'other' | 'none' = 'none', method: 'GET' | 'POST' = 'POST', authHeader?: string) { public async apiReq(endpoint: string, body: string | object = '', authType: 'refresh' | 'auth' | 'both' | 'other' | 'none' = 'none', method: 'GET' | 'POST' = 'POST', authHeader?: string) {
const options = { const options = {
@ -447,43 +197,25 @@ export default class Hidive implements ServiceClass {
} }
public async doAuth(data: AuthData): Promise<AuthResponse> { public async doAuth(data: AuthData): Promise<AuthResponse> {
if (this.api == 'old') { if (!this.token.refreshToken || !this.token.authorisationToken) {
const auth = await this.reqData('Authenticate', {'Email':data.username,'Password':data.password}); await this.doAnonymousAuth();
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<string, string> = 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 };
} }
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<string, string> = 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() { public async doAnonymousAuth() {
@ -540,110 +272,48 @@ export default class Hidive implements ServiceClass {
return true; return true;
} }
public async genSubsUrl(type: string, file: string) {
return [
`${domain.hd_api}/caption/${type}/`,
( type == 'css' ? '?id=' : '' ),
`${file}.${type}`
].join('');
}
public async doSearch(data: SearchData): Promise<SearchResponse> { public async doSearch(data: SearchData): Promise<SearchResponse> {
if (this.api == 'old') { 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', {
const searchReq = await this.reqData('Search', {'Query':data.search}); method: 'POST',
if(!searchReq.ok || !searchReq.res){ body: JSON.stringify({'requests':
console.error('Search FAILED!'); [
return { isOk: false, reason: new Error('Search failed. No more information provided') }; {'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) : '')},
const searchData = JSON.parse(searchReq.res.body) as HidiveSearch; {'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) : '')},
const searchItems = searchData.Data.TitleResults; {'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(searchItems.length>0) { ]
console.info('[INFO] Search Results:'); })
for(let i=0;i<searchItems.length;i++){ });
console.info(`[#${searchItems[i].Id}] ${searchItems[i].Name} [${searchItems[i].ShowInfoTitle}]`); if(!searchReq.ok || !searchReq.res){
} console.error('Search FAILED!');
} else{ return { isOk: false, reason: new Error('Search failed. No more information provided') };
console.warn('Nothing found!');
}
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
};
})};
} 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
};
})};
} }
} const searchData = JSON.parse(searchReq.res.body) as NewHidiveSearch;
const searchItems: Hit[] = [];
public async getNewlyAdded(page?: number) { console.info('Search Results:');
const pageNum = page ? page : 1; for (const category of searchData.results) {
const dashboardReq = await this.reqData('GetDashboard', {'Pager': {'Number': pageNum, 'Size': 30}, 'Verbose': false}); for (const hit of category.hits) {
if(!dashboardReq.ok || !dashboardReq.res) { searchItems.push(hit);
console.error('Search for new episodes FAILED!'); let fullType: string;
return; if (hit.type == 'VOD_SERIES') {
} fullType = `Z.${hit.id}`;
} else if (hit.type == 'VOD_VIDEO') {
const dashboardData = JSON.parse(dashboardReq.res.body) as HidiveDashboard; fullType = `E.${hit.id}`;
const dashboardItems = dashboardData.Data.TitleRows; } else {
const recentlyAddedIndex = dashboardItems.findIndex(item => item.Name == 'Recently Added'); fullType = `${hit.type} #${hit.id}`;
const recentlyAdded = recentlyAddedIndex >= 0 ? dashboardItems[recentlyAddedIndex] : undefined;
if (recentlyAdded) {
const searchItems = recentlyAdded?.Titles;
if(searchItems.length>0) {
console.info('[INFO] Recently Added:');
for(let i=0;i<searchItems.length;i++){
console.info(`[#${searchItems[i].Id}] ${searchItems[i].Name} [${searchItems[i].ShowInfoTitle}]`);
} }
} else{ console.log(`[${fullType}] ${hit.name} ${hit.seasonsCount ? '('+hit.seasonsCount+' Seasons)' : ''}`);
console.warn('No new episodes found!');
} }
} else {
console.warn('New episode category not found!');
} }
return { isOk: true, value: searchItems.filter(a => a.type == 'VOD_SERIES').flatMap((a): SearchResponseItem => {
return {
id: a.id+'',
image: a.coverUrl ?? '/notFound.png',
name: a.name,
rating: -1,
desc: a.description
};
})};
} }
public async getSeries(id: number) { public async getSeries(id: number) {
@ -827,152 +497,6 @@ export default class Hidive implements ServiceClass {
return { isOk: true, value: selEpsArr, showData: getShowData.series }; 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 };
}
const rawShowData = JSON.parse(getShowData.res.body) as HidiveEpisodeList;
const showData = rawShowData.Data.Title;
console.info(`[#${showData.Id}] ${showData.Name} [${showData.ShowInfoTitle}]`);
return { isOk: true, value: showData };
}
async getShow(id: number, e: string | undefined, but: boolean, all: boolean) {
const getShowData = await this.listShow(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: HidiveEpisodeExtra[] = []; let ovaSeq = 1; let movieSeq = 1;
for (let i = 0; i < showData.Episodes.length; i++) {
const titleId = showData.Episodes[i].TitleId;
const epKey = showData.Episodes[i].VideoKey;
const seriesTitle = showData.Name;
let nameLong = showData.Episodes[i].DisplayNameLong;
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++;
}
else {
nameLong = epKey;
}
let sumDub: string | RegExpMatchArray | null = showData.Episodes[i].Summary.match(/^Audio: (.*)/m);
sumDub = sumDub ? `\n - ${sumDub[0]}` : '';
let sumSub: string | RegExpMatchArray | null = showData.Episodes[i].Summary.match(/^Subtitles: (.*)/m);
sumSub = sumSub ? `\n - ${sumSub[0]}` : '';
let selMark = '';
if (all ||
but && !doEpsFilter.isSelected([parseFloat(showData.Episodes[i].EpisodeNumberValue+'')+'', showData.Episodes[i].Id+'']) ||
!but && doEpsFilter.isSelected([parseFloat(showData.Episodes[i].EpisodeNumberValue+'')+'', showData.Episodes[i].Id+''])
) {
selEpsArr.push({ isSelected: true, titleId, epKey, nameLong, seriesTitle, ...showData.Episodes[i] });
selMark = '✓ ';
}
//const epKeyTitle = !epKey.match(/e(\d+)$/) ? nameLong : epKey;
//const titleIdStr = (titleId != id ? `#${titleId}|` : '') + epKeyTitle;
//console.info(`[${titleIdStr}] ${showData.Episodes[i].Name}${selMark}${sumDub}${sumSub}`);
console.info('%s[%s] %s%s%s',
selMark,
'S'+parseFloat(showData.Episodes[i].SeasonNumberValue+'')+'E'+parseFloat(showData.Episodes[i].EpisodeNumberValue+''),
showData.Episodes[i].Name,
sumDub,
sumSub
);
}
return { isOk: true, value: selEpsArr, showData: showData };
}
public async getEpisode(selectedEpisode: HidiveEpisodeExtra, options: Record<any, any>) {
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+'')}`;
console.info(`[INFO] ${showTitle} - ${parseFloat(selectedEpisode.EpisodeNumberValue+'')}`);
const videoList = videoData.Data.VideoLanguages;
const subsList = videoData.Data.CaptionLanguages;
console.info('[INFO] Available dubs and subtitles:');
console.info('\tVideos: ' + videoList.join('\n\t\t'));
console.info('\tSubs : ' + subsList.join('\n\t\t'));
console.info(`[INFO] Selected dub(s): ${options.dubLang.join(', ')}`);
const videoUrls = videoData.Data.VideoUrls;
const subsUrls = videoData.Data.CaptionVttUrls;
const fontSize = videoData.Data.FontSize ? videoData.Data.FontSize : options.fontSize;
const subsSel = subsList;
//Get Selected Video URLs
const videoSel = videoList.sort().filter(videoLanguage =>
langsData.languages.find(a =>
a.hd_locale ? videoLanguage.match(a.hd_locale) &&
options.dubLang.includes(a.code) : false
)
);
//Prioritize Home Video, unless simul is used
videoSel.forEach(function(video, index) {
if (index > 0) {
const video1 = video.split(', ');
const video2 = videoSel[index - 1].split(', ');
if (video1[0] == video2[0]) {
if (video1[1] == 'Home Video' && video2[1] == 'Broadcast') {
options.simul ? videoSel.splice(index, 1) : videoSel.splice(index - 1, 1);
}
}
}
});
if (videoSel.length === 0) {
console.error('No suitable videos(s) found for options!');
}
//Build video array
const selectedVideoUrls: HidiveStreamInfo[] = [];
videoSel.forEach(function(video, index) {
const videodetails = videoSel[index].split(', ');
const videoinfo: HidiveStreamInfo = videoUrls[video];
videoinfo.language = videodetails[0];
videoinfo.episodeTitle = selectedEpisode.Name;
videoinfo.seriesTitle = selectedEpisode.seriesTitle;
videoinfo.season = parseFloat(selectedEpisode.SeasonNumberValue+'');
videoinfo.episodeNumber = parseFloat(selectedEpisode.EpisodeNumberValue+'');
videoinfo.uncut = videodetails[0] == 'Home Video' ? true : false;
videoinfo.image = selectedEpisode.ScreenShotSmallUrl;
console.info(`[INFO] Selected release: ${videodetails[0]} ${videodetails[1]}`);
selectedVideoUrls.push(videoinfo);
});
//Build subtitle array
const selectedSubUrls: HidiveSubtitleInfo[] = [];
subsSel.forEach(function(sub, index) {
console.info(subsSel[index]);
const subinfo = {
url: subsUrls[sub],
cc: subsSel[index].includes('Caps'),
language: subsSel[index].replace(' Subs', '').replace(' Caps', '')
};
selectedSubUrls.push(subinfo);
});
//download media list
const res = await this.downloadMediaList(selectedVideoUrls, selectedSubUrls, fontSize, options);
if (res === undefined || res.error) {
console.error('Failed to download media list');
return { isOk: false, reason: new Error('Failed to download media list') };
} else {
if (!options.skipmux) {
await this.muxStreams(res.data, { ...options, output: res.fileName });
} else {
console.info('Skipping mux');
}
downloaded({
service: 'hidive',
type: 's'
}, selectedEpisode.titleId+'', [selectedEpisode.EpisodeNumberValue+'']);
return { isOk: res, value: undefined };
}
}
return { isOk: false, reason: new Error('Unknown download error') };
}
public async downloadEpisode(selectedEpisode: NewHidiveEpisodeExtra, options: Record<any, any>) { public async downloadEpisode(selectedEpisode: NewHidiveEpisodeExtra, options: Record<any, any>) {
//Get Episode data //Get Episode data
const episodeDataReq = await this.apiReq(`/v4/vod/${selectedEpisode.id}?includePlaybackDetails=URL`, '', 'auth', 'GET'); const episodeDataReq = await this.apiReq(`/v4/vod/${selectedEpisode.id}?includePlaybackDetails=URL`, '', 'auth', 'GET');
@ -1462,260 +986,6 @@ export default class Hidive implements ServiceClass {
}; };
} }
public async downloadMediaList(videoUrls: HidiveStreamInfo[], subUrls: HidiveSubtitleInfo[], fontSize: number, options: Record<any, any>) {
let mediaName = '...';
let fileName;
const files: DownloadedMedia[] = [];
const variables: Variable[] = [];
let dlFailed = false;
//let dlVideoOnce = false; // Variable to save if best selected video quality was downloaded
let subsMargin = 0;
let videoIndex = 0;
const chosenFontSize = options.originalFontSize ? fontSize : options.fontSize;
for (const videoData of videoUrls) {
if(videoData.seriesTitle && videoData.episodeNumber && videoData.episodeTitle){
mediaName = `${videoData.seriesTitle} - ${videoData.episodeNumber} - ${videoData.episodeTitle}`;
}
if(!options.novids && !dlFailed) {
console.info(`Requesting: ${mediaName}`);
console.info('Playlists URL: %s', videoData.hls[0]);
const streamPlaylistsReq = await this.req.getData(videoData.hls[0]);
if(!streamPlaylistsReq.ok || !streamPlaylistsReq.res){
console.error('CAN\'T FETCH VIDEO PLAYLISTS!');
return { error: true, data: []};
}
variables.push(...([
['title', videoData.episodeTitle, true],
['episode', isNaN(parseFloat(videoData.episodeNumber+'')) ? videoData.episodeNumber : parseFloat(videoData.episodeNumber+''), false],
['service', 'HD', false],
['showTitle', videoData.seriesTitle, true],
['season', videoData.season, false]
] as [AvailableFilenameVars, string|number, boolean][]).map((a): Variable => {
return {
name: a[0],
replaceWith: a[1],
type: typeof a[1],
sanitize: a[2]
} as Variable;
}));
const streamPlaylists = m3u8(streamPlaylistsReq.res.body);
const plServerList: string[] = [],
plStreams: Record<string, Record<string, string>> = {},
plQuality: {
str: string,
dim: string,
CODECS: string,
RESOLUTION: {
width: number,
height: number
}
}[] = [];
for (const pl of streamPlaylists.playlists) {
// set quality
const plResolution = pl.attributes.RESOLUTION;
const plResolutionText = `${plResolution.width}x${plResolution.height}`;
// set codecs
const plCodecs = pl.attributes.CODECS;
// parse uri
const plUri = new URL(pl.uri);
let plServer = plUri.hostname;
// set server list
if (plUri.searchParams.get('cdn')) {
plServer += ` (${plUri.searchParams.get('cdn')})`;
}
if (!plServerList.includes(plServer)) {
plServerList.push(plServer);
}
// add to server
if (!Object.keys(plStreams).includes(plServer)) {
plStreams[plServer] = {};
}
if (
plStreams[plServer][plResolutionText]
&& plStreams[plServer][plResolutionText] != pl.uri
&& typeof plStreams[plServer][plResolutionText] != 'undefined'
) {
console.error(`Non duplicate url for ${plServer} detected, please report to developer!`);
}
else {
plStreams[plServer][plResolutionText] = pl.uri;
}
// set plQualityStr
const plBandwidth = Math.round(pl.attributes.BANDWIDTH / 1024);
const qualityStrAdd = `${plResolutionText} (${plBandwidth}KiB/s)`;
const qualityStrRegx = new RegExp(qualityStrAdd.replace(/(:|\(|\)|\/)/g, '\\$1'), 'm');
const qualityStrMatch = !plQuality.map(a => a.str).join('\r\n').match(qualityStrRegx);
if (qualityStrMatch) {
plQuality.push({
str: qualityStrAdd,
dim: plResolutionText,
CODECS: plCodecs,
RESOLUTION: plResolution
});
}
}
options.x = options.x > plServerList.length ? 1 : options.x;
const plSelectedServer = plServerList[options.x - 1];
const plSelectedList = plStreams[plSelectedServer];
plQuality.sort((a, b) => {
const aMatch: RegExpMatchArray | never[] = a.dim.match(/[0-9]+/) || [];
const bMatch: RegExpMatchArray | never[] = b.dim.match(/[0-9]+/) || [];
return parseInt(aMatch[0]) - parseInt(bMatch[0]);
});
let quality = options.q === 0 ? plQuality.length : options.q;
if(quality > plQuality.length) {
console.warn(`The requested quality of ${options.q} is greater than the maximun ${plQuality.length}.\n[WARN] Therefor the maximum will be capped at ${plQuality.length}.`);
quality = plQuality.length;
}
const selPlUrl = plSelectedList[plQuality.map(a => a.dim)[quality - 1]] ? plSelectedList[plQuality.map(a => a.dim)[quality - 1]] : '';
console.info(`Servers available:\n\t${plServerList.join('\n\t')}`);
console.info(`Available qualities:\n\t${plQuality.map((a, ind) => `[${ind+1}] ${a.str}`).join('\n\t')}`);
if(selPlUrl != '') {
variables.push({
name: 'height',
type: 'number',
replaceWith: quality === 0 ? plQuality[plQuality.length - 1].RESOLUTION.height as number : plQuality[quality - 1].RESOLUTION.height
}, {
name: 'width',
type: 'number',
replaceWith: quality === 0 ? plQuality[plQuality.length - 1].RESOLUTION.width as number : plQuality[quality - 1].RESOLUTION.width
});
}
const lang = langsData.languages.find(a => a.hd_locale === videoData.language);
if (!lang) {
console.error(`Unable to find language for code ${videoData.language}`);
return { error: true, data: [] };
}
console.info(`Selected quality: ${Object.keys(plSelectedList).find(a => plSelectedList[a] === selPlUrl)} @ ${plSelectedServer}`);
console.info('Stream URL:', selPlUrl);
// TODO check filename
fileName = parseFileName(options.fileName, variables, options.numbers, options.override).join(path.sep);
const outFile = parseFileName(options.fileName + '.' + lang.name + '.' + videoIndex, variables, options.numbers, options.override).join(path.sep);
console.info(`Output filename: ${outFile}`);
const chunkPage = await this.req.getData(selPlUrl);
if(!chunkPage.ok || !chunkPage.res){
console.error('CAN\'T FETCH VIDEO PLAYLIST!');
dlFailed = true;
} else {
const chunkPlaylist = m3u8(chunkPage.res.body);
//TODO: look into how to keep bumpers without the video being affected
if(chunkPlaylist.segments[0].uri.match(/\/bumpers\//) && options.removeBumpers){
subsMargin = chunkPlaylist.segments[0].duration;
chunkPlaylist.segments.splice(0, 1);
}
const totalParts = chunkPlaylist.segments.length;
const mathParts = Math.ceil(totalParts / options.partsize);
const mathMsg = `(${mathParts}*${options.partsize})`;
console.info('Total parts in stream:', totalParts, mathMsg);
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 dlStreamByPl = await new streamdl({
output: `${tsFile}.ts`,
timeout: options.timeout,
m3u8json: chunkPlaylist,
// 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: videoData.image,
parent: {
title: videoData.seriesTitle
},
title: videoData.episodeTitle,
language: lang
}) : undefined
}).download();
if (!dlStreamByPl.ok) {
console.error(`DL Stats: ${JSON.stringify(dlStreamByPl.parts)}\n`);
dlFailed = true;
}
files.push({
type: 'Video',
path: `${tsFile}.ts`,
lang: lang,
uncut: videoData.uncut
});
//dlVideoOnce = true;
}
} else if(options.novids){
fileName = parseFileName(options.fileName, variables, options.numbers, options.override).join(path.sep);
console.info('Downloading skipped!');
}
videoIndex++;
await this.sleep(options.waittime);
}
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(subUrls.length > 0) {
let subIndex = 0;
for(const sub of subUrls) {
const subLang = langsData.languages.find(a => a.hd_locale === sub.language);
if (!subLang) {
console.warn(`Language not found for subtitle language: ${sub.language}, Skipping`);
continue;
}
const sxData: Partial<sxItem> = {};
sxData.file = langsData.subsFile(fileName as string, subIndex+'', subLang, sub.cc, 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 subs4XUrl = sub.url.split('/');
const subsXUrl = subs4XUrl[subs4XUrl.length - 1].replace(/.vtt$/, '');
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 = vtt2ass(undefined, chosenFontSize, getVttContent.res.body, getCssContent.res.body, subsMargin, options.fontName, options.combineLines);
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: sub.cc
});
} 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 muxStreams(data: DownloadedMedia[], options: Record<any, any>, inverseTrackOrder: boolean = true) { public async muxStreams(data: DownloadedMedia[], options: Record<any, any>, inverseTrackOrder: boolean = true) {
this.cfg.bin = await yamlCfg.loadBinCfg(); this.cfg.bin = await yamlCfg.loadBinCfg();
let hasAudioStreams = false; let hasAudioStreams = false;

View file

@ -18,15 +18,7 @@ import update from './modules/module.updater';
} }
if (argv.addArchive) { if (argv.addArchive) {
if (argv.service === 'funi') { if (argv.service === 'crunchy') {
if (argv.s === undefined)
return console.error('`-s` not found');
addToArchive({
service: 'funi',
type: 's'
}, argv.s);
console.info('Added %s to the downloadArchive list', argv.s);
} else if (argv.service === 'crunchy') {
if (argv.s === undefined && argv.series === undefined) if (argv.s === undefined && argv.series === undefined)
return console.error('`-s` or `--srz` not found'); return console.error('`-s` or `--srz` not found');
if (argv.s && argv.series) if (argv.s && argv.series)
@ -61,14 +53,11 @@ import update from './modules/module.updater';
overrideArguments(cfg.cli, id); overrideArguments(cfg.cli, id);
/* Reimport module to override appArgv */ /* Reimport module to override appArgv */
Object.keys(require.cache).forEach(key => { Object.keys(require.cache).forEach(key => {
if (key.endsWith('crunchy.js') || key.endsWith('funi.js') || key.endsWith('hidive.js') || key.endsWith('ao.js')) if (key.endsWith('crunchy.js') || key.endsWith('hidive.js') || key.endsWith('ao.js'))
delete require.cache[key]; delete require.cache[key];
}); });
let service: ServiceClass; let service: ServiceClass;
switch(argv.service) { switch(argv.service) {
case 'funi':
service = new (await import('./funi')).default;
break;
case 'crunchy': case 'crunchy':
service = new (await import('./crunchy')).default; service = new (await import('./crunchy')).default;
break; break;
@ -87,9 +76,6 @@ import update from './modules/module.updater';
} else { } else {
let service: ServiceClass; let service: ServiceClass;
switch(argv.service) { switch(argv.service) {
case 'funi':
service = new (await import('./funi')).default;
break;
case 'crunchy': case 'crunchy':
service = new (await import('./crunchy')).default; service = new (await import('./crunchy')).default;
break; break;

View file

@ -3,13 +3,10 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { args, groups } from './module.args'; import { args, groups } from './module.args';
const transformService = (str: Array<'funi'|'crunchy'|'hidive'|'ao'|'all'>) => { const transformService = (str: Array<'crunchy'|'hidive'|'ao'|'all'>) => {
const services: string[] = []; const services: string[] = [];
str.forEach(function(part) { str.forEach(function(part) {
switch(part) { switch(part) {
case 'funi':
services.push('Funimation');
break;
case 'crunchy': case 'crunchy':
services.push('Crunchyroll'); services.push('Crunchyroll');
break; break;
@ -33,7 +30,7 @@ If you find any bugs in this documentation or in the program itself please repor
## Legal Warning ## Legal Warning
This application is not endorsed by or affiliated with *Funimation*, *AnimeOnegai*, *Hidive*, or *Crunchyroll*. This application is not endorsed by or affiliated with *Crunchyroll*, *Hidive*, or *AnimeOnegai*.
This application enables you to download videos for offline viewing which may be forbidden by law in your country. This application enables you to download videos for offline viewing which may be forbidden by law in your country.
The usage of this application may also cause a violation of the *Terms of Service* between you and the stream provider. The usage of this application may also cause a violation of the *Terms of Service* between you and the stream provider.
This tool is not responsible for your actions; please make an informed decision before using this application. This tool is not responsible for your actions; please make an informed decision before using this application.

View file

@ -63,7 +63,7 @@ const api: APIType = {
// beta api // beta api
beta_auth: `${domain.api_beta}/auth/v1/token`, beta_auth: `${domain.api_beta}/auth/v1/token`,
beta_authBasic: 'Basic bm9haWhkZXZtXzZpeWcwYThsMHE6', beta_authBasic: 'Basic bm9haWhkZXZtXzZpeWcwYThsMHE6',
beta_authBasicMob: 'Basic b2VkYXJteHN0bGgxanZhd2ltbnE6OWxFaHZIWkpEMzJqdVY1ZFc5Vk9TNTdkb3BkSnBnbzE=', beta_authBasicMob: 'Basic bm12anNoZmtueW14eGtnN2ZiaDk6WllJVnJCV1VQYmNYRHRiRDIyVlNMYTZiNFdRb3Mzelg=',
beta_profile: `${domain.api_beta}/accounts/v1/me/profile`, beta_profile: `${domain.api_beta}/accounts/v1/me/profile`,
beta_cmsToken: `${domain.api_beta}/index/v2`, beta_cmsToken: `${domain.api_beta}/index/v2`,
search: `${domain.api_beta}/content/v2/discover/search`, search: `${domain.api_beta}/content/v2/discover/search`,

View file

@ -63,7 +63,7 @@ let argvC: {
debug: boolean | undefined; debug: boolean | undefined;
nocleanup: boolean; nocleanup: boolean;
help: boolean | undefined; help: boolean | undefined;
service: 'funi' | 'crunchy' | 'hidive' | 'ao'; service: 'crunchy' | 'hidive' | 'ao';
update: boolean; update: boolean;
fontName: string | undefined; fontName: string | undefined;
_: (string | number)[]; _: (string | number)[];
@ -71,7 +71,6 @@ let argvC: {
dlVideoOnce: boolean; dlVideoOnce: boolean;
chapters: boolean; chapters: boolean;
crapi: 'android' | 'web'; crapi: 'android' | 'web';
hdapi: 'old' | 'new';
removeBumpers: boolean; removeBumpers: boolean;
originalFontSize: boolean; originalFontSize: boolean;
keepAllVideos: boolean; keepAllVideos: boolean;

View file

@ -41,7 +41,7 @@ export type TAppArg<T extends boolean|string|number|unknown[], K = any> = {
default: T|undefined, default: T|undefined,
name?: string name?: string
}, },
service: Array<'funi'|'crunchy'|'hidive'|'ao'|'all'>, service: Array<'crunchy'|'hidive'|'ao'|'all'>,
usage: string // -(-)${name} will be added for each command, usage: string // -(-)${name} will be added for each command,
demandOption?: true, demandOption?: true,
transformer?: (value: T) => K transformer?: (value: T) => K
@ -230,20 +230,6 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
default: 'web' default: 'web'
} }
}, },
{
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: 'new'
}
},
{ {
name: 'removeBumpers', name: 'removeBumpers',
describe: 'Remove bumpers from final video', describe: 'Remove bumpers from final video',
@ -280,7 +266,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
type: 'number', type: 'number',
alias: 'server', alias: 'server',
docDescribe: true, docDescribe: true,
service: ['crunchy','funi'], service: ['crunchy'],
usage: '${server}' usage: '${server}'
}, },
{ {
@ -314,8 +300,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
name: 'dlsubs', name: 'dlsubs',
group: 'dl', group: 'dl',
describe: 'Download subtitles by language tag (space-separated)' describe: 'Download subtitles by language tag (space-separated)'
+ `\nFuni Only: ${languages.filter(a => a.funi_locale && !a.cr_locale).map(a => a.locale).join(', ')}` + `\nCrunchy Only: ${languages.filter(a => a.cr_locale).map(a => a.locale).join(', ')}`,
+ `\nCrunchy Only: ${languages.filter(a => a.cr_locale && !a.funi_locale).map(a => a.locale).join(', ')}`,
docDescribe: true, docDescribe: true,
service: ['all'], service: ['all'],
type: 'array', type: 'array',
@ -339,7 +324,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
group: 'dl', group: 'dl',
describe: 'Skip downloading audio', describe: 'Skip downloading audio',
docDescribe: true, docDescribe: true,
service: ['funi'], service: ['crunchy', 'hidive'],
type: 'boolean', type: 'boolean',
usage: '' usage: ''
}, },
@ -355,8 +340,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
{ {
name: 'dubLang', name: 'dubLang',
describe: 'Set the language to download: ' describe: 'Set the language to download: '
+ `\nFuni Only: ${languages.filter(a => a.funi_locale && !a.cr_locale).map(a => a.code).join(', ')}` + `\nCrunchy Only: ${languages.filter(a => a.cr_locale).map(a => a.code).join(', ')}`,
+ `\nCrunchy Only: ${languages.filter(a => a.cr_locale && !a.funi_locale).map(a => a.code).join(', ')}`,
docDescribe: true, docDescribe: true,
group: 'dl', group: 'dl',
choices: dubLanguageCodes, choices: dubLanguageCodes,
@ -439,7 +423,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
group: 'dl', group: 'dl',
describe: 'Force downloading simulcast version instead of uncut version (if available).', describe: 'Force downloading simulcast version instead of uncut version (if available).',
docDescribe: true, docDescribe: true,
service: ['funi', 'hidive'], service: ['hidive'],
type: 'boolean', type: 'boolean',
usage: '', usage: '',
default: { default: {
@ -572,7 +556,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
group: 'util', group: 'util',
service: ['all'], service: ['all'],
type: 'string', type: 'string',
choices: ['funi', 'crunchy', 'hidive', 'ao'], choices: ['crunchy', 'hidive', 'ao'],
usage: '${service}', usage: '${service}',
default: { default: {
default: '' default: ''
@ -593,7 +577,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
group: 'fonts', group: 'fonts',
describe: 'Set the font to use in subtiles', describe: 'Set the font to use in subtiles',
docDescribe: true, docDescribe: true,
service: ['funi', 'hidive'], service: ['hidive'],
type: 'string', type: 'string',
usage: '${fontName}', usage: '${fontName}',
}, },
@ -677,7 +661,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
describe: 'Authenticate every time the script runs. Use at your own risk.', describe: 'Authenticate every time the script runs. Use at your own risk.',
docDescribe: true, docDescribe: true,
group: 'auth', group: 'auth',
service: ['funi','crunchy'], service: ['crunchy'],
type: 'boolean', type: 'boolean',
usage: '', usage: '',
default: { default: {

View file

@ -18,14 +18,12 @@ const guiCfgFile = path.join(workingDir, 'config', 'gui');
const cliCfgFile = path.join(workingDir, 'config', 'cli-defaults'); const cliCfgFile = path.join(workingDir, 'config', 'cli-defaults');
const hdPflCfgFile = path.join(workingDir, 'config', 'hd_profile'); const hdPflCfgFile = path.join(workingDir, 'config', 'hd_profile');
const sessCfgFile = { const sessCfgFile = {
funi: path.join(workingDir, 'config', 'funi_sess'),
cr: path.join(workingDir, 'config', 'cr_sess'), cr: path.join(workingDir, 'config', 'cr_sess'),
hd: path.join(workingDir, 'config', 'hd_sess'), hd: path.join(workingDir, 'config', 'hd_sess'),
ao: path.join(workingDir, 'config', 'ao_sess') ao: path.join(workingDir, 'config', 'ao_sess')
}; };
const stateFile = path.join(workingDir, 'config', 'guistate'); const stateFile = path.join(workingDir, 'config', 'guistate');
const tokenFile = { const tokenFile = {
funi: path.join(workingDir, 'config', 'funi_token'),
cr: path.join(workingDir, 'config', 'cr_token'), cr: path.join(workingDir, 'config', 'cr_token'),
hd: path.join(workingDir, 'config', 'hd_token'), hd: path.join(workingDir, 'config', 'hd_token'),
ao: path.join(workingDir, 'config', 'ao_token'), ao: path.join(workingDir, 'config', 'ao_token'),
@ -333,33 +331,6 @@ const saveNewHDToken = (data: Record<string, unknown>) => {
} }
}; };
const loadFuniToken = () => {
const loadedToken = loadYamlCfgFile<{
token?: string
}>(tokenFile.funi, true);
let token: false|string = false;
if (loadedToken && loadedToken.token)
token = loadedToken.token;
// info if token not set
if(!token){
console.info('[INFO] Token not set!\n');
}
return token;
};
const saveFuniToken = (data: {
token?: string
}) => {
const cfgFolder = path.dirname(tokenFile.funi);
try{
fs.ensureDirSync(cfgFolder);
fs.writeFileSync(`${tokenFile.funi}.yml`, yaml.stringify(data));
}
catch(e){
console.error('Can\'t save token file to disk!');
}
};
const cfgDir = path.join(workingDir, 'config'); const cfgDir = path.join(workingDir, 'config');
const getState = (): GuiState => { const getState = (): GuiState => {
@ -394,8 +365,6 @@ const setState = (state: GuiState) => {
export { export {
loadBinCfg, loadBinCfg,
loadCfg, loadCfg,
loadFuniToken,
saveFuniToken,
saveCRSession, saveCRSession,
loadCRSession, loadCRSession,
saveCRToken, saveCRToken,

View file

@ -11,9 +11,6 @@ export type ItemType = {
}[] }[]
export type DataType = { export type DataType = {
funi: {
s: ItemType
},
hidive: { hidive: {
s: ItemType s: ItemType
}, },
@ -27,9 +24,6 @@ export type DataType = {
} }
const addToArchive = (kind: { const addToArchive = (kind: {
service: 'funi',
type: 's'
} | {
service: 'crunchy', service: 'crunchy',
type: 's'|'srz' type: 's'|'srz'
} | { } | {
@ -51,16 +45,7 @@ const addToArchive = (kind: {
}); });
(data as any)[kind.service][kind.type] = items; (data as any)[kind.service][kind.type] = items;
} else { } else {
if (kind.service === 'funi') { if (kind.service === 'ao') {
data['funi'] = {
s: [
{
id: ID,
already: []
}
]
};
} else if (kind.service === 'ao') {
data['ao'] = { data['ao'] = {
s: [ s: [
{ {
@ -95,9 +80,6 @@ const addToArchive = (kind: {
}; };
const downloaded = (kind: { const downloaded = (kind: {
service: 'funi',
type: 's'
} | {
service: 'crunchy', service: 'crunchy',
type: 's'|'srz' type: 's'|'srz'
} | { } | {
@ -123,7 +105,7 @@ const downloaded = (kind: {
fs.writeFileSync(archiveFile, JSON.stringify(data, null, 4)); fs.writeFileSync(archiveFile, JSON.stringify(data, null, 4));
}; };
const makeCommand = (service: 'funi'|'crunchy'|'hidive'|'ao') : Partial<ArgvType>[] => { const makeCommand = (service: 'crunchy'|'hidive'|'ao') : Partial<ArgvType>[] => {
const data = loadData(); const data = loadData();
const ret: Partial<ArgvType>[] = []; const ret: Partial<ArgvType>[] = [];
const kind = data[service]; const kind = data[service];

View file

@ -8,19 +8,16 @@ export type LanguageItem = {
locale: string, locale: string,
code: string, code: string,
name: string, name: string,
language?: string, language?: string
funi_locale?: string,
funi_name?: string,
funi_name_lagacy?: string
} }
const languages: LanguageItem[] = [ const languages: LanguageItem[] = [
{ cr_locale: 'en-US', new_hd_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', locale: 'en', code: 'eng', name: 'English' },
{ cr_locale: 'en-IN', locale: 'en-IN', code: 'eng', name: 'English (India)', }, { cr_locale: 'en-IN', locale: 'en-IN', code: 'eng', name: 'English (India)', },
{ 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-LA', new_hd_locale: 'es-MX', hd_locale: 'Spanish LatAm', locale: 'es-419', code: 'spa', name: 'Spanish', language: 'Latin American Spanish' },
{ cr_locale: 'es-419', ao_locale: 'es', hd_locale: 'Spanish', locale: 'es-419', code: 'spa-419', name: 'Spanish', language: 'Latin American Spanish' }, { cr_locale: 'es-419',ao_locale: 'es',hd_locale: 'Spanish', locale: 'es-419', code: 'spa-419', name: 'Spanish', language: 'Latin American Spanish' },
{ cr_locale: 'es-ES', new_hd_locale: 'es-ES', hd_locale: 'Spanish Europe', locale: 'es-ES', code: 'spa-ES', name: 'Castilian', language: 'European Spanish' }, { cr_locale: 'es-ES', new_hd_locale: 'es-ES', hd_locale: 'Spanish Europe', locale: 'es-ES', code: 'spa-ES', name: 'Castilian', language: 'European Spanish' },
{ cr_locale: 'pt-BR', ao_locale: 'pt', 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-BR', ao_locale: 'pt',new_hd_locale: 'pt-BR', hd_locale: 'Portuguese', 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: '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: 'fr-FR', hd_locale: 'French', locale: 'fr', code: 'fra', name: 'French' },
{ cr_locale: 'de-DE', hd_locale: 'German', locale: 'de', code: 'deu', name: 'German' }, { cr_locale: 'de-DE', hd_locale: 'German', locale: 'de', code: 'deu', name: 'German' },
@ -30,7 +27,7 @@ const languages: LanguageItem[] = [
{ cr_locale: 'ru-RU', hd_locale: 'Russian', locale: 'ru', code: 'rus', name: 'Russian' }, { cr_locale: 'ru-RU', hd_locale: 'Russian', locale: 'ru', code: 'rus', name: 'Russian' },
{ cr_locale: 'tr-TR', hd_locale: 'Turkish', locale: 'tr', code: 'tur', name: 'Turkish' }, { cr_locale: 'tr-TR', hd_locale: 'Turkish', locale: 'tr', code: 'tur', name: 'Turkish' },
{ cr_locale: 'hi-IN', locale: 'hi', code: 'hin', name: 'Hindi' }, { cr_locale: 'hi-IN', locale: 'hi', code: 'hin', name: 'Hindi' },
{ funi_locale: 'zhMN', locale: 'zh', code: 'cmn', name: 'Chinese (Mandarin, PRC)' }, { locale: 'zh', code: 'cmn', name: 'Chinese (Mandarin, PRC)' },
{ cr_locale: 'zh-CN', locale: 'zh-CN', code: 'zho', name: 'Chinese (Mainland China)' }, { cr_locale: 'zh-CN', locale: 'zh-CN', code: 'zho', name: 'Chinese (Mainland China)' },
{ cr_locale: 'zh-TW', locale: 'zh-TW', code: 'chi', name: 'Chinese (Taiwan)' }, { cr_locale: 'zh-TW', locale: 'zh-TW', code: 'chi', name: 'Chinese (Taiwan)' },
{ cr_locale: 'ko-KR', hd_locale: 'Korean', locale: 'ko', code: 'kor', name: 'Korean' }, { cr_locale: 'ko-KR', hd_locale: 'Korean', locale: 'ko', code: 'kor', name: 'Korean' },
@ -42,7 +39,7 @@ const languages: LanguageItem[] = [
{ cr_locale: 'vi-VN', locale: 'vi-VN', code: 'vie', name: 'Vietnamese', language: 'Tiếng Việt' }, { cr_locale: 'vi-VN', locale: 'vi-VN', code: 'vie', name: 'Vietnamese', language: 'Tiếng Việt' },
{ cr_locale: 'id-ID', locale: 'id-ID', code: 'ind', name: 'Indonesian', language: 'Bahasa Indonesia' }, { cr_locale: 'id-ID', locale: 'id-ID', code: 'ind', name: 'Indonesian', language: 'Bahasa Indonesia' },
{ cr_locale: 'te-IN', locale: 'te-IN', code: 'tel', name: 'Telugu (India)', language: 'తెలుగు' }, { cr_locale: 'te-IN', locale: 'te-IN', code: 'tel', name: 'Telugu (India)', language: 'తెలుగు' },
{ cr_locale: 'ja-JP', ao_locale: 'ja', hd_locale: 'Japanese', funi_locale: 'jaJP', locale: 'ja', code: 'jpn', name: 'Japanese' }, { cr_locale: 'ja-JP', ao_locale: 'ja', hd_locale: 'Japanese', locale: 'ja', code: 'jpn', name: 'Japanese' },
]; ];
// add en language names // add en language names

View file

@ -27,7 +27,7 @@ const usefulCookies = {
// req // req
class Req { class Req {
private sessCfg: string; private sessCfg: string;
private service: 'cr'|'funi'|'hd'|'ao'; private service: 'cr'|'hd'|'ao';
private session: Record<string, { private session: Record<string, {
value: string; value: string;
expires: Date; expires: Date;
@ -39,7 +39,7 @@ class Req {
private cfgDir = yamlCfg.cfgDir; private cfgDir = yamlCfg.cfgDir;
private curl: boolean|string = false; private curl: boolean|string = false;
constructor(private domain: Record<string, unknown>, private debug: boolean, private nosess = false, private type: 'cr'|'funi'|'hd'|'ao') { constructor(private domain: Record<string, unknown>, private debug: boolean, private nosess = false, private type: 'cr'|'hd'|'ao') {
this.sessCfg = yamlCfg.sessCfgFile[type]; this.sessCfg = yamlCfg.sessCfgFile[type];
this.service = type; this.service = type;
} }

View file

@ -1,13 +1,11 @@
{ {
"name": "multi-downloader-nx", "name": "multi-downloader-nx",
"short_name": "aniDL", "short_name": "aniDL",
"version": "4.7.0", "version": "5.0.0a1",
"description": "Downloader for Crunchyroll, Funimation, or Hidive via CLI or GUI", "description": "Downloader for Crunchyroll and Hidive with CLI and GUI",
"keywords": [ "keywords": [
"download", "download",
"downloader", "downloader",
"funimation",
"funimationnow",
"hidive", "hidive",
"crunchy", "crunchy",
"crunchyroll", "crunchyroll",

10
tsc.ts
View file

@ -32,13 +32,9 @@ const ignore = [
'./bin/mkvtoolnix*', './bin/mkvtoolnix*',
'./config/token.yml$', './config/token.yml$',
'./config/updates.json$', './config/updates.json$',
'./config/cr_token.yml$', './config/*_token.yml$',
'./config/funi_token.yml$', './config/*_sess.yml$',
'./config/ao_token.yml$', './config/*_profile.yml$',
'./config/new_hd_token.yml$',
'./config/hd_token.yml$',
'./config/hd_sess.yml$',
'./config/hd_profile.yml$',
'*/\\.eslint*', '*/\\.eslint*',
'*/*\\.tsx?$', '*/*\\.tsx?$',
'./fonts*', './fonts*',