Add support for AnimationDigitalNetwork

This service brought me great pain
This commit is contained in:
AnimeDL 2024-04-12 16:19:23 -07:00
parent e9c040ceb7
commit f5f32fa701
21 changed files with 1273 additions and 21 deletions

50
@types/adnPlayerConfig.d.ts vendored Normal file
View file

@ -0,0 +1,50 @@
export interface ADNPlayerConfig {
player: Player;
}
export interface Player {
image: string;
options: Options;
}
export interface Options {
user: User;
chromecast: Chromecast;
ios: Ios;
video: Video;
dock: any[];
preference: Preference;
}
export interface Chromecast {
appId: string;
refreshTokenUrl: string;
}
export interface Ios {
videoUrl: string;
appUrl: string;
title: string;
}
export interface Preference {
quality: string;
autoplay: boolean;
language: string;
green: boolean;
}
export interface User {
hasAccess: boolean;
profileId: number;
refreshToken: string;
refreshTokenUrl: string;
}
export interface Video {
startDate: null;
currentDate: Date;
available: boolean;
free: boolean;
url: string;
}

46
@types/adnSearch.d.ts vendored Normal file
View file

@ -0,0 +1,46 @@
export interface ADNSearch {
shows: ADNSearchShow[];
total: number;
}
export interface ADNSearchShow {
id: number;
title: string;
type: string;
originalTitle: string;
shortTitle: string;
reference: string;
age: string;
languages: string[];
summary: string;
image: string;
image2x: string;
imageHorizontal: string;
imageHorizontal2x: string;
url: string;
urlPath: string;
episodeCount: number;
genres: string[];
copyright: string;
rating: number;
ratingsCount: number;
commentsCount: number;
qualities: string[];
simulcast: boolean;
free: boolean;
available: boolean;
download: boolean;
basedOn: string;
tagline: null;
firstReleaseYear: string;
productionStudio: string;
countryOfOrigin: string;
productionTeam: ProductionTeam[];
nextVideoReleaseDate: null;
indexable: boolean;
}
export interface ProductionTeam {
role: string;
name: string;
}

51
@types/adnStreams.d.ts vendored Normal file
View file

@ -0,0 +1,51 @@
export interface ADNStreams {
links: Links;
video: Video;
metadata: Metadata;
}
export interface Links {
streaming: Streaming;
subtitles: Subtitles;
history: string;
nextVideoUrl: string;
previousVideoUrl: string;
}
export interface Streaming {
[streams: string]: Streams;
}
export interface Streams {
mobile: string;
sd: string;
hd: string;
fhd: string;
auto: string;
}
export interface Subtitles {
all: string;
}
export interface Metadata {
title: string;
subtitle: string;
summary: null;
rating: number;
}
export interface Video {
guid: string;
id: number;
currentTime: number;
duration: number;
url: string;
image: string;
tcEpisodeStart: string;
tcEpisodeEnd: string;
tcIntroStart: string;
tcIntroEnd: string;
tcEndingStart: string;
tcEndingEnd: string;
}

11
@types/adnSubtitles.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
export interface ADNSubtitles {
[subtitleLang: string]: Subtitle[];
}
export interface Subtitle {
startTime: number;
endTime: number;
positionAlign: string;
lineAlign: string;
text: string;
}

77
@types/adnVideos.d.ts vendored Normal file
View file

@ -0,0 +1,77 @@
export interface ADNVideos {
videos: ADNVideo[];
}
export interface ADNVideo {
id: number;
title: string;
name: string;
number: string;
shortNumber: string;
season: string;
reference: string;
type: string;
order: number;
image: string;
image2x: string;
summary: string;
releaseDate: Date;
duration: number;
url: string;
urlPath: string;
embeddedUrl: string;
languages: string[];
qualities: string[];
rating: number;
ratingsCount: number;
commentsCount: number;
available: boolean;
download: boolean;
free: boolean;
freeWithAds: boolean;
show: Show;
indexable: boolean;
isSelected?: boolean;
}
export interface Show {
id: number;
title: string;
type: string;
originalTitle: string;
shortTitle: string;
reference: string;
age: string;
languages: string[];
summary: string;
image: string;
image2x: string;
imageHorizontal: string;
imageHorizontal2x: string;
url: string;
urlPath: string;
episodeCount: number;
genres: string[];
copyright: string;
rating: number;
ratingsCount: number;
commentsCount: number;
qualities: string[];
simulcast: boolean;
free: boolean;
available: boolean;
download: boolean;
basedOn: string;
tagline: string;
firstReleaseYear: string;
productionStudio: string;
countryOfOrigin: string;
productionTeam: ProductionTeam[];
nextVideoReleaseDate: Date;
indexable: boolean;
}
export interface ProductionTeam {
role: string;
name: string;
}

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'|undefined], 'type': [undefined, 'funi'|'crunchy'|'hidive'|'adn'|undefined],
'setup': ['funi'|'crunchy'|'hidive'|undefined, undefined], 'setup': ['funi'|'crunchy'|'hidive'|'adn'|undefined, undefined],
'openFile': [[FolderTypes, string], undefined], 'openFile': [[FolderTypes, string], undefined],
'openURL': [string, undefined], 'openURL': [string, undefined],
'isSetup': [undefined, boolean], 'isSetup': [undefined, boolean],

820
adn.ts Normal file
View file

@ -0,0 +1,820 @@
// Package Info
import packageJson from './package.json';
// Node
import path from 'path';
import fs from 'fs-extra';
import crypto from 'crypto';
// Plugins
import shlp from 'sei-helper';
import m3u8 from 'm3u8-parsed';
// Modules
import * as fontsData from './modules/module.fontsData';
import * as langsData from './modules/module.langsData';
import * as yamlCfg from './modules/module.cfg-loader';
import * as yargs from './modules/module.app-args';
import * as reqModule from './modules/module.fetch';
import Merger, { Font, MergerInput, SubtitleInput } from './modules/module.merger';
import streamdl from './modules/hls-download';
import { console } from './modules/log';
import { domain } from './modules/module.api-urls';
import { downloaded } from './modules/module.downloadArchive';
import parseSelect from './modules/module.parseSelect';
import parseFileName, { Variable } from './modules/module.filename';
import { AvailableFilenameVars } from './modules/module.args';
// Types
import { ServiceClass } from './@types/serviceClassInterface';
import { AuthData, AuthResponse, SearchData, SearchResponse, SearchResponseItem } from './@types/messageHandler';
import { sxItem } from './crunchy';
import { DownloadedMedia } from './@types/hidiveTypes';
import { ADNSearch, ADNSearchShow } from './@types/adnSearch';
import { ADNVideo, ADNVideos } from './@types/adnVideos';
import { ADNPlayerConfig } from './@types/adnPlayerConfig';
import { ADNStreams } from './@types/adnStreams';
import { ADNSubtitles } from './@types/adnSubtitles';
export default class AnimationDigitalNetwork implements ServiceClass {
public cfg: yamlCfg.ConfigObject;
public locale: string;
private token: Record<string, any>;
private req: reqModule.Req;
private posAlignMap: { [key: string]: number } = {
'start': 1,
'end': 3
};
private lineAlignMap: { [key: string]: number } = {
'middle': 8,
'end': 4
};
private jpnStrings: string[] = [
'vostf',
'vostde'
];
private deuStrings: string[] = [
'vde'
];
private fraStrings: string[] = [
'vf'
];
private deuSubStrings: string[] = [
'vde',
'vostde'
];
private fraSubStrings: string[] = [
'vf',
'vostf'
];
constructor(private debug = false) {
this.cfg = yamlCfg.loadCfg();
this.token = yamlCfg.loadADNToken();
this.req = new reqModule.Req(domain, debug, false, 'adn');
this.locale = 'fr';
}
public async cli() {
console.info(`\n=== Multi Downloader NX ${packageJson.version} ===\n`);
const argv = yargs.appArgv(this.cfg.cli);
this.locale = argv.locale;
if (argv.debug)
this.debug = true;
// load binaries
this.cfg.bin = await yamlCfg.loadBinCfg();
if (argv.allDubs) {
argv.dubLang = langsData.dubLanguageCodes;
}
if (argv.auth) {
//Authenticate
await this.doAuth({
username: argv.username ?? await shlp.question('[Q] LOGIN/EMAIL'),
password: argv.password ?? await shlp.question('[Q] PASSWORD ')
});
} else if (argv.search && argv.search.length > 2) {
//Search
await this.doSearch({ ...argv, search: argv.search as string });
} else if (argv.s && !isNaN(parseInt(argv.s,10)) && parseInt(argv.s,10) > 0) {
const selected = await this.selectShow(parseInt(argv.s), argv.e, argv.but, argv.all);
if (selected.isOk) {
for (const select of selected.value) {
if (!(await this.getEpisode(select, {...argv, skipsubs: false}))) {
console.error(`Unable to download selected episode ${select.shortNumber}`);
return false;
}
}
}
return true;
} else {
console.info('No option selected or invalid value entered. Try --help.');
}
}
private generateRandomString(length: number) {
const characters = '0123456789abcdef';
let result = '';
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
return result;
}
private parseCookies(cookiesString: string | null): Record<string, string> {
const cookies: Record<string, string> = {};
if (cookiesString) {
cookiesString.split(';').forEach(cookie => {
const parts = cookie.split('=');
const name = parts.shift()?.trim();
const value = decodeURIComponent(parts.join('='));
if (name) {
cookies[name] = value;
}
});
}
return cookies;
}
private convertToSSATimestamp(timestamp: number): string {
const seconds = Math.floor(timestamp);
const centiseconds = Math.round((timestamp - seconds) * 100);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
}
public async doSearch(data: SearchData): Promise<SearchResponse> {
const limit = 12;
const offset = data.page ? data.page * limit : 0;
const searchReq = await this.req.getData(`https://gw.api.animationdigitalnetwork.fr/show/catalog?maxAgeCategory=18&offset=${offset}&limit=${limit}&search=${encodeURIComponent(data.search)}`, {
'headers': {
'X-Target-Distribution': this.locale
}
});
if (!searchReq.ok || !searchReq.res) {
console.error('Search FAILED!');
return { isOk: false, reason: new Error('Search failed. No more information provided') };
}
const searchData = await searchReq.res.json() as ADNSearch;
const searchItems: ADNSearchShow[] = [];
console.info('Search Results:');
for (const show of searchData.shows) {
searchItems.push(show);
let fullType: string;
if (show.type == 'EPS') {
fullType = `S.${show.id}`;
} else if (show.type == 'MOV' || show.type == 'OAV') {
fullType = `E.${show.id}`;
} else {
fullType = 'Unknown';
console.warn(`Unknown type ${show.type}, please report this.`);
}
console.log(`[${fullType}] ${show.title}`);
}
return { isOk: true, value: searchItems.flatMap((a): SearchResponseItem => {
return {
id: a.id+'',
image: a.image ?? '/notFound.png',
name: a.title,
rating: a.rating,
desc: a.summary
};
})};
}
public async doAuth(data: AuthData): Promise<AuthResponse> {
const authData = new URLSearchParams({
'username': data.username,
'password': data.password,
'source': 'Web',
'rememberMe': 'true'
}).toString();
const authReqOpts: reqModule.Params = {
method: 'POST',
body: authData
};
const authReq = await this.req.getData('https://gw.api.animationdigitalnetwork.fr/authentication/login', authReqOpts);
if(!authReq.ok || !authReq.res){
console.error('Authentication failed!');
return { isOk: false, reason: new Error('Authentication failed') };
}
this.token = await authReq.res.json();
const cookies = this.parseCookies(authReq.res.headers.get('Set-Cookie'));
this.token.refreshToken = cookies.adnrt;
yamlCfg.saveADNToken(this.token);
console.info('Authentication Success');
return { isOk: true, value: undefined };
}
public async refreshToken() {
const authReq = await this.req.getData('https://gw.api.animationdigitalnetwork.fr/authentication/refresh', {
method: 'POST',
headers: {
Authorization: `Bearer ${this.token.accessToken}`,
'Cookie': `adnrt=${this.token.refreshToken}`,
'X-Access-Token': this.token.accessToken
},
body: '{}'
});
if(!authReq.ok || !authReq.res){
console.error('Token refresh failed!');
return { isOk: false, reason: new Error('Token refresh failed') };
}
this.token = await authReq.res.json();
const cookies = this.parseCookies(authReq.res.headers.get('Set-Cookie'));
//this.token.refreshtoken = this.token.refreshToken;
this.token.refreshToken = cookies.adnrt;
yamlCfg.saveADNToken(this.token);
return { isOk: true, value: undefined };
}
public async getShow(id: number) {
const getShowData = await this.req.getData(`https://gw.api.animationdigitalnetwork.fr/video/show/${id}?maxAgeCategory=18&limit=-1&order=asc`, {
'headers': {
'X-Target-Distribution': this.locale
}
});
if (!getShowData.ok || !getShowData.res) {
console.error('Failed to get Series Data');
return { isOk: false };
}
const showData = await getShowData.res.json() as ADNVideos;
return { isOk: true, value: showData };
}
public async listShow(id: number) {
const show = await this.getShow(id);
if (!show.isOk || !show.value) {
console.error('Failed to list show data: Failed to get show');
return { isOk: false };
}
const showData = show.value.videos[0].show;
console.info(`[S.${showData.id}] ${showData.title}`);
const specials: ADNVideo[] = [];
let episodeIndex = 0, specialIndex = 0;
for (const episode of show.value.videos) {
const episodeNumber = parseInt(episode.shortNumber);
if (!episodeNumber) {
specialIndex++;
const special = show.value.videos.splice(episodeIndex, 1);
special[0].shortNumber = 'S'+specialIndex;
specials.push(...special);
episodeIndex--;
} else {
console.info(` [E${episode.shortNumber}] ${episode.number} - ${episode.name}`);
}
episodeIndex++;
}
for (const special of specials) {
console.info(` [${special.shortNumber}] ${special.number} - ${special.name}`);
}
show.value.videos.push(...specials);
return { isOk: true, value: show.value };
}
public async selectShow(id: number, e: string | undefined, but: boolean, all: boolean) {
const getShowData = await this.listShow(id);
if (!getShowData.isOk || !getShowData.value) {
return { isOk: false, value: [] };
}
console.info('');
console.info('-'.repeat(30));
console.info('');
const showData = getShowData.value;
const doEpsFilter = parseSelect(e as string);
const selEpsArr: ADNVideo[] = [];
for (const episode of showData.videos) {
if (
all ||
but && !doEpsFilter.isSelected([episode.shortNumber, episode.id+'']) ||
!but && doEpsFilter.isSelected([episode.shortNumber, episode.id+''])
) {
selEpsArr.push({ isSelected: true, ...episode });
console.info('%s[%s] %s',
'✓ ',
episode.shortNumber,
episode.name,
);
}
}
return { isOk: true, value: selEpsArr };
}
public async muxStreams(data: DownloadedMedia[], options: yargs.ArgvType) {
this.cfg.bin = await yamlCfg.loadBinCfg();
let hasAudioStreams = false;
if (options.novids || data.filter(a => a.type === 'Video').length === 0)
return console.info('Skip muxing since no vids are downloaded');
if (data.some(a => a.type === 'Audio')) {
hasAudioStreams = true;
}
const merger = new Merger({
onlyVid: hasAudioStreams ? data.filter(a => a.type === 'Video').map((a) : MergerInput => {
if (a.type === 'Subtitle')
throw new Error('Never');
return {
lang: a.lang,
path: a.path,
};
}) : [],
skipSubMux: options.skipSubMux,
inverseTrackOrder: false,
keepAllVideos: options.keepAllVideos,
onlyAudio: hasAudioStreams ? data.filter(a => a.type === 'Audio').map((a) : MergerInput => {
if (a.type === 'Subtitle')
throw new Error('Never');
return {
lang: a.lang,
path: a.path,
};
}) : [],
output: `${options.output}.${options.mp4 ? 'mp4' : 'mkv'}`,
subtitles: data.filter(a => a.type === 'Subtitle').map((a) : SubtitleInput => {
if (a.type === 'Video')
throw new Error('Never');
if (a.type === 'Audio')
throw new Error('Never');
return {
file: a.path,
language: a.language,
closedCaption: a.cc
};
}),
simul: data.filter(a => a.type === 'Video').map((a) : boolean => {
if (a.type === 'Subtitle')
throw new Error('Never');
return !a.uncut as boolean;
})[0],
fonts: Merger.makeFontsList(this.cfg.dir.fonts, data.filter(a => a.type === 'Subtitle') as sxItem[]),
videoAndAudio: hasAudioStreams ? [] : data.filter(a => a.type === 'Video').map((a) : MergerInput => {
if (a.type === 'Subtitle')
throw new Error('Never');
return {
lang: a.lang,
path: a.path,
};
}),
videoTitle: options.videoTitle,
options: {
ffmpeg: options.ffmpegOptions,
mkvmerge: options.mkvmergeOptions
},
defaults: {
audio: options.defaultAudio,
sub: options.defaultSub
},
ccTag: options.ccTag
});
const bin = Merger.checkMerger(this.cfg.bin, options.mp4, options.forceMuxer);
// collect fonts info
// mergers
let isMuxed = false;
if (options.syncTiming) {
await merger.createDelays();
}
if (bin.MKVmerge) {
await merger.merge('mkvmerge', bin.MKVmerge);
isMuxed = true;
} else if (bin.FFmpeg) {
await merger.merge('ffmpeg', bin.FFmpeg);
isMuxed = true;
} else{
console.info('\nDone!\n');
return;
}
if (isMuxed && !options.nocleanup)
merger.cleanUp();
}
public async getEpisode(data: ADNVideo, options: yargs.ArgvType) {
//TODO: Move all the requests for getting the m3u8 here
const res = await this.downloadEpisode(data, options);
if (res === undefined || res.error) {
console.error('Failed to download media list');
return { isOk: false, reason: new Error('Failed to download media list') };
} else {
if (!options.skipmux) {
await this.muxStreams(res.data, { ...options, output: res.fileName });
} else {
console.info('Skipping mux');
}
downloaded({
service: 'adn',
type: 's'
}, data.id+'', [data.shortNumber]);
return { isOk: res, value: undefined };
}
}
public async downloadEpisode(data: ADNVideo, options: yargs.ArgvType) {
if(!this.token.accessToken){
console.error('Authentication required!');
return;
}
if (!this.cfg.bin.ffmpeg)
this.cfg.bin = await yamlCfg.loadBinCfg();
let mediaName = '...';
let fileName;
const variables: Variable[] = [];
if(data.show.title && data.shortNumber && data.title){
mediaName = `${data.show.shortTitle} - ${data.shortNumber} - ${data.title}`;
}
const files: DownloadedMedia[] = [];
let dlFailed = false;
let dlVideoOnce = false; // Variable to save if best selected video quality was downloaded
const refreshToken = await this.refreshToken();
if (!refreshToken.isOk) {
console.error('Failed to refresh token');
return undefined;
}
const configReq = await this.req.getData(`https://gw.api.animationdigitalnetwork.fr/player/video/${data.id}/configuration`, {
headers: {
Authorization: `Bearer ${this.token.accessToken}`
}
});
if(!configReq.ok || !configReq.res){
console.error('Player Config Request failed!');
return undefined;
}
const configuration = await configReq.res.json() as ADNPlayerConfig;
if (!configuration.player.options.user.hasAccess) {
console.error('You don\'t have access to this video!');
return undefined;
}
const tokenReq = await this.req.getData(configuration.player.options.user.refreshTokenUrl || 'https://gw.api.animationdigitalnetwork.fr/player/refresh/token', {
method: 'POST',
headers: {
'X-Player-Refresh-Token': `${configuration.player.options.user.refreshToken}`
}
});
if(!tokenReq.ok || !tokenReq.res){
console.error('Player Token Request failed!');
return undefined;
}
const token = await tokenReq.res.json() as {
refreshToken: string,
accessToken: string,
token: string
};
const linksUrl = configuration.player.options.video.url || `https://gw.api.animationdigitalnetwork.fr/player/video/${data.id}/link`;
const key = this.generateRandomString(16);
const decryptionKey = key + '7fac1178830cfe0c';
const authorization = crypto.publicEncrypt({
'key': '-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCbQrCJBRmaXM4gJidDmcpWDssg\nnumHinCLHAgS4buMtdH7dEGGEUfBofLzoEdt1jqcrCDT6YNhM0aFCqbLOPFtx9cg\n/X2G/G5bPVu8cuFM0L+ehp8s6izK1kjx3OOPH/kWzvstM5tkqgJkNyNEvHdeJl6\nKhS+IFEqwvZqgbBpKuwIDAQAB\n-----END PUBLIC KEY-----',
padding: crypto.constants.RSA_PKCS1_PADDING
}, Buffer.from(JSON.stringify({
k: key,
t: token.token
}), 'utf-8')).toString('base64');
//TODO: Add chapter support
const streamsRequest = await this.req.getData(linksUrl+'?freeWithAds=true&adaptive=true&withMetadata=true&source=Web', {
'headers': {
'X-Player-Token': authorization
}
});
if(!streamsRequest.ok || !streamsRequest.res){
if (streamsRequest.error?.res.status == 403 || streamsRequest.res?.status == 403) {
console.error('Georestricted!');
} else {
console.error('Streams request failed!');
}
return undefined;
}
const streams = await streamsRequest.res.json() as ADNStreams;
for (const streamName in streams.links.streaming) {
let audDub: langsData.LanguageItem;
if (this.jpnStrings.includes(streamName)) {
audDub = langsData.languages.find(a=>a.code == 'jpn') as langsData.LanguageItem;
} else if (this.deuStrings.includes(streamName)) {
audDub = langsData.languages.find(a=>a.code == 'deu') as langsData.LanguageItem;
} else if (this.fraStrings.includes(streamName)) {
audDub = langsData.languages.find(a=>a.code == 'fra') as langsData.LanguageItem;
} else {
console.error(`Language ${streamName} not recognized, please report this.`);
continue;
}
if (!options.dubLang.includes(audDub.code)) {
continue;
}
console.info(`Requesting: [${data.id}] ${mediaName} (${audDub.name})`);
variables.push(...([
['title', data.title, true],
['episode', data.shortNumber, false],
['service', 'ADN', false],
['seriesTitle', data.show.shortTitle, true],
['showTitle', data.show.title, true],
['season', data.season, false]
] as [AvailableFilenameVars, string|number, boolean][]).map((a): Variable => {
return {
name: a[0],
replaceWith: a[1],
type: typeof a[1],
sanitize: a[2]
} as Variable;
}));
console.info('Playlists URL: %s', streams.links.streaming[streamName].auto);
let tsFile = undefined;
if (!dlFailed && !options.novids) {
const streamPlaylistsLocationReq = await this.req.getData(streams.links.streaming[streamName].auto);
if (!streamPlaylistsLocationReq.ok || !streamPlaylistsLocationReq.res) {
console.error('CAN\'T FETCH VIDEO PLAYLIST LOCATION!');
return undefined;
}
const streamPlaylistLocation = await streamPlaylistsLocationReq.res.json() as {'location': string};
const streamPlaylistsReq = await this.req.getData(streamPlaylistLocation.location);
if (!streamPlaylistsReq.ok || !streamPlaylistsReq.res) {
console.error('CAN\'T FETCH VIDEO PLAYLISTS!');
dlFailed = true;
} else {
const streamPlaylistBody = await streamPlaylistsReq.res.text();
if (!options.novids) {
const streamPlaylists = m3u8(streamPlaylistBody);
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 maximum ${plQuality.length}.\n[WARN] Therefor the maximum will be capped at ${plQuality.length}.`);
quality = plQuality.length;
}
// When best selected video quality is already downloaded
if(dlVideoOnce && options.dlVideoOnce) {
// Select the lowest resolution with the same codecs
while(quality !=1 && plQuality[quality - 1].CODECS == plQuality[quality - 2].CODECS) {
quality--;
}
}
const selPlUrl = plSelectedList[plQuality.map(a => a.dim)[quality - 1]] ? plSelectedList[plQuality.map(a => a.dim)[quality - 1]] : '';
console.info(`Servers available:\n\t${plServerList.join('\n\t')}`);
console.info(`Available qualities:\n\t${plQuality.map((a, ind) => `[${ind+1}] ${a.str}`).join('\n\t')}`);
if(selPlUrl != ''){
variables.push({
name: 'height',
type: 'number',
replaceWith: quality === 0 ? plQuality[plQuality.length - 1].RESOLUTION.height as number : plQuality[quality - 1].RESOLUTION.height
}, {
name: 'width',
type: 'number',
replaceWith: quality === 0 ? plQuality[plQuality.length - 1].RESOLUTION.width as number : plQuality[quality - 1].RESOLUTION.width
});
console.info(`Selected quality: ${Object.keys(plSelectedList).find(a => plSelectedList[a] === selPlUrl)} @ ${plSelectedServer}`);
console.info('Stream URL:', selPlUrl);
// TODO check filename
fileName = parseFileName(options.fileName, variables, options.numbers, options.override).join(path.sep);
const outFile = parseFileName(options.fileName + '.' + audDub.name, variables, options.numbers, options.override).join(path.sep);
console.info(`Output filename: ${outFile}`);
const chunkPage = await this.req.getData(selPlUrl);
if(!chunkPage.ok || !chunkPage.res){
console.error('CAN\'T FETCH VIDEO PLAYLIST!');
dlFailed = true;
} else {
const chunkPageBody = await chunkPage.res.text();
const chunkPlaylist = m3u8(chunkPageBody);
const totalParts = chunkPlaylist.segments.length;
const mathParts = Math.ceil(totalParts / options.partsize);
const mathMsg = `(${mathParts}*${options.partsize})`;
console.info('Total parts in stream:', totalParts, mathMsg);
tsFile = path.isAbsolute(outFile as string) ? outFile : path.join(this.cfg.dir.content, outFile);
const 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: selPlUrl.replace('playlist.m3u8',''),
threads: options.partsize,
fsRetryTime: options.fsRetryTime * 1000,
override: options.force,
callback: options.callbackMaker ? options.callbackMaker({
fileName: `${path.isAbsolute(outFile) ? outFile.slice(this.cfg.dir.content.length) : outFile}`,
image: data.image,
parent: {
title: data.show.title
},
title: data.title,
language: audDub
}) : undefined
}).download();
if (!dlStreamByPl.ok) {
console.error(`DL Stats: ${JSON.stringify(dlStreamByPl.parts)}\n`);
dlFailed = true;
}
files.push({
type: 'Video',
path: `${tsFile}.ts`,
lang: audDub
});
dlVideoOnce = true;
}
} else{
console.error('Quality not selected!\n');
dlFailed = true;
}
} else if (options.novids) {
fileName = parseFileName(options.fileName, variables, options.numbers, options.override).join(path.sep);
console.info('Downloading skipped!');
}
}
} else if (options.novids && options.noaudio) {
fileName = parseFileName(options.fileName, variables, options.numbers, options.override).join(path.sep);
}
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 (Object.keys(streams.links.subtitles).length !== 0) {
const subtitlesUrlReq = await this.req.getData(streams.links.subtitles.all);
if(!subtitlesUrlReq.ok || !subtitlesUrlReq.res){
console.error('Subtitle location request failed!');
return undefined;
}
const subtitleUrl = await subtitlesUrlReq.res.json() as {'location': string};
const encryptedSubtitlesReq = await this.req.getData(subtitleUrl.location);
if(!encryptedSubtitlesReq.ok || !encryptedSubtitlesReq.res){
console.error('Subtitle request failed!');
return undefined;
}
const encryptedSubtitles = await encryptedSubtitlesReq.res.text();
const iv = Buffer.from(encryptedSubtitles.slice(0, 24), 'base64');
const derivedKey = Buffer.from(decryptionKey, 'hex');
const encryptedData = Buffer.from(encryptedSubtitles.slice(24), 'base64');
const decipher = crypto.createDecipheriv('aes-128-cbc', derivedKey, iv);
const decryptedData = Buffer.concat([decipher.update(encryptedData), decipher.final()]).toString('utf8');
let subIndex = 0;
const subtitles = JSON.parse(decryptedData) as ADNSubtitles;
if (Object.keys(subtitles).length === 0) {
console.warn('No subtitles found.');
}
for (const subName in subtitles) {
let subLang: langsData.LanguageItem;
if (this.deuSubStrings.includes(subName)) {
subLang = langsData.languages.find(a=>a.code == 'deu') as langsData.LanguageItem;
} else if (this.fraSubStrings.includes(subName)) {
subLang = langsData.languages.find(a=>a.code == 'fra') as langsData.LanguageItem;
} else {
console.error(`Language ${subName} not recognized, please report this.`);
continue;
}
if (!options.dlsubs.includes(subLang.locale) && !options.dlsubs.includes('all')) {
continue;
}
const sxData: Partial<sxItem> = {};
sxData.file = langsData.subsFile(fileName as string, subIndex+'', subLang, false, options.ccTag);
sxData.path = path.join(this.cfg.dir.content, sxData.file);
const split = sxData.path.split(path.sep).slice(0, -1);
split.forEach((val, ind, arr) => {
const isAbsolut = path.isAbsolute(sxData.path 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));
});
sxData.language = subLang;
if(options.dlsubs.includes('all') || options.dlsubs.includes(subLang.locale)) {
let subBody = '[Script Info]'
+ '\nScriptType:V4.00'
+ '\nWrapStyle: 0'
+ '\nPlayResX: 1280'
+ '\nPlayResY: 720'
+ '\nScaledBorderAndShadow: yes'
+ '\n[V4 Styles]'
+ '\nFormat: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,TertiaryColour,BackColour,Bold,Italic,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,AlphaLevel,Encoding'
+ `\nStyle: Default,${options.fontName ?? 'Arial'},${options.fontSize ?? 50},16777215,16777215,16777215,0,-1,0,1,1,0,2,20,20,20,0,0`
+ '\n[Events]'
+ '\nFormat: Marked,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text';
for (const sub of subtitles[subName]) {
const [start, end, text, lineAlign, positionAlign] =
[sub.startTime, sub.endTime, sub.text, sub.lineAlign, sub.positionAlign];
const alignment = (this.posAlignMap[positionAlign] || 2) + (this.lineAlignMap[lineAlign] || 0);
subBody += `\nDialogue: Marked=0,${this.convertToSSATimestamp(start)},${this.convertToSSATimestamp(end)},Default,,0,0,0,,${(alignment !== 2 ? `{\\a${alignment}}` : '')}${text.replace('\n', '\\N').replace('<i>', '{\\i1}').replace('</i>', '{\\i0}')}`;
}
sxData.title = `${subLang.language}`;
sxData.fonts = fontsData.assFonts(subBody) as Font[];
fs.writeFileSync(sxData.path, subBody);
console.info(`Subtitle converted: ${sxData.file}`);
files.push({
type: 'Subtitle',
...sxData as sxItem,
cc: false
});
}
subIndex++;
}
} else {
console.warn('Couldn\'t find subtitles.');
}
} else{
console.info('Subtitles downloading skipped!');
}
return {
error: dlFailed,
data: files,
fileName: fileName ? (path.isAbsolute(fileName) ? fileName : path.join(this.cfg.dir.content, fileName)) || './unknown' : './unknown'
};
}
public sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
}

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*, and *Hidive*. This downloader can download anime from different sites. Currently supported are *Funimation*, *Crunchyroll*, *AnimationDigitalNetwork*, and *Hidive*.
## Legal Warning ## Legal Warning
This application is not endorsed by or affiliated with *Funimation*, *Crunchyroll*, or *Hidive*. 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 *Funimation*, *Crunchyroll*, *AnimationDigitalNetwork*, or *Hidive*. 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

View file

@ -25,6 +25,8 @@ const MenuBar: React.FC = () => {
return 'Crunchyroll'; return 'Crunchyroll';
case 'funi': case 'funi':
return 'Funimation'; return 'Funimation';
case 'adn':
return 'AnimationDigitalNetwork';
case 'hidive': case 'hidive':
return 'Hidive'; return 'Hidive';
} }

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'; type Services = 'adn'|'funi'|'crunchy'|'hidive';
export const serviceContext = React.createContext<Services|undefined>(undefined); export const serviceContext = React.createContext<Services|undefined>(undefined);
@ -24,6 +24,7 @@ const ServiceProvider: FCWithChildren = ({ children }) => {
<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('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('adn')} startIcon={<Avatar src={'https://animationdigitalnetwork.de/favicon.ico'} />}>AnimationDigitalNetwork</Button>
</Box> </Box>
</Box> </Box>
: <serviceContext.Provider value={service}> : <serviceContext.Provider value={service}>

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'|undefined, service: 'crunchy'|'funi'|'hidive'|'adn'|undefined,
version: string, version: string,
} }

View file

@ -6,6 +6,7 @@ import { setState, getState, writeYamlCfgFile } from '../../modules/module.cfg-l
import CrunchyHandler from './services/crunchyroll'; import CrunchyHandler from './services/crunchyroll';
import FunimationHandler from './services/funimation'; import FunimationHandler from './services/funimation';
import HidiveHandler from './services/hidive'; import HidiveHandler from './services/hidive';
import ADNHandler from './services/adn';
import WebSocketHandler from './websocket'; import WebSocketHandler from './websocket';
import packageJson from '../../package.json'; import packageJson from '../../package.json';
@ -37,6 +38,8 @@ export default class ServiceHandler {
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);
} else if (data === 'adn') {
this.service = new ADNHandler(this.ws);
} }
}); });
@ -55,7 +58,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')); this.ws.events.on('type', async (_, respond) => respond(this.service === undefined ? undefined : this.service.name as 'hidive'|'crunchy'|'funi'|'adn'));
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') });

138
gui/server/services/adn.ts Normal file
View file

@ -0,0 +1,138 @@
import { AuthData, CheckTokenResponse, DownloadData, EpisodeListResponse, MessageHandler, ResolveItemsData, SearchData, SearchResponse } from '../../../@types/messageHandler';
import AnimationDigitalNetwork from '../../../adn';
import { getDefault } from '../../../modules/module.args';
import { languages } 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 ADNHandler extends Base implements MessageHandler {
private adn: AnimationDigitalNetwork;
public name = 'adn';
constructor(ws: WebSocketHandler) {
super(ws);
this.adn = new AnimationDigitalNetwork();
this.initState();
this.getDefaults();
}
public getDefaults() {
const _default = yargs.appArgv(this.adn.cfg.cli, true);
this.adn.locale = _default.locale;
}
public async auth(data: AuthData) {
return this.adn.doAuth(data);
}
public async checkToken(): Promise<CheckTokenResponse> {
//TODO: implement proper method to check token
return { isOk: true, value: undefined };
}
public async search(data: SearchData): Promise<SearchResponse> {
console.debug(`Got search options: ${JSON.stringify(data)}`);
const search = await this.adn.doSearch(data);
if (!search.isOk) {
return search;
}
return { isOk: true, value: search.value };
}
public async handleDefault(name: string) {
return getDefault(name, this.adn.cfg.cli);
}
public async availableDubCodes(): Promise<string[]> {
const dubLanguageCodesArray: string[] = [];
for(const language of languages){
if (language.adn_locale)
dubLanguageCodesArray.push(language.code);
}
return [...new Set(dubLanguageCodesArray)];
}
public async availableSubCodes(): Promise<string[]> {
const subLanguageCodesArray: string[] = [];
for(const language of languages){
if (language.adn_locale)
subLanguageCodesArray.push(language.locale);
}
return ['all', 'none', ...new Set(subLanguageCodesArray)];
}
public async resolveItems(data: ResolveItemsData): Promise<boolean> {
const parse = parseInt(data.id);
if (isNaN(parse) || parse <= 0)
return false;
console.debug(`Got resolve options: ${JSON.stringify(data)}`);
const res = await this.adn.selectShow(parseInt(data.id), data.e, data.but, data.all);
if (!res.isOk || !res.value)
return res.isOk;
this.addToQueue(res.value.map(a => {
return {
...data,
ids: [a.id],
title: a.title,
parent: {
title: a.show.shortTitle,
season: a.season
},
e: a.shortNumber,
image: a.image,
episode: a.shortNumber
};
}));
return true;
}
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.adn.listShow(parse);
if (!request.isOk || !request.value)
return {isOk: false, reason: new Error('Unknown upstream error, check for additional logs')};
return { isOk: true, value: request.value.videos.map(function(item) {
return {
e: item.shortNumber,
lang: [],
name: item.title,
season: item.season,
seasonTitle: item.show.title,
episode: item.shortNumber,
id: item.id+'',
img: item.image,
description: item.summary,
time: item.duration+''
};
})};
}
public async downloadItem(data: DownloadData) {
this.setDownloading(true);
console.debug(`Got download options: ${JSON.stringify(data)}`);
const _default = yargs.appArgv(this.adn.cfg.cli, true);
const res = await this.adn.selectShow(parseInt(data.id), data.e, false, false);
if (res.isOk) {
for (const select of res.value) {
if (!(await this.adn.getEpisode(select, {..._default, skipsubs: false, callbackMaker: this.makeProgressHandler.bind(this), q: data.q, fileName: data.fileName, dlsubs: data.dlsubs, dlVideoOnce: data.dlVideoOnce, force: 'y',
novids: data.novids, hslang: data.hslang || 'none' }))) {
const er = new Error(`Unable to download episode ${data.e} from ${data.id}`);
er.name = 'Download error';
this.alertError(er);
}
}
} else {
this.alertError(new Error('Failed to download episode, check for additional logs.'));
}
this.sendMessage({ name: 'finish', data: undefined });
this.setDownloading(false);
this.onFinish();
}
}
export default ADNHandler;

View file

@ -66,6 +66,9 @@ import update from './modules/module.updater';
case 'hidive': case 'hidive':
service = new (await import('./hidive')).default; service = new (await import('./hidive')).default;
break; break;
case 'adn':
service = new (await import('./adn')).default;
break;
default: default:
service = new (await import(`./${argv.service}`)).default; service = new (await import(`./${argv.service}`)).default;
break; break;
@ -84,6 +87,9 @@ import update from './modules/module.updater';
case 'hidive': case 'hidive':
service = new (await import('./hidive')).default; service = new (await import('./hidive')).default;
break; break;
case 'adn':
service = new (await import('./adn')).default;
break;
default: default:
service = new (await import(`./${argv.service}`)).default; service = new (await import(`./${argv.service}`)).default;
break; break;

View file

@ -3,7 +3,7 @@ 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'|'all'>) => { const transformService = (str: Array<'funi'|'crunchy'|'hidive'|'adn'|'all'>) => {
const services: string[] = []; const services: string[] = [];
str.forEach(function(part) { str.forEach(function(part) {
switch(part) { switch(part) {
@ -16,6 +16,9 @@ const transformService = (str: Array<'funi'|'crunchy'|'hidive'|'all'>) => {
case 'hidive': case 'hidive':
services.push('Hidive'); services.push('Hidive');
break; break;
case 'adn':
services.push('AnimationDigitalNetwork');
break;
case 'all': case 'all':
services.push('All'); services.push('All');
break; break;
@ -30,7 +33,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*, *Hidive*, or *Crunchyroll*. This application is not endorsed by or affiliated with *Funimation*, *Hidive*, *AnimationDigitalNetwork*, or *Crunchyroll*.
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

@ -1,6 +1,8 @@
import yargs, { Choices } from 'yargs'; import yargs, { Choices } from 'yargs';
import { args, AvailableMuxer, groups } from './module.args'; import { args, AvailableMuxer, groups } from './module.args';
import { LanguageItem } from './module.langsData'; import { LanguageItem } from './module.langsData';
import { HLSCallback } from './hls-download';
import { DownloadInfo } from '../@types/messageHandler';
let argvC: { let argvC: {
[x: string]: unknown; [x: string]: unknown;
@ -61,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'; service: 'funi' | 'crunchy' | 'hidive' | 'adn';
update: boolean; update: boolean;
fontName: string | undefined; fontName: string | undefined;
_: (string | number)[]; _: (string | number)[];
@ -74,6 +76,7 @@ let argvC: {
originalFontSize: boolean; originalFontSize: boolean;
keepAllVideos: boolean; keepAllVideos: boolean;
syncTiming: boolean; syncTiming: boolean;
callbackMaker?: (data: DownloadInfo) => HLSCallback;
}; };
export type ArgvType = typeof argvC; export type ArgvType = typeof argvC;

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'|'all'>, service: Array<'funi'|'crunchy'|'hidive'|'adn'|'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
@ -112,7 +112,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
default: 'en-US' default: 'en-US'
}, },
type: 'string', type: 'string',
service: ['crunchy'], service: ['crunchy', 'adn'],
usage: '${locale}' usage: '${locale}'
}, },
{ {
@ -572,7 +572,7 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
group: 'util', group: 'util',
service: ['all'], service: ['all'],
type: 'string', type: 'string',
choices: ['funi', 'crunchy', 'hidive'], choices: ['funi', 'crunchy', 'hidive', 'adn'],
usage: '${service}', usage: '${service}',
default: { default: {
default: '' default: ''
@ -593,7 +593,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: ['funi', 'hidive', 'adn'],
type: 'string', type: 'string',
usage: '${fontName}', usage: '${fontName}',
}, },

View file

@ -20,12 +20,14 @@ const hdPflCfgFile = path.join(workingDir, 'config', 'hd_profile');
const sessCfgFile = { const sessCfgFile = {
funi: path.join(workingDir, 'config', 'funi_sess'), funi: path.join(workingDir, 'config', 'funi_sess'),
cr: path.join(workingDir, 'config', 'cr_sess'), cr: path.join(workingDir, 'config', 'cr_sess'),
adn: path.join(workingDir, 'config', 'adn_sess'),
hd: path.join(workingDir, 'config', 'hd_sess') hd: path.join(workingDir, 'config', 'hd_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'), funi: path.join(workingDir, 'config', 'funi_token'),
cr: path.join(workingDir, 'config', 'cr_token'), cr: path.join(workingDir, 'config', 'cr_token'),
adn: path.join(workingDir, 'config', 'adn_token'),
hd: path.join(workingDir, 'config', 'hd_token'), hd: path.join(workingDir, 'config', 'hd_token'),
hdNew: path.join(workingDir, 'config', 'hd_new_token') hdNew: path.join(workingDir, 'config', 'hd_new_token')
}; };
@ -216,6 +218,24 @@ const saveCRToken = (data: Record<string, unknown>) => {
} }
}; };
const loadADNToken = () => {
let token = loadYamlCfgFile(tokenFile.adn, true);
if(typeof token !== 'object' || token === null || Array.isArray(token)){
token = {};
}
return token;
};
const saveADNToken = (data: Record<string, unknown>) => {
const cfgFolder = path.dirname(tokenFile.adn);
try{
fs.ensureDirSync(cfgFolder);
fs.writeFileSync(`${tokenFile.adn}.yml`, yaml.stringify(data));
}
catch(e){
console.error('Can\'t save token file to disk!');
}
};
const loadHDSession = () => { const loadHDSession = () => {
let session = loadYamlCfgFile(sessCfgFile.hd, true); let session = loadYamlCfgFile(sessCfgFile.hd, true);
@ -379,6 +399,8 @@ export {
loadCRSession, loadCRSession,
saveCRToken, saveCRToken,
loadCRToken, loadCRToken,
saveADNToken,
loadADNToken,
saveHDSession, saveHDSession,
loadHDSession, loadHDSession,
saveHDToken, saveHDToken,

View file

@ -17,6 +17,9 @@ export type DataType = {
hidive: { hidive: {
s: ItemType s: ItemType
}, },
adn: {
s: ItemType
},
crunchy: { crunchy: {
srz: ItemType, srz: ItemType,
s: ItemType s: ItemType
@ -32,6 +35,9 @@ const addToArchive = (kind: {
} | { } | {
service: 'hidive', service: 'hidive',
type: 's' type: 's'
} | {
service: 'adn',
type: 's'
}, ID: string) => { }, ID: string) => {
const data = loadData(); const data = loadData();
@ -65,6 +71,15 @@ const addToArchive = (kind: {
already: [] as string[] already: [] as string[]
} : []), } : []),
}; };
} else if (kind.service === 'adn') {
data['adn'] = {
s: [
{
id: ID,
already: []
}
]
};
} else { } else {
data['hidive'] = { data['hidive'] = {
s: [ s: [
@ -88,6 +103,9 @@ const downloaded = (kind: {
} | { } | {
service: 'hidive', service: 'hidive',
type: 's' type: 's'
} | {
service: 'adn',
type: 's'
}, ID: string, episode: string[]) => { }, ID: string, episode: string[]) => {
let data = loadData(); let data = loadData();
if (!Object.prototype.hasOwnProperty.call(data, kind.service) || !Object.prototype.hasOwnProperty.call(data[kind.service], kind.type) if (!Object.prototype.hasOwnProperty.call(data, kind.service) || !Object.prototype.hasOwnProperty.call(data[kind.service], kind.type)
@ -105,7 +123,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') : Partial<ArgvType>[] => { const makeCommand = (service: 'funi'|'crunchy'|'hidive'|'adn') : 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

@ -13,7 +13,7 @@ export type Params = {
// req // req
export class Req { export class Req {
private sessCfg: string; private sessCfg: string;
private service: 'cr'|'funi'|'hd'; private service: 'cr'|'funi'|'hd'|'adn';
private session: Record<string, { private session: Record<string, {
value: string; value: string;
expires: Date; expires: Date;
@ -25,7 +25,7 @@ export 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') { constructor(private domain: Record<string, unknown>, private debug: boolean, private nosess = false, private type: 'cr'|'funi'|'hd'|'adn') {
this.sessCfg = yamlCfg.sessCfgFile[type]; this.sessCfg = yamlCfg.sessCfgFile[type];
this.service = type; this.service = type;
} }

View file

@ -3,6 +3,7 @@
export type LanguageItem = { export type LanguageItem = {
cr_locale?: string, cr_locale?: string,
hd_locale?: string, hd_locale?: string,
adn_locale?: string,
new_hd_locale?: string, new_hd_locale?: string,
locale: string, locale: string,
code: string, code: string,
@ -21,8 +22,8 @@ const languages: LanguageItem[] = [
{ 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', 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', new_hd_locale: 'pt-BR', hd_locale: 'Portuguese', funi_name: 'Portuguese (Brazil)', funi_locale: 'ptBR', locale: 'pt-BR', code: 'por', name: 'Portuguese', language: 'Brazilian Portuguese' },
{ cr_locale: 'pt-PT', locale: 'pt-PT', code: 'por', name: 'Portuguese (Portugal)', language: 'Portugues (Portugal)' }, { cr_locale: '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', adn_locale: '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', adn_locale: 'de', hd_locale: 'German', locale: 'de', code: 'deu', name: 'German' },
{ cr_locale: 'ar-ME', locale: 'ar', code: 'ara-ME', name: 'Arabic' }, { cr_locale: 'ar-ME', locale: 'ar', code: 'ara-ME', name: 'Arabic' },
{ cr_locale: 'ar-SA', hd_locale: 'Arabic', locale: 'ar', code: 'ara', name: 'Arabic (Saudi Arabia)' }, { cr_locale: 'ar-SA', hd_locale: 'Arabic', locale: 'ar', code: 'ara', name: 'Arabic (Saudi Arabia)' },
{ cr_locale: 'it-IT', hd_locale: 'Italian', locale: 'it', code: 'ita', name: 'Italian' }, { cr_locale: 'it-IT', hd_locale: 'Italian', locale: 'it', code: 'ita', name: 'Italian' },
@ -41,7 +42,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', hd_locale: 'Japanese', funi_locale: 'jaJP', locale: 'ja', code: 'jpn', name: 'Japanese' }, { cr_locale: 'ja-JP', adn_locale: 'ja', hd_locale: 'Japanese', funi_locale: 'jaJP', locale: 'ja', code: 'jpn', name: 'Japanese' },
]; ];
// add en language names // add en language names
@ -69,7 +70,7 @@ const subtitleLanguagesFilter = (() => {
})(); })();
const searchLocales = (() => { const searchLocales = (() => {
return ['', ...new Set(languages.map(l => { return l.cr_locale; }).slice(0, -1))]; return ['', ...new Set(languages.map(l => { return l.cr_locale; }).slice(0, -1)), ...new Set(languages.map(l => { return l.adn_locale; }).slice(0, -1))];
})(); })();
// convert // convert