diff --git a/@types/adnPlayerConfig.d.ts b/@types/adnPlayerConfig.d.ts new file mode 100644 index 0000000..82a81ec --- /dev/null +++ b/@types/adnPlayerConfig.d.ts @@ -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; +} diff --git a/@types/adnSearch.d.ts b/@types/adnSearch.d.ts new file mode 100644 index 0000000..a8f47b7 --- /dev/null +++ b/@types/adnSearch.d.ts @@ -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; +} diff --git a/@types/adnStreams.d.ts b/@types/adnStreams.d.ts new file mode 100644 index 0000000..64cbcf7 --- /dev/null +++ b/@types/adnStreams.d.ts @@ -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; +} diff --git a/@types/adnSubtitles.d.ts b/@types/adnSubtitles.d.ts new file mode 100644 index 0000000..ec45718 --- /dev/null +++ b/@types/adnSubtitles.d.ts @@ -0,0 +1,11 @@ +export interface ADNSubtitles { + [subtitleLang: string]: Subtitle[]; +} + +export interface Subtitle { + startTime: number; + endTime: number; + positionAlign: string; + lineAlign: string; + text: string; +} diff --git a/@types/adnVideos.d.ts b/@types/adnVideos.d.ts new file mode 100644 index 0000000..ccf3d0b --- /dev/null +++ b/@types/adnVideos.d.ts @@ -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; +} diff --git a/@types/ws.d.ts b/@types/ws.d.ts index fab66b8..f8efde0 100644 --- a/@types/ws.d.ts +++ b/@types/ws.d.ts @@ -30,8 +30,8 @@ export type MessageTypes = { 'isDownloading': [undefined, boolean], 'openFolder': [FolderTypes, undefined], 'changeProvider': [undefined, boolean], - 'type': [undefined, 'funi'|'crunchy'|'hidive'|undefined], - 'setup': ['funi'|'crunchy'|'hidive'|undefined, undefined], + 'type': [undefined, 'funi'|'crunchy'|'hidive'|'adn'|undefined], + 'setup': ['funi'|'crunchy'|'hidive'|'adn'|undefined, undefined], 'openFile': [[FolderTypes, string], undefined], 'openURL': [string, undefined], 'isSetup': [undefined, boolean], diff --git a/adn.ts b/adn.ts new file mode 100644 index 0000000..d133c9b --- /dev/null +++ b/adn.ts @@ -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; + 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 { + const cookies: Record = {}; + 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 { + 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 { + 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> = {}, + 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 = {}; + 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('', '{\\i1}').replace('', '{\\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); + }); + } +} \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 851f956..92610b4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,11 +2,11 @@ [![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 -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 diff --git a/gui/react/src/components/MenuBar/MenuBar.tsx b/gui/react/src/components/MenuBar/MenuBar.tsx index 23f5bb6..e4c2dbf 100644 --- a/gui/react/src/components/MenuBar/MenuBar.tsx +++ b/gui/react/src/components/MenuBar/MenuBar.tsx @@ -25,6 +25,8 @@ const MenuBar: React.FC = () => { return 'Crunchyroll'; case 'funi': return 'Funimation'; + case 'adn': + return 'AnimationDigitalNetwork'; case 'hidive': return 'Hidive'; } diff --git a/gui/react/src/provider/ServiceProvider.tsx b/gui/react/src/provider/ServiceProvider.tsx index eb770c5..553d5ab 100644 --- a/gui/react/src/provider/ServiceProvider.tsx +++ b/gui/react/src/provider/ServiceProvider.tsx @@ -3,7 +3,7 @@ import {Divider, Box, Button, Typography, Avatar} from '@mui/material'; import useStore from '../hooks/useStore'; import { StoreState } from './Store'; -type Services = 'funi'|'crunchy'|'hidive'; +type Services = 'adn'|'funi'|'crunchy'|'hidive'; export const serviceContext = React.createContext(undefined); @@ -24,6 +24,7 @@ const ServiceProvider: FCWithChildren = ({ children }) => { + : diff --git a/gui/react/src/provider/Store.tsx b/gui/react/src/provider/Store.tsx index c82ef92..31e61ee 100644 --- a/gui/react/src/provider/Store.tsx +++ b/gui/react/src/provider/Store.tsx @@ -21,7 +21,7 @@ export type DownloadOptions = { export type StoreState = { episodeListing: Episode[]; downloadOptions: DownloadOptions, - service: 'crunchy'|'funi'|'hidive'|undefined, + service: 'crunchy'|'funi'|'hidive'|'adn'|undefined, version: string, } diff --git a/gui/server/serviceHandler.ts b/gui/server/serviceHandler.ts index 9e8e365..eddd7dd 100644 --- a/gui/server/serviceHandler.ts +++ b/gui/server/serviceHandler.ts @@ -6,6 +6,7 @@ import { setState, getState, writeYamlCfgFile } from '../../modules/module.cfg-l import CrunchyHandler from './services/crunchyroll'; import FunimationHandler from './services/funimation'; import HidiveHandler from './services/hidive'; +import ADNHandler from './services/adn'; import WebSocketHandler from './websocket'; import packageJson from '../../package.json'; @@ -37,6 +38,8 @@ export default class ServiceHandler { this.service = new CrunchyHandler(this.ws); } else if (data === 'hidive') { 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) => { 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) => { if (this.service === undefined) return respond({ isOk: false, reason: new Error('No service selected') }); diff --git a/gui/server/services/adn.ts b/gui/server/services/adn.ts new file mode 100644 index 0000000..8efd27a --- /dev/null +++ b/gui/server/services/adn.ts @@ -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 { + //TODO: implement proper method to check token + return { isOk: true, value: undefined }; + } + + public async search(data: SearchData): Promise { + 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 { + const dubLanguageCodesArray: string[] = []; + for(const language of languages){ + if (language.adn_locale) + dubLanguageCodesArray.push(language.code); + } + return [...new Set(dubLanguageCodesArray)]; + } + + public async availableSubCodes(): Promise { + 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 { + 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 { + 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; \ No newline at end of file diff --git a/index.ts b/index.ts index 2d6b91f..2e1c089 100644 --- a/index.ts +++ b/index.ts @@ -66,6 +66,9 @@ import update from './modules/module.updater'; case 'hidive': service = new (await import('./hidive')).default; break; + case 'adn': + service = new (await import('./adn')).default; + break; default: service = new (await import(`./${argv.service}`)).default; break; @@ -84,6 +87,9 @@ import update from './modules/module.updater'; case 'hidive': service = new (await import('./hidive')).default; break; + case 'adn': + service = new (await import('./adn')).default; + break; default: service = new (await import(`./${argv.service}`)).default; break; diff --git a/modules/build-docs.ts b/modules/build-docs.ts index 5b8781b..fb90500 100644 --- a/modules/build-docs.ts +++ b/modules/build-docs.ts @@ -3,7 +3,7 @@ import fs from 'fs'; import path from 'path'; 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[] = []; str.forEach(function(part) { switch(part) { @@ -16,6 +16,9 @@ const transformService = (str: Array<'funi'|'crunchy'|'hidive'|'all'>) => { case 'hidive': services.push('Hidive'); break; + case 'adn': + services.push('AnimationDigitalNetwork'); + break; case 'all': services.push('All'); break; @@ -30,7 +33,7 @@ If you find any bugs in this documentation or in the program itself please repor ## 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. 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. diff --git a/modules/module.app-args.ts b/modules/module.app-args.ts index 00014c8..b3915cc 100644 --- a/modules/module.app-args.ts +++ b/modules/module.app-args.ts @@ -1,6 +1,8 @@ import yargs, { Choices } from 'yargs'; import { args, AvailableMuxer, groups } from './module.args'; import { LanguageItem } from './module.langsData'; +import { HLSCallback } from './hls-download'; +import { DownloadInfo } from '../@types/messageHandler'; let argvC: { [x: string]: unknown; @@ -61,7 +63,7 @@ let argvC: { debug: boolean | undefined; nocleanup: boolean; help: boolean | undefined; - service: 'funi' | 'crunchy' | 'hidive'; + service: 'funi' | 'crunchy' | 'hidive' | 'adn'; update: boolean; fontName: string | undefined; _: (string | number)[]; @@ -74,6 +76,7 @@ let argvC: { originalFontSize: boolean; keepAllVideos: boolean; syncTiming: boolean; + callbackMaker?: (data: DownloadInfo) => HLSCallback; }; export type ArgvType = typeof argvC; diff --git a/modules/module.args.ts b/modules/module.args.ts index e5a05a5..7108248 100644 --- a/modules/module.args.ts +++ b/modules/module.args.ts @@ -41,7 +41,7 @@ export type TAppArg = { default: T|undefined, name?: string }, - service: Array<'funi'|'crunchy'|'hidive'|'all'>, + service: Array<'funi'|'crunchy'|'hidive'|'adn'|'all'>, usage: string // -(-)${name} will be added for each command, demandOption?: true, transformer?: (value: T) => K @@ -112,7 +112,7 @@ const args: TAppArg[] = [ default: 'en-US' }, type: 'string', - service: ['crunchy'], + service: ['crunchy', 'adn'], usage: '${locale}' }, { @@ -572,7 +572,7 @@ const args: TAppArg[] = [ group: 'util', service: ['all'], type: 'string', - choices: ['funi', 'crunchy', 'hidive'], + choices: ['funi', 'crunchy', 'hidive', 'adn'], usage: '${service}', default: { default: '' @@ -593,7 +593,7 @@ const args: TAppArg[] = [ group: 'fonts', describe: 'Set the font to use in subtiles', docDescribe: true, - service: ['funi', 'hidive'], + service: ['funi', 'hidive', 'adn'], type: 'string', usage: '${fontName}', }, diff --git a/modules/module.cfg-loader.ts b/modules/module.cfg-loader.ts index 6f39d8c..5a2980c 100644 --- a/modules/module.cfg-loader.ts +++ b/modules/module.cfg-loader.ts @@ -20,12 +20,14 @@ const hdPflCfgFile = path.join(workingDir, 'config', 'hd_profile'); const sessCfgFile = { funi: path.join(workingDir, 'config', 'funi_sess'), cr: path.join(workingDir, 'config', 'cr_sess'), + adn: path.join(workingDir, 'config', 'adn_sess'), hd: path.join(workingDir, 'config', 'hd_sess') }; const stateFile = path.join(workingDir, 'config', 'guistate'); const tokenFile = { funi: path.join(workingDir, 'config', 'funi_token'), cr: path.join(workingDir, 'config', 'cr_token'), + adn: path.join(workingDir, 'config', 'adn_token'), hd: path.join(workingDir, 'config', 'hd_token'), hdNew: path.join(workingDir, 'config', 'hd_new_token') }; @@ -216,6 +218,24 @@ const saveCRToken = (data: Record) => { } }; +const loadADNToken = () => { + let token = loadYamlCfgFile(tokenFile.adn, true); + if(typeof token !== 'object' || token === null || Array.isArray(token)){ + token = {}; + } + return token; +}; + +const saveADNToken = (data: Record) => { + 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 = () => { let session = loadYamlCfgFile(sessCfgFile.hd, true); @@ -379,6 +399,8 @@ export { loadCRSession, saveCRToken, loadCRToken, + saveADNToken, + loadADNToken, saveHDSession, loadHDSession, saveHDToken, diff --git a/modules/module.downloadArchive.ts b/modules/module.downloadArchive.ts index bdac33c..56a7d9f 100644 --- a/modules/module.downloadArchive.ts +++ b/modules/module.downloadArchive.ts @@ -17,6 +17,9 @@ export type DataType = { hidive: { s: ItemType }, + adn: { + s: ItemType + }, crunchy: { srz: ItemType, s: ItemType @@ -32,6 +35,9 @@ const addToArchive = (kind: { } | { service: 'hidive', type: 's' +} | { + service: 'adn', + type: 's' }, ID: string) => { const data = loadData(); @@ -65,6 +71,15 @@ const addToArchive = (kind: { already: [] as string[] } : []), }; + } else if (kind.service === 'adn') { + data['adn'] = { + s: [ + { + id: ID, + already: [] + } + ] + }; } else { data['hidive'] = { s: [ @@ -88,6 +103,9 @@ const downloaded = (kind: { } | { service: 'hidive', type: 's' +} | { + service: 'adn', + type: 's' }, ID: string, episode: string[]) => { let data = loadData(); 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)); }; -const makeCommand = (service: 'funi'|'crunchy'|'hidive') : Partial[] => { +const makeCommand = (service: 'funi'|'crunchy'|'hidive'|'adn') : Partial[] => { const data = loadData(); const ret: Partial[] = []; const kind = data[service]; diff --git a/modules/module.fetch.ts b/modules/module.fetch.ts index 7e79b53..b4cbaa5 100644 --- a/modules/module.fetch.ts +++ b/modules/module.fetch.ts @@ -13,7 +13,7 @@ export type Params = { // req export class Req { private sessCfg: string; - private service: 'cr'|'funi'|'hd'; + private service: 'cr'|'funi'|'hd'|'adn'; private session: Record, private debug: boolean, private nosess = false, private type: 'cr'|'funi'|'hd') { + constructor(private domain: Record, private debug: boolean, private nosess = false, private type: 'cr'|'funi'|'hd'|'adn') { this.sessCfg = yamlCfg.sessCfgFile[type]; this.service = type; } diff --git a/modules/module.langsData.ts b/modules/module.langsData.ts index d139229..9f0ab05 100644 --- a/modules/module.langsData.ts +++ b/modules/module.langsData.ts @@ -3,6 +3,7 @@ export type LanguageItem = { cr_locale?: string, hd_locale?: string, + adn_locale?: string, new_hd_locale?: string, locale: 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: 'pt-BR', new_hd_locale: 'pt-BR', hd_locale: 'Portuguese', funi_name: 'Portuguese (Brazil)', funi_locale: 'ptBR', locale: 'pt-BR', code: 'por', name: 'Portuguese', language: 'Brazilian Portuguese' }, { cr_locale: 'pt-PT', locale: 'pt-PT', code: 'por', name: 'Portuguese (Portugal)', language: 'Portugues (Portugal)' }, - { cr_locale: 'fr-FR', hd_locale: 'French', locale: 'fr', code: 'fra', name: 'French' }, - { cr_locale: 'de-DE', hd_locale: 'German', locale: 'de', code: 'deu', name: 'German' }, + { cr_locale: 'fr-FR', adn_locale: 'fr', hd_locale: 'French', locale: 'fr', code: 'fra', name: 'French' }, + { 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-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' }, @@ -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: '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: '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 @@ -69,7 +70,7 @@ const subtitleLanguagesFilter = (() => { })(); 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