From 4c4436814bde381f4be025413f8fa2f3019a750a Mon Sep 17 00:00:00 2001 From: AnimeDL Date: Wed, 10 Apr 2024 22:12:57 -0700 Subject: [PATCH] Initial commit to add AnimeOnegai --- .gitignore | 1 + @types/animeOnegaiSearch.d.ts | 88 +++ @types/animeOnegaiSeasons.d.ts | 36 + @types/animeOnegaiSeries.d.ts | 111 +++ @types/animeOnegaiStream.d.ts | 41 + @types/ws.d.ts | 4 +- ao.ts | 772 +++++++++++++++++++ docs/README.md | 5 +- gui/react/src/components/MenuBar/MenuBar.tsx | 2 + gui/react/src/provider/ServiceProvider.tsx | 3 +- gui/react/src/provider/Store.tsx | 2 +- gui/server/serviceHandler.ts | 5 +- gui/server/services/animeonegai.ts | 144 ++++ index.ts | 17 +- modules/build-docs.ts | 7 +- modules/module.app-args.ts | 5 +- modules/module.args.ts | 6 +- modules/module.cfg-loader.ts | 25 +- modules/module.downloadArchive.ts | 20 +- modules/module.fetch.ts | 6 +- modules/module.langsData.ts | 7 +- modules/module.req.ts | 4 +- tsc.ts | 1 + 23 files changed, 1287 insertions(+), 25 deletions(-) create mode 100644 @types/animeOnegaiSearch.d.ts create mode 100644 @types/animeOnegaiSeasons.d.ts create mode 100644 @types/animeOnegaiSeries.d.ts create mode 100644 @types/animeOnegaiStream.d.ts create mode 100644 ao.ts create mode 100644 gui/server/services/animeonegai.ts diff --git a/.gitignore b/.gitignore index a56e4c7..97799b4 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ hd_profile.yml hd_sess.yml hd_token.yml hd_new_token.yml +ao_token.yml archive.json guistate.json fonts diff --git a/@types/animeOnegaiSearch.d.ts b/@types/animeOnegaiSearch.d.ts new file mode 100644 index 0000000..74bdfc1 --- /dev/null +++ b/@types/animeOnegaiSearch.d.ts @@ -0,0 +1,88 @@ +export interface AnimeOnegaiSearch { + text: string; + list: AOSearchResult[]; +} + +export interface AOSearchResult { + /** + * Asset ID + */ + ID: number; + CreatedAt: Date; + UpdatedAt: Date; + DeletedAt: null; + title: string; + active: boolean; + excerpt: string; + description: string; + bg: string; + poster: string; + entry: string; + code_name: string; + /** + * The Video ID required to get the streams + */ + video_entry: string; + trailer: string; + year: number; + /** + * Asset Type, Known Possibilities + * * 1 - Video + * * 2 - Series + */ + asset_type: 1 | 2; + status: number; + permalink: string; + duration: string; + subtitles: boolean; + price: number; + rent_price: number; + rating: number; + color: number | null; + classification: number; + brazil_classification: null | string; + likes: number; + views: number; + button: string; + stream_url: string; + stream_url_backup: string; + copyright: null | string; + skip_intro: null | string; + ending: null | string; + bumper_intro: string; + ads: string; + age_restriction: boolean | null; + epg: null; + allow_languages: string[] | null; + allow_countries: string[] | null; + classification_text: string; + locked: boolean; + resign: boolean; + favorite: boolean; + actors_list: null; + voiceactors_list: null; + artdirectors_list: null; + audios_list: null; + awards_list: null; + companies_list: null; + countries_list: null; + directors_list: null; + edition_list: null; + genres_list: null; + music_list: null; + photograpy_list: null; + producer_list: null; + screenwriter_list: null; + season_list: null; + tags_list: null; + chapter_id: number; + chapter_entry: string; + chapter_poster: string; + progress_time: number; + progress_percent: number; + included_subscription: number; + paid_content: number; + rent_content: number; + objectID: string; + lang: string; +} \ No newline at end of file diff --git a/@types/animeOnegaiSeasons.d.ts b/@types/animeOnegaiSeasons.d.ts new file mode 100644 index 0000000..effbc8b --- /dev/null +++ b/@types/animeOnegaiSeasons.d.ts @@ -0,0 +1,36 @@ +export interface AnimeOnegaiSeasons { + ID: number; + CreatedAt: Date; + UpdatedAt: Date; + DeletedAt: null; + name: string; + number: number; + asset_id: number; + entry: string; + description: string; + active: boolean; + allow_languages: string[]; + allow_countries: string[]; + list: Episode[]; +} + +export interface Episode { + ID: number; + CreatedAt: Date; + UpdatedAt: Date; + DeletedAt: null; + name: string; + number: number; + description: string; + thumbnail: string; + entry: string; + video_entry: string; + active: boolean; + season_id: number; + stream_url: string; + skip_intro: null; + ending: null; + open_free: boolean; + asset_id: number; + age_restriction: null; +} diff --git a/@types/animeOnegaiSeries.d.ts b/@types/animeOnegaiSeries.d.ts new file mode 100644 index 0000000..52662ff --- /dev/null +++ b/@types/animeOnegaiSeries.d.ts @@ -0,0 +1,111 @@ +export interface AnimeOnegaiSeries { + ID: number; + CreatedAt: Date; + UpdatedAt: Date; + DeletedAt: null; + title: string; + active: boolean; + excerpt: string; + description: string; + bg: string; + poster: string; + entry: string; + code_name: string; + /** + * The Video ID required to get the streams + */ + video_entry: string; + trailer: string; + year: number; + asset_type: number; + status: number; + permalink: string; + duration: string; + subtitles: boolean; + price: number; + rent_price: number; + rating: number; + color: number; + classification: number; + brazil_classification: string; + likes: number; + views: number; + button: string; + stream_url: string; + stream_url_backup: string; + copyright: string; + skip_intro: null; + ending: null; + bumper_intro: string; + ads: string; + age_restriction: boolean; + epg: null; + allow_languages: string[]; + allow_countries: string[]; + classification_text: string; + locked: boolean; + resign: boolean; + favorite: boolean; + actors_list: CtorsList[]; + voiceactors_list: CtorsList[]; + artdirectors_list: any[]; + audios_list: SList[]; + awards_list: any[]; + companies_list: any[]; + countries_list: any[]; + directors_list: CtorsList[]; + edition_list: any[]; + genres_list: SList[]; + music_list: any[]; + photograpy_list: any[]; + producer_list: any[]; + screenwriter_list: any[]; + season_list: any[]; + tags_list: TagsList[]; + chapter_id: number; + chapter_entry: string; + chapter_poster: string; + progress_time: number; + progress_percent: number; + included_subscription: number; + paid_content: number; + rent_content: number; + objectID: string; + lang: string; +} + +export interface CtorsList { + ID: number; + CreatedAt: Date; + UpdatedAt: Date; + DeletedAt: null; + name: string; + Permalink?: string; + country: number | null; + year: number | null; + death: number | null; + image: string; + genre: null; + description: string; + permalink?: string; + background?: string; +} + +export interface SList { + ID: number; + CreatedAt: Date; + UpdatedAt: Date; + DeletedAt: null; + name: string; + age_restriction?: number; +} + +export interface TagsList { + ID: number; + CreatedAt: Date; + UpdatedAt: Date; + DeletedAt: null; + name: string; + position: number; + status: boolean; +} diff --git a/@types/animeOnegaiStream.d.ts b/@types/animeOnegaiStream.d.ts new file mode 100644 index 0000000..7199316 --- /dev/null +++ b/@types/animeOnegaiStream.d.ts @@ -0,0 +1,41 @@ +export interface AnimeOnegaiStream { + ID: number; + CreatedAt: Date; + UpdatedAt: Date; + DeletedAt: null; + name: string; + source_url: string; + backup_url: string; + live: boolean; + token_handler: number; + entry: string; + job: string; + drm: boolean; + transcoding_content_id: string; + transcoding_asset_id: string; + status: number; + thumbnail: string; + hls: string; + dash: string; + widevine_proxy: string; + playready_proxy: string; + apple_licence: string; + apple_certificate: string; + dpath: string; + dbin: string; + subtitles: Subtitle[]; + origin: number; + offline_entry: string; + offline_status: boolean; +} + +export interface Subtitle { + ID: number; + CreatedAt: Date; + UpdatedAt: Date; + DeletedAt: null; + name: string; + lang: string; + entry_id: string; + url: string; +} diff --git a/@types/ws.d.ts b/@types/ws.d.ts index fab66b8..c2bea5a 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'|'ao'|undefined], + 'setup': ['funi'|'crunchy'|'hidive'|'ao'|undefined, undefined], 'openFile': [[FolderTypes, string], undefined], 'openURL': [string, undefined], 'isSetup': [undefined, boolean], diff --git a/ao.ts b/ao.ts new file mode 100644 index 0000000..bb60d89 --- /dev/null +++ b/ao.ts @@ -0,0 +1,772 @@ +// Package Info +import packageJson from './package.json'; + +// Node +import path from 'path'; +import fs from 'fs-extra'; + +// Plugins +import shlp from 'sei-helper'; + +// 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 getKeys, { canDecrypt } from './modules/widevine'; +import streamdl, { M3U8Json } from './modules/hls-download'; +import { exec } from './modules/sei-helper-fixes'; +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'; +import { parse } from './modules/module.transform-mpd'; + +// Types +import { ServiceClass } from './@types/serviceClassInterface'; +import { AuthData, AuthResponse, SearchData, SearchResponse, SearchResponseItem } from './@types/messageHandler'; +import { AOSearchResult, AnimeOnegaiSearch } from './@types/animeOnegaiSearch'; +import { AnimeOnegaiSeries } from './@types/animeOnegaiSeries'; +import { AnimeOnegaiSeasons, Episode } from './@types/animeOnegaiSeasons'; +import { DownloadedMedia } from './@types/hidiveTypes'; +import { AnimeOnegaiStream } from './@types/animeOnegaiStream'; +import { sxItem } from './crunchy'; + +type parsedMultiDubDownload = { + data: { + lang: string, + videoId: string + episode: Episode + }[], + seriesTitle: string, + seasonTitle: string, + episodeTitle: string, + episodeNumber: number, + seasonNumber: number, + seriesID: number, + seasonID: number, + image: string, +} + +export default class AnimeOnegai implements ServiceClass { + public cfg: yamlCfg.ConfigObject; + private token: Record; + private req: reqModule.Req; + public jpnStrings: string[] = [ + 'Japonés con Subtítulos en Español', + 'Japonés con Subtítulos en Portugués', + 'Japonês com legendas em espanhol', + 'Japonês com legendas em português' + ]; + public spaStrings: string[] = [ + 'Doblaje en Español', + 'Dublagem em espanhol' + ]; + public porStrings: string[] = [ + 'Doblaje en Portugués', + 'Dublagem em português' + ]; + + constructor(private debug = false) { + this.cfg = yamlCfg.loadCfg(); + this.token = yamlCfg.loadAOToken(); + this.req = new reqModule.Req(domain, debug, false, 'ao'); + } + + public async cli() { + console.info(`\n=== Multi Downloader NX ${packageJson.version} ===\n`); + const argv = yargs.appArgv(this.cfg.cli); + 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, argv); + if (selected.isOk) { + for (const select of selected.value) { + if (!(await this.downloadEpisode(select, {...argv, skipsubs: false}))) { + console.error(`Unable to download selected episode ${select.episodeNumber}`); + return false; + } + } + } + return true; + } else if (argv.token) { + this.token = {token: argv.token}; + yamlCfg.saveAOToken(this.token); + console.info('Saved token'); + } else { + console.info('No option selected or invalid value entered. Try --help.'); + } + } + + public async doSearch(data: SearchData): Promise { + const searchReq = await this.req.getData(`https://api.animeonegai.com/v1/search/algolia/${encodeURIComponent(data.search)}`); + if (!searchReq.ok || !searchReq.res) { + console.error('Search FAILED!'); + return { isOk: false, reason: new Error('Search failed. No more information provided') }; + } + const searchData = await searchReq.res.json() as AnimeOnegaiSearch; + const searchItems: AOSearchResult[] = []; + console.info('Search Results:'); + for (const hit of searchData.list) { + searchItems.push(hit); + let fullType: string; + if (hit.asset_type == 2) { + fullType = `S.${hit.ID}`; + } else if (hit.asset_type == 1) { + fullType = `E.${hit.ID}`; + } else { + fullType = 'Unknown'; + console.warn(`Unknown asset type ${hit.asset_type}, please report this.`); + } + console.log(`[${fullType}] ${hit.title}`); + } + return { isOk: true, value: searchItems.filter(a => a.asset_type == 2).flatMap((a): SearchResponseItem => { + return { + id: a.ID+'', + image: a.poster ?? '/notFound.png', + name: a.title, + rating: a.likes, + desc: a.description + }; + })}; + } + + public async doAuth(data: AuthData): Promise { + data; + console.error('Authentication not possible, manual authentication required due to recaptcha. In order to login use the --token flag. You can get the token by logging into the website, and opening the dev console and running the command "localStorage.ott_token"'); + return { isOk: false, reason: new Error('Authentication not possible, manual authentication required do to recaptcha.') }; + } + + public async getShow(id: number) { + const getSeriesData = await this.req.getData(`https://api.animeonegai.com/v1/asset/${id}`); + if (!getSeriesData.ok || !getSeriesData.res) { + console.error('Failed to get Show Data'); + return { isOk: false }; + } + const seriesData = await getSeriesData.res.json() as AnimeOnegaiSeries; + + const getSeasonData = await this.req.getData(`https://api.animeonegai.com/v1/asset/content/${id}`); + if (!getSeasonData.ok || !getSeasonData.res) { + console.error('Failed to get Show Data'); + return { isOk: false }; + } + const seasonData = await getSeasonData.res.json() as AnimeOnegaiSeasons[]; + + return { isOk: true, data: seriesData, seasons: seasonData }; + } + + public async listShow(id: number, outputEpisode: boolean = true) { + const series = await this.getShow(id); + if (!series.isOk || !series.data) { + console.error('Failed to list series data: Failed to get series'); + return { isOk: false }; + } + console.info(`[S.${series.data.ID}] ${series.data.title} (${series.seasons.length} Seasons)`); + if (series.seasons.length === 0) { + console.info(' No Seasons found!'); + return { isOk: false }; + } + const episodes: { [key: number]: (Episode & { lang?: string })[] } = {}; + for (const season of series.seasons) { + let lang: string | undefined = undefined; + if (this.jpnStrings.includes(season.name)) lang = 'ja'; + if (this.porStrings.includes(season.name)) lang = 'pt'; + if (this.spaStrings.includes(season.name)) lang = 'es'; + for (const episode of season.list) { + if (!episodes[episode.number]) { + episodes[episode.number] = []; + } + /*if (!episodes[episode.number].find(a=>a.lang == lang))*/ episodes[episode.number].push({...episode, lang}); + } + } + //Enable to output episodes seperate from selection + if (outputEpisode) { + for (const episodeKey in episodes) { + const episode = episodes[episodeKey][0]; + const langs = Array.from(new Set(episodes[episodeKey].map(a=>a.lang))); + console.info(` [E.${episode.ID}] E${episode.number} - ${episode.name} (${langs.map(a=>{ + if (a) return langsData.languages.find(b=>b.ao_locale === a)?.name; + return 'Unknown'; + }).join(', ')})`); + } + } + return { isOk: true, value: episodes, series: series }; + } + + public async selectShow(id: number, e: string | undefined, but: boolean, all: boolean, options: yargs.ArgvType) { + const getShowData = await this.listShow(id, false); + if (!getShowData.isOk || !getShowData.value) { + return { isOk: false, value: [] }; + } + //const showData = getShowData.value; + const doEpsFilter = parseSelect(e as string); + // build selected episodes + const selEpsArr: parsedMultiDubDownload[] = []; + const episodes = getShowData.value; + const seasonNumberTitleParse = getShowData.series.data.title.match(/\d+$/); + const seasonNumber = seasonNumberTitleParse ? parseInt(seasonNumberTitleParse[0]) : 1; + for (const episodeKey in getShowData.value) { + const episode = episodes[episodeKey][0]; + const selectedLangs: string[] = []; + const selected: { + lang: string, + videoId: string + episode: Episode + }[] = []; + for (const episode of episodes[episodeKey]) { + const lang = langsData.languages.find(a=>a.ao_locale === episode.lang); + let isSelected = false; + if (typeof selected.find(a=>a.lang == episode.lang) == 'undefined') { + if (options.dubLang.includes(lang?.code ?? 'Unknown')) { + if ((but && !doEpsFilter.isSelected([episode.number+'', episode.ID+''])) || all || (!but && doEpsFilter.isSelected([episode.number+'', episode.ID+'']))) { + isSelected = true; + selected.push({lang: episode.lang as string, videoId: episode.video_entry, episode: episode }); + } + } + const selectedLang = isSelected ? `✓ ${lang?.name ?? 'Unknown'}` : `${lang?.name ?? 'Unknown'}`; + if (!selectedLangs.includes(selectedLang)) { + selectedLangs.push(selectedLang); + } + } + } + if (selected.length > 0) { + selEpsArr.push({ + 'data': selected, + 'seasonNumber': seasonNumber, + 'episodeNumber': episode.number, + 'episodeTitle': episode.name, + 'image': episode.thumbnail, + 'seasonID': episode.season_id, + 'seasonTitle': getShowData.series.data.title, + 'seriesTitle': getShowData.series.data.title, + 'seriesID': getShowData.series.data.ID + }); + } + console.info(` [S${seasonNumber}E${episode.number}] - ${episode.name} (${selectedLangs.join(', ')})`); + } + return { isOk: true, value: selEpsArr, showData: getShowData.series }; + } + + public async downloadEpisode(data: parsedMultiDubDownload, options: yargs.ArgvType): Promise { + const res = await this.downloadMediaList(data, options); + if (res === undefined || res.error) { + return false; + } else { + if (!options.skipmux) { + await this.muxStreams(res.data, { ...options, output: res.fileName }); + } else { + console.info('Skipping mux'); + } + downloaded({ + service: 'ao', + type: 's' + }, data.seasonID+'', [data.episodeNumber+'']); + } + return true; + } + + 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 downloadMediaList(medias: parsedMultiDubDownload, options: yargs.ArgvType) : Promise<{ + data: DownloadedMedia[], + fileName: string, + error: boolean + } | undefined> { + if(!this.token.token){ + console.error('Authentication required!'); + return; + } + + if (!this.cfg.bin.ffmpeg) + this.cfg.bin = await yamlCfg.loadBinCfg(); + + let mediaName = '...'; + let fileName; + const variables: Variable[] = []; + if(medias.seasonTitle && medias.episodeNumber && medias.episodeTitle){ + mediaName = `${medias.seasonTitle} - ${medias.episodeNumber} - ${medias.episodeTitle}`; + } + + const files: DownloadedMedia[] = []; + + let dlFailed = false; + let dlVideoOnce = false; // Variable to save if best selected video quality was downloaded + + + for (const media of medias.data) { + console.info(`Requesting: [E.${media.episode.ID}] ${mediaName}`); + + const AuthHeaders = { + headers: { + Authorization: `Bearer ${this.token.token}`, + 'Referer': 'https://www.animeonegai.com/', + 'Origin': 'https://www.animeonegai.com', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'Content-Type': 'application/json' + } + }; + + const playbackReq = await this.req.getData(`https://api.animeonegai.com/v1/media/${media.videoId}`, AuthHeaders); + if(!playbackReq.ok || !playbackReq.res){ + console.error('Request Stream URLs FAILED!'); + return undefined; + } + const streamData = await playbackReq.res.json() as AnimeOnegaiStream; + + variables.push(...([ + ['title', medias.episodeTitle, true], + ['episode', isNaN(medias.episodeNumber) ? medias.episodeNumber : medias.episodeNumber, false], + ['service', 'AO', false], + ['seriesTitle', medias.seriesTitle, true], + ['showTitle', medias.seasonTitle, true], + ['season', medias.seasonNumber, false] + ] as [AvailableFilenameVars, string|number, boolean][]).map((a): Variable => { + return { + name: a[0], + replaceWith: a[1], + type: typeof a[1], + sanitize: a[2] + } as Variable; + })); + + if (!canDecrypt) { + console.warn('Decryption not enabled!'); + } + + const lang = langsData.languages.find(a=>a.ao_locale == media.lang) as langsData.LanguageItem; + if (!lang) { + console.error(`Unable to find language for code ${media.lang}`); + return; + } + let tsFile = undefined; + + console.info('Playlists URL: %s', streamData.dash); + + if(!dlFailed && !(options.novids && options.noaudio)){ + const streamPlaylistsReq = await this.req.getData(streamData.dash, AuthHeaders); + if(!streamPlaylistsReq.ok || !streamPlaylistsReq.res){ + console.error('CAN\'T FETCH VIDEO PLAYLISTS!'); + dlFailed = true; + } else { + if (!options.novids) { + const streamPlaylistBody = (await streamPlaylistsReq.res.text()).replace(/(.*?)<\/BaseURL>/g, `${streamData.dash.split('/dash/')[0]}/dash/$1`); + fs.writeFileSync('test.mpd', streamPlaylistBody); + //Parse MPD Playlists + const streamPlaylists = await parse(streamPlaylistBody, lang as langsData.LanguageItem, streamData.dash.split('/dash/')[0]+'/dash/'); + + //Get name of CDNs/Servers + const streamServers = Object.keys(streamPlaylists); + + options.x = options.x > streamServers.length ? 1 : options.x; + + const selectedServer = streamServers[options.x - 1]; + const selectedList = streamPlaylists[selectedServer]; + + //set Video Qualities + const videos = selectedList.video.map(item => { + return { + ...item, + resolutionText: `${item.quality.width}x${item.quality.height} (${Math.round(item.bandwidth/1024)}KiB/s)` + }; + }); + + const audios = selectedList.audio.map(item => { + return { + ...item, + resolutionText: `${Math.round(item.bandwidth/1000)}kB/s` + }; + }); + + videos.sort((a, b) => { + return a.quality.width - b.quality.width; + }); + + audios.sort((a, b) => { + return a.bandwidth - b.bandwidth; + }); + + let chosenVideoQuality = options.q === 0 ? videos.length : options.q; + if(chosenVideoQuality > videos.length) { + console.warn(`The requested quality of ${options.q} is greater than the maximum ${videos.length}.\n[WARN] Therefor the maximum will be capped at ${videos.length}.`); + chosenVideoQuality = videos.length; + } + chosenVideoQuality--; + + let chosenAudioQuality = options.q === 0 ? audios.length : options.q; + if(chosenAudioQuality > audios.length) { + chosenAudioQuality = audios.length; + } + chosenAudioQuality--; + + const chosenVideoSegments = videos[chosenVideoQuality]; + const chosenAudioSegments = audios[chosenAudioQuality]; + + console.info(`Servers available:\n\t${streamServers.join('\n\t')}`); + console.info(`Available Video Qualities:\n\t${videos.map((a, ind) => `[${ind+1}] ${a.resolutionText}`).join('\n\t')}`); + console.info(`Available Audio Qualities:\n\t${audios.map((a, ind) => `[${ind+1}] ${a.resolutionText}`).join('\n\t')}`); + + variables.push({ + name: 'height', + type: 'number', + replaceWith: chosenVideoSegments.quality.height + }, { + name: 'width', + type: 'number', + replaceWith: chosenVideoSegments.quality.width + }); + + console.info(`Selected quality: \n\tVideo: ${chosenVideoSegments.resolutionText}\n\tAudio: ${chosenAudioSegments.resolutionText}\n\tServer: ${selectedServer}`); + //console.info('Stream URL:', chosenVideoSegments.segments[0].uri); + // TODO check filename + fileName = parseFileName(options.fileName, variables, options.numbers, options.override).join(path.sep); + const outFile = parseFileName(options.fileName + '.' + lang.name, variables, options.numbers, options.override).join(path.sep); + const tempFile = parseFileName(`temp-${media.videoId}`, variables, options.numbers, options.override).join(path.sep); + const tempTsFile = path.isAbsolute(tempFile as string) ? tempFile : path.join(this.cfg.dir.content, tempFile); + + let [audioDownloaded, videoDownloaded] = [false, false]; + + // When best selected video quality is already downloaded + if(dlVideoOnce && options.dlVideoOnce) { + console.info('Already downloaded video, skipping video download...'); + } else if (options.novids) { + console.info('Skipping video download...'); + } else { + //Download Video + const totalParts = chosenVideoSegments.segments.length; + const mathParts = Math.ceil(totalParts / options.partsize); + const mathMsg = `(${mathParts}*${options.partsize})`; + console.info('Total parts in video stream:', totalParts, mathMsg); + 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 videoJson: M3U8Json = { + segments: chosenVideoSegments.segments + }; + const videoDownload = await new streamdl({ + output: chosenVideoSegments.pssh ? `${tempTsFile}.video.enc.mp4` : `${tsFile}.video.mp4`, + timeout: options.timeout, + m3u8json: videoJson, + // baseurl: chunkPlaylist.baseUrl, + threads: options.partsize, + fsRetryTime: options.fsRetryTime * 1000, + override: options.force, + callback: options.callbackMaker ? options.callbackMaker({ + fileName: `${path.isAbsolute(outFile) ? outFile.slice(this.cfg.dir.content.length) : outFile}`, + image: medias.image, + parent: { + title: medias.seasonTitle + }, + title: medias.episodeTitle, + language: lang + }) : undefined + }).download(); + if(!videoDownload.ok){ + console.error(`DL Stats: ${JSON.stringify(videoDownload.parts)}\n`); + dlFailed = true; + } + dlVideoOnce = true; + videoDownloaded = true; + } + + if (chosenAudioSegments && !options.noaudio) { + //Download Audio (if available) + const totalParts = chosenAudioSegments.segments.length; + const mathParts = Math.ceil(totalParts / options.partsize); + const mathMsg = `(${mathParts}*${options.partsize})`; + console.info('Total parts in audio stream:', totalParts, mathMsg); + tsFile = path.isAbsolute(outFile as string) ? outFile : path.join(this.cfg.dir.content, outFile); + const split = outFile.split(path.sep).slice(0, -1); + split.forEach((val, ind, arr) => { + const isAbsolut = path.isAbsolute(outFile as string); + if (!fs.existsSync(path.join(isAbsolut ? '' : this.cfg.dir.content, ...arr.slice(0, ind), val))) + fs.mkdirSync(path.join(isAbsolut ? '' : this.cfg.dir.content, ...arr.slice(0, ind), val)); + }); + const audioJson: M3U8Json = { + segments: chosenAudioSegments.segments + }; + const audioDownload = await new streamdl({ + output: chosenAudioSegments.pssh ? `${tempTsFile}.audio.enc.mp4` : `${tsFile}.audio.mp4`, + timeout: options.timeout, + m3u8json: audioJson, + // baseurl: chunkPlaylist.baseUrl, + threads: options.partsize, + fsRetryTime: options.fsRetryTime * 1000, + override: options.force, + callback: options.callbackMaker ? options.callbackMaker({ + fileName: `${path.isAbsolute(outFile) ? outFile.slice(this.cfg.dir.content.length) : outFile}`, + image: medias.image, + parent: { + title: medias.seasonTitle + }, + title: medias.episodeTitle, + language: lang + }) : undefined + }).download(); + if(!audioDownload.ok){ + console.error(`DL Stats: ${JSON.stringify(audioDownload.parts)}\n`); + dlFailed = true; + } + audioDownloaded = true; + } else if (options.noaudio) { + console.info('Skipping audio download...'); + } + + //Handle Decryption if needed + if ((chosenVideoSegments.pssh || chosenAudioSegments.pssh) && (videoDownloaded || audioDownloaded)) { + console.info('Decryption Needed, attempting to decrypt'); + const encryptionKeys = await getKeys(chosenVideoSegments.pssh, streamData.widevine_proxy, {}); + if (encryptionKeys.length == 0) { + console.error('Failed to get encryption keys'); + return undefined; + } + /*const keys = {} as Record; + encryptionKeys.forEach(function(key) { + keys[key.kid] = key.key; + });*/ + + if (this.cfg.bin.mp4decrypt) { + const commandBase = `--show-progress --key ${encryptionKeys[1].kid}:${encryptionKeys[1].key} `; + const commandVideo = commandBase+`"${tempTsFile}.video.enc.mp4" "${tempTsFile}.video.mp4"`; + const commandAudio = commandBase+`"${tempTsFile}.audio.enc.mp4" "${tempTsFile}.audio.mp4"`; + + if (videoDownloaded) { + console.info('Started decrypting video'); + const decryptVideo = exec('mp4decrypt', `"${this.cfg.bin.mp4decrypt}"`, commandVideo); + if (!decryptVideo.isOk) { + console.error(decryptVideo.err); + console.error(`Decryption failed with exit code ${decryptVideo.err.code}`); + fs.renameSync(`${tempTsFile}.video.enc.mp4`, `${tsFile}.video.enc.mp4`); + return undefined; + } else { + console.info('Decryption done for video'); + if (!options.nocleanup) { + fs.removeSync(`${tempTsFile}.video.enc.mp4`); + } + fs.renameSync(`${tempTsFile}.video.mp4`, `${tsFile}.video.mp4`); + files.push({ + type: 'Video', + path: `${tsFile}.video.mp4`, + lang: lang + }); + } + } + + if (audioDownloaded) { + console.info('Started decrypting audio'); + const decryptAudio = exec('mp4decrypt', `"${this.cfg.bin.mp4decrypt}"`, commandAudio); + if (!decryptAudio.isOk) { + console.error(decryptAudio.err); + console.error(`Decryption failed with exit code ${decryptAudio.err.code}`); + fs.renameSync(`${tempTsFile}.audio.enc.mp4`, `${tsFile}.audio.enc.mp4`); + return undefined; + } else { + if (!options.nocleanup) { + fs.removeSync(`${tempTsFile}.audio.enc.mp4`); + } + fs.renameSync(`${tempTsFile}.audio.mp4`, `${tsFile}.audio.mp4`); + files.push({ + type: 'Audio', + path: `${tsFile}.audio.mp4`, + lang: lang + }); + console.info('Decryption done for audio'); + } + } + } else { + console.warn('mp4decrypt not found, files need decryption. Decryption Keys:', encryptionKeys); + } + } else { + if (videoDownloaded) { + files.push({ + type: 'Video', + path: `${tsFile}.video.mp4`, + lang: lang + }); + } + if (audioDownloaded) { + files.push({ + type: 'Audio', + path: `${tsFile}.audio.mp4`, + lang: lang + }); + } + } + } else { + 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); + } + + 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(streamData.subtitles.length > 0) { + let subIndex = 0; + for(const sub of streamData.subtitles) { + const subLang = langsData.languages.find(a => a.ao_locale === sub.lang); + if (!subLang) { + console.warn(`Language not found for subtitle language: ${sub.lang}, Skipping`); + continue; + } + const sxData: Partial = {}; + sxData.file = langsData.subsFile(fileName as string, subIndex+'', subLang, false, options.ccTag); + sxData.path = path.join(this.cfg.dir.content, sxData.file); + sxData.language = subLang; + if((options.dlsubs.includes('all') || options.dlsubs.includes(subLang.locale)) && sub.url.includes('.ass')) { + const getSubtitle = await this.req.getData(sub.url); + if (getSubtitle.ok && getSubtitle.res) { + console.info(`Subtitle Downloaded: ${sub.url}`); + const sBody = await getSubtitle.res.text(); + sxData.title = `${subLang.language}`; + sxData.fonts = fontsData.assFonts(sBody) as Font[]; + fs.writeFileSync(sxData.path, sBody); + files.push({ + type: 'Subtitle', + ...sxData as sxItem, + cc: false + }); + } else{ + console.warn(`Failed to download subtitle: ${sxData.file}`); + } + } + subIndex++; + } + } else{ + console.warn('Can\'t find urls for subtitles!'); + } + } + else{ + console.info('Subtitles downloading skipped!'); + } + await this.sleep(options.waittime); + } + 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..7e9bdaf 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*, *Hidive*, and *AnimeOnegai*. ## 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*, *Hidive*, or *AnimeOnegai*. This application enables you to download videos for offline viewing which may be forbidden by law in your country. The usage of this application may also cause a violation of the *Terms of Service* between you and the stream provider. This tool is not responsible for your actions; please make an informed decision before using this application. ## Dependencies @@ -79,7 +79,6 @@ You can run the code from native TypeScript, this requires ts-node which you can Afterwords, you can run the application like this: * CLI: `ts-node -T ./index.ts --help` -* GUI: `ts-node -T ./gui.ts` ### Run as JavaScript diff --git a/gui/react/src/components/MenuBar/MenuBar.tsx b/gui/react/src/components/MenuBar/MenuBar.tsx index 23f5bb6..11d8ba9 100644 --- a/gui/react/src/components/MenuBar/MenuBar.tsx +++ b/gui/react/src/components/MenuBar/MenuBar.tsx @@ -27,6 +27,8 @@ const MenuBar: React.FC = () => { return 'Funimation'; case 'hidive': return 'Hidive'; + case 'ao': + return 'AnimeOnegai'; } }; diff --git a/gui/react/src/provider/ServiceProvider.tsx b/gui/react/src/provider/ServiceProvider.tsx index eb770c5..0461a15 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 = 'funi'|'crunchy'|'hidive'|'ao'; 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..a48a69d 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'|'ao'|undefined, version: string, } diff --git a/gui/server/serviceHandler.ts b/gui/server/serviceHandler.ts index 9e8e365..052a78f 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 AnimeOnegaiHandler from './services/animeonegai'; 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 === 'ao') { + this.service = new AnimeOnegaiHandler(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'|'ao')); 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/animeonegai.ts b/gui/server/services/animeonegai.ts new file mode 100644 index 0000000..df7d053 --- /dev/null +++ b/gui/server/services/animeonegai.ts @@ -0,0 +1,144 @@ +import { AuthData, CheckTokenResponse, DownloadData, Episode, EpisodeListResponse, MessageHandler, ResolveItemsData, SearchData, SearchResponse } from '../../../@types/messageHandler'; +import AnimeOnegai from '../../../ao'; +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 AnimeOnegaiHandler extends Base implements MessageHandler { + private ao: AnimeOnegai; + public name = 'ao'; + constructor(ws: WebSocketHandler) { + super(ws); + this.ao = new AnimeOnegai(); + this.initState(); + } + + public async auth(data: AuthData) { + return this.ao.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.ao.doSearch(data); + if (!search.isOk) { + return search; + } + return { isOk: true, value: search.value }; + } + + public async handleDefault(name: string) { + return getDefault(name, this.ao.cfg.cli); + } + + public async availableDubCodes(): Promise { + const dubLanguageCodesArray: string[] = []; + for(const language of languages){ + if (language.ao_locale) + dubLanguageCodesArray.push(language.code); + } + return [...new Set(dubLanguageCodesArray)]; + } + + public async availableSubCodes(): Promise { + const subLanguageCodesArray: string[] = []; + for(const language of languages){ + if (language.ao_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 _default = yargs.appArgv(this.ao.cfg.cli, true); + const res = await this.ao.selectShow(parseInt(data.id), data.e, data.but, data.all, _default); + if (!res.isOk || !res.value) + return res.isOk; + this.addToQueue(res.value.map(a => { + return { + ...data, + ids: a.data.map(a => a.videoId), + title: a.episodeTitle, + parent: { + title: a.seasonTitle, + season: a.seasonTitle + }, + e: a.episodeNumber+'', + image: a.image, + episode: a.episodeNumber+'' + }; + })); + return true; + } + + public async listEpisodes(id: string): Promise { + const parse = parseInt(id); + if (isNaN(parse) || parse <= 0) + return { isOk: false, reason: new Error('The ID is invalid') }; + + const request = await this.ao.listShow(parse); + if (!request.isOk || !request.value) + return {isOk: false, reason: new Error('Unknown upstream error, check for additional logs')}; + + const episodes: Episode[] = []; + const seasonNumberTitleParse = request.series.data.title.match(/\d+$/); + const seasonNumber = seasonNumberTitleParse ? parseInt(seasonNumberTitleParse[0]) : 1; + //request.value + for (const episodeKey in request.value) { + const episode = request.value[episodeKey][0]; + const langs = Array.from(new Set(request.value[episodeKey].map(a=>a.lang))); + episodes.push({ + e: episode.number+'', + lang: langs as string[], + name: episode.name, + season: seasonNumber+'', + seasonTitle: '', + episode: episode.number+'', + id: episode.video_entry+'', + img: episode.thumbnail, + description: episode.description, + time: '' + }); + } + return { isOk: true, value: episodes }; + } + + public async downloadItem(data: DownloadData) { + this.setDownloading(true); + console.debug(`Got download options: ${JSON.stringify(data)}`); + const _default = yargs.appArgv(this.ao.cfg.cli, true); + const res = await this.ao.selectShow(parseInt(data.id), data.e, false, false, { + ..._default, + dubLang: data.dubLang, + e: data.e + }); + if (res.isOk) { + for (const select of res.value) { + if (!(await this.ao.downloadEpisode(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 AnimeOnegaiHandler; \ No newline at end of file diff --git a/index.ts b/index.ts index 2d6b91f..baeed94 100644 --- a/index.ts +++ b/index.ts @@ -45,6 +45,15 @@ import update from './modules/module.updater'; type: 's' }, (argv.s === undefined ? argv.series : argv.s) as string); console.info('Added %s to the downloadArchive list', (argv.s === undefined ? argv.series : argv.s)); + } else if (argv.service === 'ao') { + if (argv.s === undefined) + return console.error('`-s` not found'); + addToArchive({ + service: 'hidive', + //type: argv.s === undefined ? 'srz' : 's' + type: 's' + }, (argv.s === undefined ? argv.series : argv.s) as string); + console.info('Added %s to the downloadArchive list', (argv.s === undefined ? argv.series : argv.s)); } } else if (argv.downloadArchive) { const ids = makeCommand(argv.service); @@ -52,7 +61,7 @@ import update from './modules/module.updater'; overrideArguments(cfg.cli, id); /* Reimport module to override appArgv */ Object.keys(require.cache).forEach(key => { - if (key.endsWith('crunchy.js') || key.endsWith('funi.js') || key.endsWith('hidive.js')) + if (key.endsWith('crunchy.js') || key.endsWith('funi.js') || key.endsWith('hidive.js') || key.endsWith('ao.js')) delete require.cache[key]; }); let service: ServiceClass; @@ -66,6 +75,9 @@ import update from './modules/module.updater'; case 'hidive': service = new (await import('./hidive')).default; break; + case 'ao': + service = new (await import('./ao')).default; + break; default: service = new (await import(`./${argv.service}`)).default; break; @@ -84,6 +96,9 @@ import update from './modules/module.updater'; case 'hidive': service = new (await import('./hidive')).default; break; + case 'ao': + service = new (await import('./ao')).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..a740972 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'|'ao'|'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 'ao': + services.push('AnimeOnegai'); + 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*, *AnimeOnegai*, *Hidive*, 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..cfd0c1a 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 { DownloadInfo } from '../@types/messageHandler'; +import { HLSCallback } from './hls-download'; 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' | 'ao'; 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..1156a28 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'|'ao'|'all'>, usage: string // -(-)${name} will be added for each command, demandOption?: true, transformer?: (value: T) => K @@ -572,7 +572,7 @@ const args: TAppArg[] = [ group: 'util', service: ['all'], type: 'string', - choices: ['funi', 'crunchy', 'hidive'], + choices: ['funi', 'crunchy', 'hidive', 'ao'], usage: '${service}', default: { default: '' @@ -689,7 +689,7 @@ const args: TAppArg[] = [ describe: 'Allows you to login with your token (Example on crunchy is Refresh Token/etp-rt cookie)', docDescribe: true, group: 'auth', - service: ['crunchy'], + service: ['crunchy', 'ao'], type: 'string', usage: '${token}', default: { diff --git a/modules/module.cfg-loader.ts b/modules/module.cfg-loader.ts index 6f39d8c..531fd99 100644 --- a/modules/module.cfg-loader.ts +++ b/modules/module.cfg-loader.ts @@ -20,13 +20,15 @@ const hdPflCfgFile = path.join(workingDir, 'config', 'hd_profile'); const sessCfgFile = { funi: path.join(workingDir, 'config', 'funi_sess'), cr: path.join(workingDir, 'config', 'cr_sess'), - hd: path.join(workingDir, 'config', 'hd_sess') + hd: path.join(workingDir, 'config', 'hd_sess'), + ao: path.join(workingDir, 'config', 'ao_sess') }; const stateFile = path.join(workingDir, 'config', 'guistate'); const tokenFile = { funi: path.join(workingDir, 'config', 'funi_token'), cr: path.join(workingDir, 'config', 'cr_token'), hd: path.join(workingDir, 'config', 'hd_token'), + ao: path.join(workingDir, 'config', 'ao_token'), hdNew: path.join(workingDir, 'config', 'hd_new_token') }; @@ -216,6 +218,25 @@ const saveCRToken = (data: Record) => { } }; +const loadAOToken = () => { + let token = loadYamlCfgFile(tokenFile.ao, true); + if(typeof token !== 'object' || token === null || Array.isArray(token)){ + token = {}; + } + return token; +}; + +const saveAOToken = (data: Record) => { + const cfgFolder = path.dirname(tokenFile.ao); + try{ + fs.ensureDirSync(cfgFolder); + fs.writeFileSync(`${tokenFile.ao}.yml`, yaml.stringify(data)); + } + catch(e){ + console.error('Can\'t save token file to disk!'); + } +}; + const loadHDSession = () => { let session = loadYamlCfgFile(sessCfgFile.hd, true); @@ -387,6 +408,8 @@ export { loadNewHDToken, saveHDProfile, loadHDProfile, + saveAOToken, + loadAOToken, getState, setState, writeYamlCfgFile, diff --git a/modules/module.downloadArchive.ts b/modules/module.downloadArchive.ts index bdac33c..e33b2a8 100644 --- a/modules/module.downloadArchive.ts +++ b/modules/module.downloadArchive.ts @@ -17,6 +17,9 @@ export type DataType = { hidive: { s: ItemType }, + ao: { + s: ItemType + }, crunchy: { srz: ItemType, s: ItemType @@ -32,6 +35,9 @@ const addToArchive = (kind: { } | { service: 'hidive', type: 's' +} | { + service: 'ao', + type: 's' }, ID: string) => { const data = loadData(); @@ -54,6 +60,15 @@ const addToArchive = (kind: { } ] }; + } else if (kind.service === 'ao') { + data['ao'] = { + s: [ + { + id: ID, + already: [] + } + ] + }; } else if (kind.service === 'crunchy') { data['crunchy'] = { s: ([] as ItemType).concat(kind.type === 's' ? { @@ -88,6 +103,9 @@ const downloaded = (kind: { } | { service: 'hidive', type: 's' +} | { + service: 'ao', + 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'|'ao') : 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..ce4d5df 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'|'ao'; 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'|'ao') { this.sessCfg = yamlCfg.sessCfgFile[type]; this.service = type; } @@ -72,7 +72,7 @@ export class Req { } // try do request try { - const res = await fetch(durl.toString(), options); + const res = await fetch(durl, options); if (!res.ok) { console.error(`${res.status}: ${res.statusText}`); const body = await res.text(); diff --git a/modules/module.langsData.ts b/modules/module.langsData.ts index d139229..e7192a6 100644 --- a/modules/module.langsData.ts +++ b/modules/module.langsData.ts @@ -4,6 +4,7 @@ export type LanguageItem = { cr_locale?: string, hd_locale?: string, new_hd_locale?: string, + ao_locale?: string, locale: string, code: string, name: string, @@ -17,9 +18,9 @@ const languages: LanguageItem[] = [ { cr_locale: 'en-US', new_hd_locale: 'en-US', hd_locale: 'English', funi_locale: 'enUS', locale: 'en', code: 'eng', name: 'English' }, { cr_locale: 'en-IN', locale: 'en-IN', code: 'eng', name: 'English (India)', }, { cr_locale: 'es-LA', new_hd_locale: 'es-MX', hd_locale: 'Spanish LatAm', funi_name: 'Spanish (LAS)', funi_name_lagacy: 'Spanish (Latin Am)', funi_locale: 'esLA', locale: 'es-419', code: 'spa', name: 'Spanish', language: 'Latin American Spanish' }, - { cr_locale: 'es-419',hd_locale: 'Spanish', locale: 'es-419', code: 'spa-419', name: 'Spanish', language: 'Latin American Spanish' }, + { cr_locale: 'es-419', ao_locale: 'es', hd_locale: 'Spanish', locale: 'es-419', code: 'spa-419', name: 'Spanish', language: 'Latin American Spanish' }, { cr_locale: 'es-ES', new_hd_locale: 'es-ES', hd_locale: 'Spanish Europe', locale: 'es-ES', code: 'spa-ES', name: 'Castilian', language: 'European Spanish' }, - { cr_locale: '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', ao_locale: 'pt', new_hd_locale: 'pt-BR', hd_locale: 'Portuguese', funi_name: 'Portuguese (Brazil)', funi_locale: 'ptBR', locale: 'pt-BR', code: 'por', name: 'Portuguese', language: 'Brazilian Portuguese' }, { cr_locale: 'pt-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' }, @@ -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', ao_locale: 'ja', hd_locale: 'Japanese', funi_locale: 'jaJP', locale: 'ja', code: 'jpn', name: 'Japanese' }, ]; // add en language names diff --git a/modules/module.req.ts b/modules/module.req.ts index 232abe0..3bb3821 100644 --- a/modules/module.req.ts +++ b/modules/module.req.ts @@ -27,7 +27,7 @@ const usefulCookies = { // req class Req { private sessCfg: string; - private service: 'cr'|'funi'|'hd'; + private service: 'cr'|'funi'|'hd'|'ao'; 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'|'ao') { this.sessCfg = yamlCfg.sessCfgFile[type]; this.service = type; } diff --git a/tsc.ts b/tsc.ts index 8b2eedd..c1078f1 100644 --- a/tsc.ts +++ b/tsc.ts @@ -34,6 +34,7 @@ const ignore = [ './config/updates.json$', './config/cr_token.yml$', './config/funi_token.yml$', + './config/ao_token.yml$', './config/new_hd_token.yml$', './config/hd_token.yml$', './config/hd_sess.yml$',