diff --git a/@types/crunchyEpisodeList.d.ts b/@types/crunchyEpisodeList.d.ts new file mode 100644 index 0000000..7a2b87b --- /dev/null +++ b/@types/crunchyEpisodeList.d.ts @@ -0,0 +1,122 @@ +export interface CrunchyEpisodeList { + __class__: string; + __href__: string; + __resource_key__: string; + __links__: Actions; + __actions__: Actions; + total: number; + items: Item[]; +} + +export interface Actions { +} + +export interface Item { + __class__: Class; + __href__: string; + __resource_key__: string; + __links__: Links; + __actions__: Actions; + id: string; + channel_id: ChannelID; + series_id: string; + series_title: string; + series_slug_title: string; + season_id: string; + season_title: string; + season_slug_title: string; + season_number: number; + episode: string; + episode_number: number | null; + sequence_number: number; + production_episode_id: string; + title: string; + slug_title: string; + description: string; + next_episode_id?: string; + next_episode_title?: string; + hd_flag: boolean; + is_mature: boolean; + mature_blocked: boolean; + episode_air_date: string; + is_subbed: boolean; + is_dubbed: boolean; + is_clip: boolean; + seo_title: string; + seo_description: string; + season_tags: string[]; + available_offline: boolean; + media_type: Class; + slug: string; + images: Images; + duration_ms: number; + ad_breaks: AdBreak[]; + is_premium_only: boolean; + listing_id: string; + subtitle_locales: SubtitleLocale[]; + playback?: string; + availability_notes: string; + available_date?: string; + hide_season_title?: boolean; + hide_season_number?: boolean; + isSelected?: boolean; + seq_id: string; +} + +export enum Class { + Episode = "episode", +} + +export interface Links { + ads: Ads; + "episode/channel": Ads; + "episode/next_episode"?: Ads; + "episode/season": Ads; + "episode/series": Ads; + streams?: Ads; +} + +export interface Ads { + href: string; +} + +export interface AdBreak { + type: AdBreakType; + offset_ms: number; +} + +export enum AdBreakType { + Midroll = "midroll", + Preroll = "preroll", +} + +export enum ChannelID { + Crunchyroll = "crunchyroll", +} + +export interface Images { + thumbnail: Array; +} + +export interface Thumbnail { + width: number; + height: number; + type: ThumbnailType; + source: string; +} + +export enum ThumbnailType { + Thumbnail = "thumbnail", +} + +export enum SubtitleLocale { + ArSA = "ar-SA", + DeDE = "de-DE", + EnUS = "en-US", + Es419 = "es-419", + EsES = "es-ES", + FrFR = "fr-FR", + ItIT = "it-IT", + PtBR = "pt-BR", + RuRU = "ru-RU", +} diff --git a/@types/crunchySearch.d.ts b/@types/crunchySearch.d.ts new file mode 100644 index 0000000..4a9e5b8 --- /dev/null +++ b/@types/crunchySearch.d.ts @@ -0,0 +1,175 @@ +// Generated by https://quicktype.io + +export interface CrunchySearch { + __class__: string; + __href__: string; + __resource_key__: string; + __links__: CrunchySearchLinks; + __actions__: Actions; + total: number; + items: CrunchySearchItem[]; +} + +export interface Actions { +} + +export interface CrunchySearchLinks { + continuation?: Continuation; +} + +export interface Continuation { + href: string; +} + +export interface CrunchySearchItem { + __class__: string; + __href__: string; + __resource_key__: string; + __links__: CrunchySearchLinks; + __actions__: Actions; + type: string; + total: number; + items: ItemItem[]; +} + +export interface ItemItem { + __actions__: Actions; + __class__: Class; + __href__: string; + __links__: PurpleLinks; + channel_id: ChannelID; + description: string; + external_id: string; + id: string; + images: Images; + linked_resource_key: string; + new: boolean; + new_content: boolean; + promo_description: string; + promo_title: string; + search_metadata: SearchMetadata; + series_metadata?: SeriesMetadata; + slug: string; + slug_title: string; + title: string; + type: ItemType; + episode_metadata?: EpisodeMetadata; + playback?: string; + isSelected?: boolean; + season_number?: string; + is_premium_only?: boolean; + hide_metadata?: boolean; + seq_id?: string; + f_num?: string; + s_num?: string; + ep_num?: string; + last_public?: string; + subtitle_locales?: string[]; + availability_notes?: string +} + +export enum Class { + Panel = "panel", +} + +export interface PurpleLinks { + resource: Continuation; + "resource/channel": Continuation; + "episode/season"?: Continuation; + "episode/series"?: Continuation; + streams?: Continuation; +} + +export enum ChannelID { + Crunchyroll = "crunchyroll", +} + +export interface EpisodeMetadata { + ad_breaks: AdBreak[]; + availability_notes: string; + available_offline: boolean; + duration_ms: number; + episode: string; + episode_air_date: string; + episode_number: number; + is_clip: boolean; + is_dubbed: boolean; + is_mature: boolean; + is_premium_only: boolean; + is_subbed: boolean; + mature_blocked: boolean; + maturity_ratings: string[]; + season_id: string; + season_number: number; + season_slug_title: string; + season_title: string; + sequence_number: number; + series_id: string; + series_slug_title: string; + series_title: string; + subtitle_locales: string[]; + tenant_categories?: TenantCategory[]; + available_date?: string; + free_available_date?: string; +} + +export interface AdBreak { + offset_ms: number; + type: AdBreakType; +} + +export enum AdBreakType { + Midroll = "midroll", + Preroll = "preroll", +} + +export enum TenantCategory { + Action = "Action", + Drama = "Drama", + SciFi = "Sci-Fi", +} + +export interface Images { + poster_tall?: Array; + poster_wide?: Array; + thumbnail?: Array; +} + +export interface PosterTall { + height: number; + source: string; + type: PosterTallType; + width: number; +} + +export enum PosterTallType { + PosterTall = "poster_tall", + PosterWide = "poster_wide", + Thumbnail = "thumbnail", +} + +export interface SearchMetadata { + score: number; +} + +export interface SeriesMetadata { + availability_notes: string; + episode_count: number; + extended_description: string; + is_dubbed: boolean; + is_mature: boolean; + is_simulcast: boolean; + is_subbed: boolean; + mature_blocked: boolean; + maturity_ratings: string[]; + season_count: number; + tenant_categories: TenantCategory[]; +} + +export enum ItemType { + Episode = "episode", + Series = "series", + Season = 'season', + MovieListing = 'movie_listing', + Movie = 'Movie' +} diff --git a/@types/crunchyTypes.ts b/@types/crunchyTypes.ts new file mode 100644 index 0000000..3fdcc80 --- /dev/null +++ b/@types/crunchyTypes.ts @@ -0,0 +1,26 @@ +export type CrunchyEpMeta = { + mediaId: string, + seasonTitle: string, + episodeNumber: string, + episodeTitle: string, + playback?: string +} + +export type ParseItem = { + isSelected?: boolean, + type?: string, + id: string, + title: string, + playback?: string, + season_number?: number|string, + is_premium_only?: boolean, + hide_metadata?: boolean, + seq_id?: string, + f_num?: string, + s_num?: string + external_id?: string, + ep_num?: string + last_public?: string, + subtitle_locales?: string[], + availability_notes?: string +} \ No newline at end of file diff --git a/@types/sei-helper.d.ts b/@types/sei-helper.d.ts index 2cd732e..2fd29c1 100644 --- a/@types/sei-helper.d.ts +++ b/@types/sei-helper.d.ts @@ -1,5 +1,15 @@ declare module 'sei-helper' { - export async function question(qStr: string): string; + export async function question(qStr: string): Promise; export function cleanupFilename(str: string): string; export function exec(str: string, str1: string, str2: string); + export const cookie: { + parse: (data: Record) => Record + } + export function formatTime(time: number): string } \ No newline at end of file diff --git a/crunchy-beta.js b/crunchy-beta.ts similarity index 90% rename from crunchy-beta.js rename to crunchy-beta.ts index 88d6014..c8b2d9d 100644 --- a/crunchy-beta.js +++ b/crunchy-beta.ts @@ -1,40 +1,44 @@ #!/usr/bin/env node // build-in -const path = require('path'); -const fs = require('fs-extra'); +import path from 'path'; +import fs from 'fs-extra'; // package program -const packageJson = require('./package.json'); +import packageJson from './package.json'; console.log(`\n=== Crunchyroll Beta Downloader NX ${packageJson.version} ===\n`); // plugins -const shlp = require('sei-helper'); -const m3u8 = require('m3u8-parsed'); -const streamdl = require('hls-download'); +import shlp from 'sei-helper'; +import m3u8 from 'm3u8-parsed'; +import streamdl from 'hls-download'; // custom modules -const fontsData = require('./crunchy/modules/module.fontsData'); -const langsData = require('./crunchy/modules/module.langsData'); -const yamlCfg = require('./crunchy/modules/module.cfg-loader'); -const yargs = require('./crunchy/modules/module.app-args'); -const epsFilter = require('./crunchy/modules/module.eps-filter'); -const appMux = require('./crunchy/modules/module.muxing'); +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 epsFilter from './modules/module.eps-filter'; +import Merger from './modules/module.merger'; // new-cfg paths const cfg = yamlCfg.loadCfg(); let token = yamlCfg.loadCRToken(); -let cmsToken = {}; +let cmsToken: { + cms?: Record +} = {}; // args -const appYargs = new yargs(cfg.cli, langsData, true); -const argv = appYargs.appArgv(); +const argv = yargs.appArgv(cfg.cli) argv.appstore = {}; // load req -const { domain, api } = require('./crunchy/modules/module.api-urls'); -const reqModule = require('./crunchy/modules/module.req'); -const req = new reqModule.Req(domain, argv, true); +import { domain, api } from './modules/module.api-urls'; +import * as reqModule from './modules/module.req'; +import { CrunchySearch, ItemItem, ItemType } from './@types/crunchySearch'; +import { CrunchyEpisodeList } from './@types/crunchyEpisodeList'; +import { CrunchyEpMeta, ParseItem } from './@types/crunchyTypes'; +const req = new reqModule.Req(domain, argv); // select (async () => { @@ -84,7 +88,7 @@ const req = new reqModule.Req(domain, argv, true); async function getFonts(){ console.log('[INFO] Downloading fonts...'); for(const f of Object.keys(fontsData.fonts)){ - const fontFile = fontsData.fonts[f]; + const fontFile = fontsData.fonts[f as fontsData.AvailableFonts]; const fontLoc = path.join(cfg.dir.fonts, fontFile); if(fs.existsSync(fontLoc) && fs.statSync(fontLoc).size != 0){ console.log(`[INFO] ${f} (${fontFile}) already downloaded!`); @@ -99,8 +103,8 @@ async function getFonts(){ } catch(e){} const fontUrl = fontsData.root + fontFile; - const getFont = await req.getData(fontUrl, { useProxy: true, binary: true }); - if(getFont.ok){ + const getFont = await req.getData(fontUrl, { binary: true }); + if(getFont.ok && getFont.res){ fs.writeFileSync(fontLoc, getFont.res.body); console.log(`[INFO] Downloaded: ${f} (${fontFile})`); } @@ -114,22 +118,21 @@ async function getFonts(){ // auth method async function doAuth(){ - const iLogin = argv.user ? argv.user : await shlp.question('[Q] LOGIN/EMAIL'); - const iPsswd = argv.pass ? argv.pass : await shlp.question('[Q] PASSWORD '); + const iLogin = await shlp.question('[Q] LOGIN/EMAIL'); + const iPsswd = await shlp.question('[Q] PASSWORD '); const authData = new URLSearchParams({ 'username': iLogin, 'password': iPsswd, 'grant_type': 'password', 'scope': 'offline_access' }).toString(); - const authReqOpts = { + const authReqOpts: reqModule.Params = { method: 'POST', headers: api.beta_authHeaderMob, - body: authData, - useProxy: true + body: authData }; const authReq = await req.getData(api.beta_auth, authReqOpts); - if(!authReq.ok){ + if(!authReq.ok || !authReq.res){ console.log('[ERROR] Authentication failed!'); return; } @@ -152,7 +155,7 @@ async function getProfile(){ useProxy: true }; const profileReq = await req.getData(api.beta_profile, profileReqOptions); - if(!profileReq.ok){ + if(!profileReq.ok || !profileReq.res){ console.log('[ERROR] Get profile failed!'); return; } @@ -166,14 +169,13 @@ async function doAnonymousAuth(){ 'grant_type': 'client_id', 'scope': 'offline_access', }).toString(); - const authReqOpts = { + const authReqOpts: reqModule.Params = { method: 'POST', headers: api.beta_authHeaderMob, - body: authData, - useProxy: true + body: authData }; const authReq = await req.getData(api.beta_auth, authReqOpts); - if(!authReq.ok){ + if(!authReq.ok || !authReq.res){ console.log('[ERROR] Authentication failed!'); return; } @@ -196,14 +198,13 @@ async function refreshToken(){ 'grant_type': 'refresh_token', 'scope': 'offline_access' }).toString(); - const authReqOpts = { + const authReqOpts: reqModule.Params = { method: 'POST', headers: api.beta_authHeaderMob, - body: authData, - useProxy: true + body: authData }; const authReq = await req.getData(api.beta_auth, authReqOpts); - if(!authReq.ok){ + if(!authReq.ok || !authReq.res){ console.log('[ERROR] Authentication failed!'); return; } @@ -232,12 +233,12 @@ async function getCMStoken(){ useProxy: true }; const cmsTokenReq = await req.getData(api.beta_cmsToken, cmsTokenReqOpts); - if(!cmsTokenReq.ok){ + if(!cmsTokenReq.ok || !cmsTokenReq.res){ console.log('[ERROR] Authentication CMS token failed!'); return; } cmsToken = JSON.parse(cmsTokenReq.res.body); - console.log('[INFO] Your Country: %s\n', cmsToken.cms.bucket.split('/')[1]); + console.log('[INFO] Your Country: %s\n', cmsToken.cms?.bucket.split('/')[1]); } async function getCmsData(){ @@ -257,8 +258,8 @@ async function getCmsData(){ 'Key-Pair-Id': cmsToken.cms.key_pair_id, }), ].join(''); - const indexReq = await req.getData(indexReqOpts, { useProxy: true }); - if(!indexReq.ok){ + const indexReq = await req.getData(indexReqOpts); + if(!indexReq.ok || ! indexReq.res){ console.log('[ERROR] Get CMS index FAILED!'); return; } @@ -277,18 +278,18 @@ async function doSearch(){ useProxy: true }; const searchParams = new URLSearchParams({ - q: argv.search, - n: 5, - start: argv.page ? (parseInt(argv.page)-1)*5 : 0, + q: argv.search as string, + n: "5", + start: argv.page ? `${(argv.page-1)*5}` : "0", type: argv['search-type'], locale: argv['search-locale'], }).toString(); let searchReq = await req.getData(`${api.beta_search}?${searchParams}`, searchReqOpts); - if(!searchReq.ok){ + if(!searchReq.ok || ! searchReq.res){ console.log('[ERROR] Search FAILED!'); return; } - let searchResults = JSON.parse(searchReq.res.body); + let searchResults = JSON.parse(searchReq.res.body) as CrunchySearch; if(searchResults.total < 1){ console.log('[INFO] Nothing Found!'); return; @@ -300,9 +301,9 @@ async function doSearch(){ 'episode': 'Found episodes' }; for(let search_item of searchResults.items){ - console.log('[INFO] %s:', searchTypesInfo[search_item.type]); + console.log('[INFO] %s:', searchTypesInfo[search_item.type as keyof typeof searchTypesInfo]); // calculate pages - let itemPad = parseInt(new URL(search_item.__href__, domain.api_beta).searchParams.get('start')); + let itemPad = parseInt(new URL(search_item.__href__, domain.api_beta).searchParams.get('start') || ''); let pageCur = itemPad > 0 ? Math.ceil(itemPad/5) + 1 : 1; let pageMax = Math.ceil(search_item.total/5); // pages per category @@ -322,16 +323,17 @@ async function doSearch(){ } } -async function parseObject(item, pad, getSeries, getMovieListing){ +async function parseObject(item: ParseItem, pad?: number, getSeries?: boolean, getMovieListing?: boolean){ if(argv.debug){ console.log(item); } - pad = typeof pad == 'number' ? pad : 2; - getSeries = typeof getSeries == 'boolean' ? getSeries : true; - getMovieListing = typeof getMovieListing == 'boolean' ? getMovieListing : true; - item.isSelected = typeof item.isSelected == 'boolean' ? item.isSelected : false; + pad = pad || 2; + getSeries = getSeries === undefined ? true : getSeries; + getMovieListing = getMovieListing === undefined ? true : getMovieListing; + item.isSelected = item.isSelected === undefined ? false : item.isSelected; if(!item.type){ - item.type = item.__class__; + console.log('[INFO] Unable to parse type for %s. Defaulted to %s', item.id, ItemType.Episode) + item.type = ItemType.Episode; } const oTypes = { 'series': 'Z', // SRZ @@ -396,7 +398,7 @@ async function parseObject(item, pad, getSeries, getMovieListing){ const showObjectBooleans = oBooleans.length > 0 && !iMetadata.hide_metadata ? true : false; // make obj ids let objects_ids = []; - objects_ids.push(oTypes[item.type] + ':' + item.id); + objects_ids.push(oTypes[item.type as keyof typeof oTypes] + ':' + item.id); if(item.seq_id){ objects_ids.unshift(item.seq_id); } @@ -453,10 +455,10 @@ async function parseObject(item, pad, getSeries, getMovieListing){ } } -async function getSeriesById(pad, hideSeriesTitle){ +async function getSeriesById(pad?: number, hideSeriesTitle?: boolean){ // parse - pad = typeof pad == 'number' ? pad : 0; - hideSeriesTitle = typeof hideSeriesTitle == 'boolean' ? hideSeriesTitle : false; + pad = pad || 0; + hideSeriesTitle = hideSeriesTitle !== undefined ? hideSeriesTitle : false; // check token if(!cmsToken.cms){ console.log('[ERROR] Authentication required!'); @@ -480,7 +482,7 @@ async function getSeriesById(pad, hideSeriesTitle){ cmsToken.cms.bucket, '/seasons?', new URLSearchParams({ - 'series_id': argv.series, + 'series_id': argv.series as string, 'Policy': cmsToken.cms.policy, 'Signature': cmsToken.cms.signature, 'Key-Pair-Id': cmsToken.cms.key_pair_id, @@ -488,8 +490,8 @@ async function getSeriesById(pad, hideSeriesTitle){ ].join(''); // reqs if(!hideSeriesTitle){ - const seriesReq = await req.getData(seriesReqOpts, {useProxy: true}); - if(!seriesReq.ok){ + const seriesReq = await req.getData(seriesReqOpts); + if(!seriesReq.ok || !seriesReq.res){ console.log('[ERROR] Series Request FAILED!'); return; } @@ -497,8 +499,8 @@ async function getSeriesById(pad, hideSeriesTitle){ await parseObject(seriesData, pad, false); } // seasons list - const seriesSeasonListReq = await req.getData(seriesSeasonListReqOpts, {useProxy: true}); - if(!seriesSeasonListReq.ok){ + const seriesSeasonListReq = await req.getData(seriesSeasonListReqOpts); + if(!seriesSeasonListReq.ok || !seriesSeasonListReq.res){ console.log('[ERROR] Series Request FAILED!'); return; } @@ -513,8 +515,8 @@ async function getSeriesById(pad, hideSeriesTitle){ } } -async function getMovieListingById(pad){ - pad = typeof pad == 'number' ? pad : 2; +async function getMovieListingById(pad?: number){ + pad = pad || 2; if(!cmsToken.cms){ console.log('[ERROR] Authentication required!'); return; @@ -524,14 +526,14 @@ async function getMovieListingById(pad){ cmsToken.cms.bucket, '/movies?', new URLSearchParams({ - 'movie_listing_id': argv['movie-listing'], + 'movie_listing_id': argv['movie-listing'] as string, 'Policy': cmsToken.cms.policy, 'Signature': cmsToken.cms.signature, 'Key-Pair-Id': cmsToken.cms.key_pair_id, }), ].join(''); - const movieListingReq = await req.getData(movieListingReqOpts, {useProxy: true}); - if(!movieListingReq.ok){ + const movieListingReq = await req.getData(movieListingReqOpts); + if(!movieListingReq.ok || !movieListingReq.res){ console.log('[ERROR] Movie Listing Request FAILED!'); return; } @@ -558,11 +560,11 @@ async function getNewlyAdded(){ }; const newlyAddedParams = new URLSearchParams({ sort_by: 'newly_added', - n: 25, - start: argv.page ? (parseInt(argv.page)-1)*25 : 0, + n: "25", + start: (argv.page ? (argv.page-1)*25 : 0).toString(), }).toString(); let newlyAddedReq = await req.getData(`${api.beta_browse}?${newlyAddedParams}`, newlyAddedReqOpts); - if(!newlyAddedReq.ok){ + if(!newlyAddedReq.ok || !newlyAddedReq.res){ console.log('[ERROR] Get newly added FAILED!'); return; } @@ -572,7 +574,7 @@ async function getNewlyAdded(){ await parseObject(i, 2); } // calculate pages - let itemPad = parseInt(new URL(newlyAddedResults.__href__, domain.api_beta).searchParams.get('start')); + let itemPad = parseInt(new URL(newlyAddedResults.__href__, domain.api_beta).searchParams.get('start') as string); let pageCur = itemPad > 0 ? Math.ceil(itemPad/5) + 1 : 1; let pageMax = Math.ceil(newlyAddedResults.total/5); console.log(` [INFO] Total results: ${newlyAddedResults.total} (Page: ${pageCur}/${pageMax})`); @@ -596,8 +598,8 @@ async function getSeasonById(){ 'Key-Pair-Id': cmsToken.cms.key_pair_id, }), ].join(''); - const showInfoReq = await req.getData(showInfoReqOpts, {useProxy: true}); - if(!showInfoReq.ok){ + const showInfoReq = await req.getData(showInfoReqOpts); + if(!showInfoReq.ok || !showInfoReq.res){ console.log('[ERROR] Show Request FAILED!'); return; } @@ -608,20 +610,23 @@ async function getSeasonById(){ cmsToken.cms.bucket, '/episodes?', new URLSearchParams({ - 'season_id': argv.season, + 'season_id': argv.season as string, 'Policy': cmsToken.cms.policy, 'Signature': cmsToken.cms.signature, 'Key-Pair-Id': cmsToken.cms.key_pair_id, }), ].join(''); - const reqEpsList = await req.getData(reqEpsListOpts, {useProxy: true}); - if(!reqEpsList.ok){ + const reqEpsList = await req.getData(reqEpsListOpts); + if(!reqEpsList.ok || !reqEpsList.res){ console.log('[ERROR] Episode List Request FAILED!'); return; } - let episodeList = JSON.parse(reqEpsList.res.body); + let episodeList = JSON.parse(reqEpsList.res.body) as CrunchyEpisodeList; - const epNumList = { ep: [], sp: 0 }; + const epNumList: { + ep: number[], + sp: number + } = { ep: [], sp: 0 }; const epNumLen = epsFilter.epNumLen; if(episodeList.total < 1){ @@ -631,7 +636,7 @@ async function getSeasonById(){ const doEpsFilter = new epsFilter.doFilter(); const selEps = doEpsFilter.checkFilter(argv.episode); - const selectedMedia = []; + const selectedMedia: CrunchyEpMeta[] = []; episodeList.items.forEach((item) => { item.hide_season_title = true; @@ -644,7 +649,7 @@ async function getSeasonById(){ item.season_title = 'NO_TITLE'; } // set data - const epMeta = { + const epMeta: CrunchyEpMeta = { mediaId: item.id, seasonTitle: item.season_title, episodeNumber: item.episode, @@ -685,7 +690,7 @@ async function getSeasonById(){ } if(selectedMedia.length > 1){ - argv.appstore.isBatch = true; + (argv.appstore as Record).isBatch = true; } console.log(); diff --git a/crunchy/modules/module.req.js b/crunchy/modules/module.req.js deleted file mode 100644 index b101427..0000000 --- a/crunchy/modules/module.req.js +++ /dev/null @@ -1,285 +0,0 @@ -const path = require('path'); -const fs = require('fs-extra'); - -const shlp = require('sei-helper'); -const got = require('got'); -const cookieFile = require('./module.cookieFile'); -const yamlCfg = require('./module.cfg-loader'); -const curlReq = require('./module.curl-req'); - -// set usable cookies -const usefulCookies = { - auth: [ - 'etp_rt', - 'c_visitor', - ], - sess: [ - 'session_id', - ], -}; - -// req -const Req = class { - constructor(domain, argv, is_beta){ - // settings and cookies - this.is_beta = Boolean(is_beta); - this.loadSessTxt = this.is_beta ? false : true; - // main cfg - this.domain = domain; - this.argv = argv; - // session cfg - this.sessCfg = yamlCfg.sessCfgFile, - this.session = this.is_beta ? {} : yamlCfg.loadCRSession(); - this.cfgDir = yamlCfg.cfgFolder; - this.curl = false; - } - async getData (durl, params) { - params = params || {}; - // options - let options = { - method: params.method ? params.method : 'GET', - headers: { - 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64; rv:90.0) Gecko/20100101 Firefox/90.0', - }, - }; - // additional params - if(params.headers){ - options.headers = {...options.headers, ...params.headers}; - } - if(options.method == 'POST'){ - options.headers['Content-Type'] = 'application/x-www-form-urlencoded'; - } - if(params.body){ - options.body = params.body; - } - if(params.binary == true){ - options.responseType = 'buffer'; - } - if(typeof params.followRedirect == 'boolean'){ - options.followRedirect = params.followRedirect; - } - // check if cookies.txt exists - const sessTxtFile = path.join(this.cfgDir, 'cookies.txt'); - if(!this.is_beta && this.loadSessTxt && fs.existsSync(sessTxtFile)){ - const cookiesTxtName = path.basename(sessTxtFile); - try{ - // console.log(`[INFO] Loading custom ${cookiesTxtName} file...`); - const netcookie = fs.readFileSync(sessTxtFile, 'utf8'); - fs.unlinkSync(sessTxtFile); - this.setNewCookie('', true, netcookie); - } - catch(e){ - console.log(`[ERROR] Cannot load ${cookiesTxtName} file!`); - } - } - this.loadSessTxt = false; - // proxy - if(params.useProxy && this.argv.proxy && this.argv.curl){ - try{ - options.curlProxy = buildProxy(this.argv.proxy); - options.curlProxyAuth = this.argv['proxy-auth']; - } - catch(e){ - console.log(`[WARN] Not valid proxy URL${e.input?' ('+e.input+')':''}!`); - console.log('[WARN] Skipping...\n'); - this.argv.proxy = false; - } - } - // if auth - let cookie = []; - const loc = new URL(durl); - if(!this.is_beta && Object.values(this.domain).includes(loc.origin)){ - for(let uCookie of usefulCookies.auth){ - const checkedCookie = this.checkCookieVal(this.session[uCookie]); - if(checkedCookie){ - cookie.push(uCookie); - } - } - for(let uCookie of usefulCookies.sess){ - if(this.checkSessId(this.session[uCookie]) && !this.argv.nosess){ - cookie.push(uCookie); - } - } - if(!params.skipCookies){ - cookie.push('c_locale'); - options.headers.Cookie = shlp.cookie.make({ - ...{ c_locale : { value: 'enUS' } }, - ...this.session, - }, cookie); - } - } - // avoid cloudflare protection - if(loc.origin == this.domain.www){ - options.minVersion = 'TLSv1.3'; - options.maxVersion = 'TLSv1.3'; - options.http2 = true; - } - // debug - options.hooks = { - beforeRequest: [ - (options) => { - if(this.argv.debug){ - console.log('[DEBUG] GOT OPTIONS:'); - console.log(options); - } - } - ] - }; - if(this.argv.debug){ - options.curlDebug = true; - } - // try do request - try { - let res; - if(this.curl && this.argv.curl && Object.values(this.domain).includes(loc.origin)){ - res = await curlReq(this.curl, durl.toString(), options, this.cfgDir); - } - else{ - res = await got(durl.toString(), options); - } - if(!this.is_beta && !params.skipCookies && res && res.headers && res.headers['set-cookie']){ - this.setNewCookie(res.headers['set-cookie'], false); - for(let uCookie of usefulCookies.sess){ - if(this.session[uCookie] && this.argv.nosess){ - this.argv.nosess = false; - } - } - } - return { - ok: true, - res, - }; - } - catch(error){ - if(error.response && error.response.statusCode && error.response.statusMessage){ - console.log(`[ERROR] ${error.name} ${error.response.statusCode}: ${error.response.statusMessage}`); - } - else{ - console.log(`[ERROR] ${error.name}: ${error.code || error.message}`); - } - if(error.response && !error.res){ - error.res = error.response; - const docTitle = error.res.body.match(/(.*)<\/title>/); - if(error.res.body && docTitle){ - console.log('[ERROR]', docTitle[1]); - } - } - if(error.res && error.res.body && error.response.statusCode - && error.response.statusCode != 404 && error.response.statusCode != 403){ - console.log('[ERROR] Body:', error.res.body); - } - return { - ok: false, - error, - }; - } - } - setNewCookie(setCookie, isAuth, fileData){ - let cookieUpdated = [], lastExp = 0; - setCookie = fileData ? cookieFile(fileData) : shlp.cookie.parse(setCookie); - for(let cookieName of Object.keys(setCookie)){ - if(setCookie[cookieName] && setCookie[cookieName].value && setCookie[cookieName].value == 'deleted'){ - delete setCookie[cookieName]; - } - } - for(let uCookie of usefulCookies.auth){ - const cookieForceExp = 60*60*24*7; - const cookieExpCur = this.session[uCookie] ? this.session[uCookie] : { expires: 0 }; - const cookieExp = new Date(cookieExpCur.expires).getTime() - cookieForceExp; - if(cookieExp > lastExp){ - lastExp = cookieExp; - } - } - for(let uCookie of usefulCookies.auth){ - if(!setCookie[uCookie]){ - continue; - } - if(isAuth || setCookie[uCookie] && Date.now() > lastExp){ - this.session[uCookie] = setCookie[uCookie]; - cookieUpdated.push(uCookie); - } - } - for(let uCookie of usefulCookies.sess){ - if(!setCookie[uCookie]){ - continue; - } - if( - isAuth - || this.argv.nosess && setCookie[uCookie] - || setCookie[uCookie] && !this.checkSessId(this.session[uCookie]) - ){ - const sessionExp = 60*60; - this.session[uCookie] = setCookie[uCookie]; - this.session[uCookie].expires = new Date(Date.now() + sessionExp*1000); - this.session[uCookie]['Max-Age'] = sessionExp.toString(); - cookieUpdated.push(uCookie); - } - } - if(cookieUpdated.length > 0){ - if(this.argv.debug){ - console.log('[SAVING FILE]',`${this.sessCfg}.yml`); - } - yamlCfg.saveCRSession(this.session); - console.log(`[INFO] Cookies were updated! (${cookieUpdated.join(', ')})\n`); - } - } - checkCookieVal(chcookie){ - return chcookie - && chcookie.toString() == '[object Object]' - && typeof chcookie.value == 'string' - ? true : false; - } - checkSessId(session_id){ - if(session_id && typeof session_id.expires == 'string'){ - session_id.expires = new Date(session_id.expires); - } - return session_id - && session_id.toString() == '[object Object]' - && typeof session_id.expires == 'object' - && Date.now() < new Date(session_id.expires).getTime() - && typeof session_id.value == 'string' - ? true : false; - } - uuidv4(){ - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - let r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); - } -}; - -function buildProxy(proxyBaseUrl, proxyAuth){ - if(!proxyBaseUrl.match(/^(https?|socks4|socks5):/)){ - proxyBaseUrl = 'http://' + proxyBaseUrl; - } - - let proxyCfg = new URL(proxyBaseUrl); - let proxyStr = `${proxyCfg.protocol}//`; - - if(typeof proxyCfg.hostname != 'string' || proxyCfg.hostname == ''){ - throw new Error('[ERROR] Hostname and port required for proxy!'); - } - - if(proxyAuth && typeof proxyAuth == 'string' && proxyAuth.match(':')){ - proxyCfg.username = proxyAuth.split(':')[0]; - proxyCfg.password = proxyAuth.split(':')[1]; - proxyStr += `${proxyCfg.username}:${proxyCfg.password}@`; - } - - proxyStr += proxyCfg.hostname; - - if(!proxyCfg.port && proxyCfg.protocol == 'http:'){ - proxyStr += ':80'; - } - else if(!proxyCfg.port && proxyCfg.protocol == 'https:'){ - proxyStr += ':443'; - } - - return proxyStr; -} - -module.exports = { - buildProxy, - usefulCookies, - Req, -}; diff --git a/funi.ts b/funi.ts index 622df0c..434b61d 100644 --- a/funi.ts +++ b/funi.ts @@ -55,7 +55,7 @@ let title = '', stDlPath: Subtitle[] = []; // main -(async () => { +export default (async () => { // load binaries cfg.bin = await yamlCfg.loadBinCfg(); // select mode @@ -65,13 +65,13 @@ let title = '', else if(argv.search){ searchShow(); } - else if(argv.s && argv.s > 0){ + else if(argv.s && !isNaN(parseInt(argv.s)) && parseInt(argv.s) > 0){ getShow(); } else{ appYargs.showHelp(); } -})(); +}); // auth async function auth(){ @@ -157,7 +157,7 @@ async function getShow(){ sort_direction: string, title_id: number, language?: string - } = { limit: -1, sort: 'order', sort_direction: 'ASC', title_id: argv.s as number }; + } = { limit: -1, sort: 'order', sort_direction: 'ASC', title_id: parseInt(argv.s as string) }; if(argv.alt){ qs.language = 'English'; } const episodesData = await getData({ baseUrl: api_host, diff --git a/index.ts b/index.ts index 79a7e47..d46f088 100644 --- a/index.ts +++ b/index.ts @@ -1 +1,17 @@ -import { appArgv } from "./modules/module.app-args"; \ No newline at end of file +import { appArgv } from "./modules/module.app-args"; +import * as yamlCfg from './modules/module.cfg-loader'; + +import funimation from './funi' + +(async () => { + const cfg = yamlCfg.loadCfg(); + + const argv = appArgv(cfg.cli); + + if (argv.service === 'funi') { + await funimation() + } else if (argv.service === 'crunchy') { + + } + +})() \ No newline at end of file diff --git a/modules/module.api-urls.ts b/modules/module.api-urls.ts index 1701160..fc42175 100644 --- a/modules/module.api-urls.ts +++ b/modules/module.api-urls.ts @@ -1,3 +1,5 @@ +import { Headers } from "got/dist/source"; + // api domains const domain = { www: 'https://www.crunchyroll.com', @@ -28,8 +30,8 @@ export type APIType = { beta_search: string beta_browse: string beta_cms: string, - beta_authHeader: HeadersInit, - beta_authHeaderMob: HeadersInit + beta_authHeader: Headers, + beta_authHeaderMob: Headers } // api urls diff --git a/modules/module.app-args.ts b/modules/module.app-args.ts index eb3033a..a8fd11e 100644 --- a/modules/module.app-args.ts +++ b/modules/module.app-args.ts @@ -119,9 +119,9 @@ const appArgv = (cfg: { default: parseDefault<number>('videoLayer', 7), type: 'number' }) - .option('server', { + .option('x', { group: groups.dl, - alias: 'x', + alias: 'server', describe: 'Select server', choices: [1, 2, 3, 4], default: parseDefault<number>('nServer', 1), @@ -260,7 +260,14 @@ const appArgv = (cfg: { describe: 'Show this help', type: 'boolean' }) + .option('service', { + group: groups.util, + describe: 'Set the service to use', + choices: ['funi', 'chrunchy'], + demandOption: true + }) .parseSync(); + return argv; } const showHelp = yargs.showHelp; diff --git a/modules/module.cfg-loader.ts b/modules/module.cfg-loader.ts index 8e677f0..8188b42 100644 --- a/modules/module.cfg-loader.ts +++ b/modules/module.cfg-loader.ts @@ -192,4 +192,17 @@ const saveFuniToken = (data: { } }; -export { loadBinCfg, loadCfg, loadFuniToken, saveFuniToken, saveCRSession, saveCRToken, loadCRToken, loadCRSession }; \ No newline at end of file +const cfgDir = path.join(workingDir, 'config'); + +export { + loadBinCfg, + loadCfg, + loadFuniToken, + saveFuniToken, + saveCRSession, + saveCRToken, + loadCRToken, + loadCRSession, + sessCfgFile, + cfgDir +}; \ No newline at end of file diff --git a/modules/module.cookieFile.ts b/modules/module.cookieFile.ts index 80fc8fc..5a45392 100644 --- a/modules/module.cookieFile.ts +++ b/modules/module.cookieFile.ts @@ -23,4 +23,4 @@ const parse = (data: string) => { return res; }; -module.exports = parse; +export default parse; diff --git a/modules/module.curl-req.ts b/modules/module.curl-req.ts index 5df060a..2cbc1b5 100644 --- a/modules/module.curl-req.ts +++ b/modules/module.curl-req.ts @@ -1,14 +1,15 @@ // build-in import child_process from 'child_process'; import fs from 'fs-extra'; +import { Headers } from 'got'; import path from 'path'; export type CurlOptions = { - headers?: Record<string, string>, + headers?: Headers, curlProxy?: boolean, curlProxyAuth?: string, minVersion?: string, - http2?: string, + http2?: boolean, body?: unknown, curlDebug?: boolean } | undefined; @@ -158,4 +159,4 @@ function uuidv4() { }); } -module.exports = curlReq; +export default curlReq; diff --git a/modules/module.fontsData.ts b/modules/module.fontsData.ts index 7f53978..9da695e 100644 --- a/modules/module.fontsData.ts +++ b/modules/module.fontsData.ts @@ -89,5 +89,7 @@ function fontMime(fontFile: string){ return 'application/octet-stream'; } +export type AvailableFonts = keyof typeof fonts; + // output export { root, fonts, assFonts, fontMime }; diff --git a/modules/module.getdata.ts b/modules/module.getdata.ts index e40a2e3..16322c8 100644 --- a/modules/module.getdata.ts +++ b/modules/module.getdata.ts @@ -29,6 +29,7 @@ export type Options = { dinstid?: boolean|string, debug?: boolean } +// TODO convert to class const getData = async <T = string>(options: Options) => { const regionHeaders = {}; diff --git a/modules/module.req.ts b/modules/module.req.ts new file mode 100644 index 0000000..661cad1 --- /dev/null +++ b/modules/module.req.ts @@ -0,0 +1,261 @@ +import path from 'path'; +import fs from 'fs-extra'; + +import shlp from 'sei-helper'; +import got, { Headers, Method, Options, ReadError, Response } from 'got'; +import cookieFile from './module.cookieFile'; +import * as yamlCfg from './module.cfg-loader'; +import curlReq from './module.curl-req'; + +export type Params = { + method?: Method, + headers?: Headers, + body?: string | Buffer, + binary?: boolean, + followRedirect?: boolean +} + +// set usable cookies +const usefulCookies = { + auth: [ + 'etp_rt', + 'c_visitor', + ], + sess: [ + 'session_id', + ], +}; + +// req +class Req { + private sessCfg = yamlCfg.sessCfgFile; + private session: Record<string, { + value: string; + expires: Date; + path: string; + domain: string; + secure: boolean; + 'Max-Age'?: string + }> = {}; + private cfgDir = yamlCfg.cfgDir + private curl: boolean|string = false; + + constructor(private domain: Record<string, unknown>, private argv: Record<string, unknown>) {} + async getData<T = string> (durl: string, params?: Params) { + params = params || {}; + // options + let options: Options & { + minVersion?: string, + maxVersion?: string + curlDebug?: boolean + } = { + method: params.method ? params.method : 'GET', + headers: { + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64; rv:90.0) Gecko/20100101 Firefox/90.0', + }, + }; + // additional params + if(params.headers){ + options.headers = {...options.headers, ...params.headers}; + } + if(options.method == 'POST'){ + (options.headers as Headers)['Content-Type'] = 'application/x-www-form-urlencoded'; + } + if(params.body){ + options.body = params.body; + } + if(params.binary == true){ + options.responseType = 'buffer'; + } + if(typeof params.followRedirect == 'boolean'){ + options.followRedirect = params.followRedirect; + } + // Removed Proxy support since it was only partialy supported + /*if(params.useProxy && this.argv.proxy && this.argv.curl){ + try{ + options.curlProxy = buildProxy(this.argv.proxy); + options.curlProxyAuth = this.argv['proxy-auth']; + } + catch(e){ + console.log(`[WARN] Not valid proxy URL${e.input?' ('+e.input+')':''}!`); + console.log('[WARN] Skipping...\n'); + this.argv.proxy = false; + } + }*/ + // if auth + let cookie = []; + const loc = new URL(durl); + // avoid cloudflare protection + if(loc.origin == this.domain.www){ + options.minVersion = 'TLSv1.3'; + options.maxVersion = 'TLSv1.3'; + options.http2 = true; + } + // debug + options.hooks = { + beforeRequest: [ + (options) => { + if(this.argv.debug){ + console.log('[DEBUG] GOT OPTIONS:'); + console.log(options); + } + } + ] + }; + if(this.argv.debug){ + options.curlDebug = true; + } + // try do request + try { + let res: Response<T>; + if(this.curl && this.argv.curl && Object.values(this.domain).includes(loc.origin)){ + res = await curlReq(typeof this.curl === 'boolean' ? '' : this.curl, durl.toString(), options, this.cfgDir) as unknown as Response<T>; + } + else{ + res = await got(durl.toString(), options) as unknown as Response<T>; + } + return { + ok: true, + res + }; + } + catch(_error){ + const error = _error as { + name: string + } & ReadError & { + res: Response<unknown> + } + if(error.response && error.response.statusCode && error.response.statusMessage){ + console.log(`[ERROR] ${error.name} ${error.response.statusCode}: ${error.response.statusMessage}`); + } + else{ + console.log(`[ERROR] ${error.name}: ${error.code || error.message}`); + } + if(error.response && !error.res){ + error.res = error.response; + const docTitle = (error.res.body as string).match(/<title>(.*)<\/title>/); + if(error.res.body && docTitle){ + console.log('[ERROR]', docTitle[1]); + } + } + if(error.res && error.res.body && error.response.statusCode + && error.response.statusCode != 404 && error.response.statusCode != 403){ + console.log('[ERROR] Body:', error.res.body); + } + return { + ok: false, + error, + }; + } + } + setNewCookie(setCookie: Record<string, string>, isAuth: boolean, fileData?: string){ + let cookieUpdated = [], lastExp = 0; + console.trace('Type of setCookie:', typeof setCookie, setCookie) + const parsedCookie = fileData ? cookieFile(fileData) : shlp.cookie.parse(setCookie); + for(let cookieName of Object.keys(parsedCookie)){ + if(parsedCookie[cookieName] && parsedCookie[cookieName].value && parsedCookie[cookieName].value == 'deleted'){ + delete parsedCookie[cookieName]; + } + } + for(let uCookie of usefulCookies.auth){ + const cookieForceExp = 60*60*24*7; + const cookieExpCur = this.session[uCookie] ? this.session[uCookie] : { expires: 0 }; + const cookieExp = new Date(cookieExpCur.expires).getTime() - cookieForceExp; + if(cookieExp > lastExp){ + lastExp = cookieExp; + } + } + for(let uCookie of usefulCookies.auth){ + if(!parsedCookie[uCookie]){ + continue; + } + if(isAuth || parsedCookie[uCookie] && Date.now() > lastExp){ + this.session[uCookie] = parsedCookie[uCookie]; + cookieUpdated.push(uCookie); + } + } + for(let uCookie of usefulCookies.sess){ + if(!parsedCookie[uCookie]){ + continue; + } + if( + isAuth + || this.argv.nosess && parsedCookie[uCookie] + || parsedCookie[uCookie] && !this.checkSessId(this.session[uCookie]) + ){ + const sessionExp = 60*60; + this.session[uCookie] = parsedCookie[uCookie]; + this.session[uCookie].expires = new Date(Date.now() + sessionExp*1000); + this.session[uCookie]['Max-Age'] = sessionExp.toString(); + cookieUpdated.push(uCookie); + } + } + if(cookieUpdated.length > 0){ + if(this.argv.debug){ + console.log('[SAVING FILE]',`${this.sessCfg}.yml`); + } + yamlCfg.saveCRSession(this.session); + console.log(`[INFO] Cookies were updated! (${cookieUpdated.join(', ')})\n`); + } + } + checkCookieVal(chcookie: Record<string, string>){ + return chcookie + && chcookie.toString() == '[object Object]' + && typeof chcookie.value == 'string' + ? true : false; + } + checkSessId(session_id: Record<string, unknown>){ + if(session_id && typeof session_id.expires == 'string'){ + session_id.expires = new Date(session_id.expires); + } + return session_id + && session_id.toString() == '[object Object]' + && typeof session_id.expires == 'object' + && Date.now() < new Date(session_id.expires as any).getTime() + && typeof session_id.value == 'string' + ? true : false; + } + uuidv4(){ + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + let r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + } + }; + + function buildProxy(proxyBaseUrl: string, proxyAuth: string){ + if(!proxyBaseUrl.match(/^(https?|socks4|socks5):/)){ + proxyBaseUrl = 'http://' + proxyBaseUrl; + } + + let proxyCfg = new URL(proxyBaseUrl); + let proxyStr = `${proxyCfg.protocol}//`; + + if(typeof proxyCfg.hostname != 'string' || proxyCfg.hostname == ''){ + throw new Error('[ERROR] Hostname and port required for proxy!'); + } + + if(proxyAuth && typeof proxyAuth == 'string' && proxyAuth.match(':')){ + proxyCfg.username = proxyAuth.split(':')[0]; + proxyCfg.password = proxyAuth.split(':')[1]; + proxyStr += `${proxyCfg.username}:${proxyCfg.password}@`; + } + + proxyStr += proxyCfg.hostname; + + if(!proxyCfg.port && proxyCfg.protocol == 'http:'){ + proxyStr += ':80'; + } + else if(!proxyCfg.port && proxyCfg.protocol == 'https:'){ + proxyStr += ':443'; + } + + return proxyStr; + } + + export { + buildProxy, + usefulCookies, + Req, + }; + \ No newline at end of file