1538 lines
No EOL
54 KiB
TypeScript
1538 lines
No EOL
54 KiB
TypeScript
// build-in
|
|
import path from 'path';
|
|
import fs from 'fs-extra';
|
|
|
|
// package program
|
|
import packageJson from './package.json';
|
|
// plugins
|
|
import shlp from 'sei-helper';
|
|
import m3u8 from 'm3u8-parsed';
|
|
import streamdl from 'hls-download';
|
|
|
|
// custom 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 Merger, { Font, MergerInput, SubtitleInput } from './modules/module.merger';
|
|
|
|
export type sxItem = {
|
|
language: langsData.LanguageItem,
|
|
path: string,
|
|
file: string
|
|
title: string,
|
|
fonts: Font[]
|
|
}
|
|
|
|
// args
|
|
|
|
// load req
|
|
import { domain, api } from './modules/module.api-urls';
|
|
import * as reqModule from './modules/module.req';
|
|
import { CrunchySearch } from './@types/crunchySearch';
|
|
import { CrunchyEpisodeList, Item } from './@types/crunchyEpisodeList';
|
|
import { CrunchyDownloadOptions, CrunchyEpMeta, CrunchyMuxOptions, CurnchyMultiDownload, DownloadedMedia, ParseItem, SeriesSearch, SeriesSearchItem } from './@types/crunchyTypes';
|
|
import { ObjectInfo } from './@types/objectInfo';
|
|
import parseFileName, { Variable } from './modules/module.filename';
|
|
import { PlaybackData } from './@types/playbackData';
|
|
import { downloaded } from './modules/module.downloadArchive';
|
|
import parseSelect from './modules/module.parseSelect';
|
|
import { AvailableFilenameVars, getDefault } from './modules/module.args';
|
|
import { AuthData, AuthResponse, Episode, ResponseBase, SearchData, SearchResponse, SearchResponseItem } from './@types/messageHandler';
|
|
import { ServiceClass } from './@types/serviceClassInterface';
|
|
|
|
export default class Crunchy implements ServiceClass {
|
|
public cfg: yamlCfg.ConfigObject;
|
|
private token: Record<string, any>;
|
|
private req: reqModule.Req;
|
|
private cmsToken: {
|
|
cms?: Record<string, string>
|
|
} = {};
|
|
|
|
constructor(private debug = false) {
|
|
this.cfg = yamlCfg.loadCfg();
|
|
this.token = yamlCfg.loadCRToken();
|
|
this.req = new reqModule.Req(domain, false, false);
|
|
}
|
|
|
|
public checkToken(): boolean {
|
|
return Object.keys(this.cmsToken.cms ?? {}).length > 0;
|
|
}
|
|
|
|
public async cli() {
|
|
console.log(`\n=== Multi Downloader NX ${packageJson.version} ===\n`);
|
|
const argv = yargs.appArgv(this.cfg.cli);
|
|
this.debug = argv.debug ?? false;
|
|
|
|
// load binaries
|
|
this.cfg.bin = await yamlCfg.loadBinCfg();
|
|
if (argv.allDubs) {
|
|
argv.dubLang = langsData.dubLanguageCodes;
|
|
}
|
|
// select mode
|
|
if (argv.silentAuth && !argv.auth) {
|
|
await this.doAuth({
|
|
username: argv.username ?? await shlp.question('[Q] LOGIN/EMAIL'),
|
|
password: argv.password ?? await shlp.question('[Q] PASSWORD ')
|
|
});
|
|
}
|
|
if(argv.dlFonts){
|
|
await this.getFonts();
|
|
}
|
|
else if(argv.auth){
|
|
await this.doAuth({
|
|
username: argv.username ?? await shlp.question('[Q] LOGIN/EMAIL'),
|
|
password: argv.password ?? await shlp.question('[Q] PASSWORD ')
|
|
});
|
|
}
|
|
else if(argv.cmsindex){
|
|
await this.refreshToken();
|
|
await this.getCmsData();
|
|
}
|
|
else if(argv.new){
|
|
await this.refreshToken();
|
|
await this.getNewlyAdded();
|
|
}
|
|
else if(argv.search && argv.search.length > 2){
|
|
await this.refreshToken();
|
|
await this.doSearch({ ...argv, search: argv.search as string });
|
|
}
|
|
else if(argv.series && argv.series.match(/^[0-9A-Z]{9}$/)){
|
|
await this.refreshToken();
|
|
await this.logSeriesById(argv.series as string);
|
|
const selected = await this.downloadFromSeriesID(argv.series, { ...argv });
|
|
if (selected.isOk) {
|
|
for (const select of selected.value) {
|
|
if (!(await this.downloadEpisode(select, {...argv, skipsubs: false }))) {
|
|
console.log(`[ERROR] Unable to download selected episode ${select.episodeNumber}`);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
else if(argv['movie-listing'] && argv['movie-listing'].match(/^[0-9A-Z]{9}$/)){
|
|
await this.refreshToken();
|
|
await this.logMovieListingById(argv['movie-listing'] as string);
|
|
}
|
|
else if(argv.s && argv.s.match(/^[0-9A-Z]{9}$/)){
|
|
await this.refreshToken();
|
|
if (argv.dubLang.length > 1) {
|
|
console.log('[INFO] One show can only be downloaded with one dub. Use --srz instead.');
|
|
}
|
|
argv.dubLang = [argv.dubLang[0]];
|
|
const selected = await this.getSeasonById(argv.s, argv.numbers, argv.e, argv.but, argv.all);
|
|
if (selected.isOk) {
|
|
for (const select of selected.value) {
|
|
if (!(await this.downloadEpisode(select, {...argv, skipsubs: false }))) {
|
|
console.log(`[ERROR] Unable to download selected episode ${select.episodeNumber}`);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
else if(argv.e){
|
|
await this.refreshToken();
|
|
const selected = await this.getObjectById(argv.e, false);
|
|
for (const select of selected as Partial<CrunchyEpMeta>[]) {
|
|
if (!(await this.downloadEpisode(select as CrunchyEpMeta, {...argv, skipsubs: false }))) {
|
|
console.log(`[ERROR] Unable to download selected episode ${select.episodeNumber}`);
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
else{
|
|
yargs.showHelp();
|
|
}
|
|
}
|
|
|
|
public async getFonts() {
|
|
console.log('[INFO] Downloading fonts...');
|
|
const fonts = Object.values(fontsData.fontFamilies).reduce((pre, curr) => pre.concat(curr));
|
|
for(const f of fonts) {
|
|
const fontLoc = path.join(this.cfg.dir.fonts, f);
|
|
if(fs.existsSync(fontLoc) && fs.statSync(fontLoc).size != 0){
|
|
console.log(`[INFO] ${f} already downloaded!`);
|
|
}
|
|
else{
|
|
const fontFolder = path.dirname(fontLoc);
|
|
if(fs.existsSync(fontLoc) && fs.statSync(fontLoc).size == 0){
|
|
fs.unlinkSync(fontLoc);
|
|
}
|
|
try{
|
|
fs.ensureDirSync(fontFolder);
|
|
}
|
|
catch(e){
|
|
console.log();
|
|
}
|
|
const fontUrl = fontsData.root + f;
|
|
const getFont = await this.req.getData<Buffer>(fontUrl, { binary: true });
|
|
if(getFont.ok && getFont.res){
|
|
fs.writeFileSync(fontLoc, getFont.res.body);
|
|
console.log(`[INFO] Downloaded: ${f}`);
|
|
}
|
|
else{
|
|
console.log(`[WARN] Failed to download: ${f}`);
|
|
}
|
|
}
|
|
}
|
|
console.log('[INFO] All required fonts downloaded!');
|
|
}
|
|
|
|
public async doAuth(data: AuthData): Promise<AuthResponse> {
|
|
const authData = new URLSearchParams({
|
|
'username': data.username,
|
|
'password': data.password,
|
|
'grant_type': 'password',
|
|
'scope': 'offline_access'
|
|
}).toString();
|
|
const authReqOpts: reqModule.Params = {
|
|
method: 'POST',
|
|
headers: api.beta_authHeaderMob,
|
|
body: authData
|
|
};
|
|
const authReq = await this.req.getData(api.beta_auth, authReqOpts);
|
|
if(!authReq.ok || !authReq.res){
|
|
console.log('[ERROR] Authentication failed!');
|
|
return { isOk: false, reason: new Error('Authentication failed') };
|
|
}
|
|
this.token = JSON.parse(authReq.res.body);
|
|
this.token.expires = new Date(Date.now() + this.token.expires_in);
|
|
yamlCfg.saveCRToken(this.token);
|
|
await this.getProfile();
|
|
console.log('[INFO] Your Country: %s', this.token.country);
|
|
return { isOk: true, value: undefined };
|
|
}
|
|
|
|
public async doAnonymousAuth(){
|
|
const authData = new URLSearchParams({
|
|
'grant_type': 'client_id',
|
|
'scope': 'offline_access',
|
|
}).toString();
|
|
const authReqOpts: reqModule.Params = {
|
|
method: 'POST',
|
|
headers: api.beta_authHeaderMob,
|
|
body: authData
|
|
};
|
|
const authReq = await this.req.getData(api.beta_auth, authReqOpts);
|
|
if(!authReq.ok || !authReq.res){
|
|
console.log('[ERROR] Authentication failed!');
|
|
return;
|
|
}
|
|
this.token = JSON.parse(authReq.res.body);
|
|
this.token.expires = new Date(Date.now() + this.token.expires_in);
|
|
yamlCfg.saveCRToken(this.token);
|
|
}
|
|
|
|
public async getProfile() : Promise<boolean> {
|
|
if(!this.token.access_token){
|
|
console.log('[ERROR] No access token!');
|
|
return false;
|
|
}
|
|
const profileReqOptions = {
|
|
headers: {
|
|
Authorization: `Bearer ${this.token.access_token}`,
|
|
},
|
|
useProxy: true
|
|
};
|
|
const profileReq = await this.req.getData(api.beta_profile, profileReqOptions);
|
|
if(!profileReq.ok || !profileReq.res){
|
|
console.log('[ERROR] Get profile failed!');
|
|
return false;
|
|
}
|
|
const profile = JSON.parse(profileReq.res.body);
|
|
console.log('[INFO] USER: %s (%s)', profile.username, profile.email);
|
|
return true;
|
|
}
|
|
|
|
public async refreshToken(){
|
|
if(!this.token.access_token && !this.token.refresh_token || this.token.access_token && !this.token.refresh_token){
|
|
await this.doAnonymousAuth();
|
|
}
|
|
else{
|
|
if(Date.now() > new Date(this.token.expires).getTime()){
|
|
console.log('[WARN] The token has expired compleatly. I will try to refresh the token anyway, but you might have to reauth.');
|
|
}
|
|
const authData = new URLSearchParams({
|
|
'refresh_token': this.token.refresh_token,
|
|
'grant_type': 'refresh_token',
|
|
'scope': 'offline_access'
|
|
}).toString();
|
|
const authReqOpts: reqModule.Params = {
|
|
method: 'POST',
|
|
headers: api.beta_authHeaderMob,
|
|
body: authData
|
|
};
|
|
const authReq = await this.req.getData(api.beta_auth, authReqOpts);
|
|
if(!authReq.ok || !authReq.res){
|
|
console.log('[ERROR] Authentication failed!');
|
|
return;
|
|
}
|
|
this.token = JSON.parse(authReq.res.body);
|
|
this.token.expires = new Date(Date.now() + this.token.expires_in);
|
|
yamlCfg.saveCRToken(this.token);
|
|
}
|
|
if(this.token.refresh_token){
|
|
await this.getProfile();
|
|
}
|
|
else{
|
|
console.log('[INFO] USER: Anonymous');
|
|
}
|
|
await this.getCMStoken();
|
|
}
|
|
|
|
public async getCMStoken(){
|
|
if(!this.token.access_token){
|
|
console.log('[ERROR] No access token!');
|
|
return;
|
|
}
|
|
const cmsTokenReqOpts = {
|
|
headers: {
|
|
Authorization: `Bearer ${this.token.access_token}`,
|
|
},
|
|
useProxy: true
|
|
};
|
|
const cmsTokenReq = await this.req.getData(api.beta_cmsToken, cmsTokenReqOpts);
|
|
if(!cmsTokenReq.ok || !cmsTokenReq.res){
|
|
console.log('[ERROR] Authentication CMS token failed!');
|
|
return;
|
|
}
|
|
this.cmsToken = JSON.parse(cmsTokenReq.res.body);
|
|
console.log('[INFO] Your Country: %s\n', this.cmsToken.cms?.bucket.split('/')[1]);
|
|
}
|
|
|
|
public async getCmsData(){
|
|
// check token
|
|
if(!this.cmsToken.cms){
|
|
console.log('[ERROR] Authentication required!');
|
|
return;
|
|
}
|
|
// opts
|
|
const indexReqOpts = [
|
|
api.beta_cms,
|
|
this.cmsToken.cms.bucket,
|
|
'/index?',
|
|
new URLSearchParams({
|
|
'Policy': this.cmsToken.cms.policy,
|
|
'Signature': this.cmsToken.cms.signature,
|
|
'Key-Pair-Id': this.cmsToken.cms.key_pair_id,
|
|
}),
|
|
].join('');
|
|
const indexReq = await this.req.getData(indexReqOpts);
|
|
if(!indexReq.ok || ! indexReq.res){
|
|
console.log('[ERROR] Get CMS index FAILED!');
|
|
return;
|
|
}
|
|
console.log(JSON.parse(indexReq.res.body));
|
|
}
|
|
|
|
public async doSearch(data: SearchData): Promise<SearchResponse>{
|
|
if(!this.token.access_token){
|
|
console.log('[ERROR] Authentication required!');
|
|
return { isOk: false, reason: new Error('Not authenticated') };
|
|
}
|
|
const searchReqOpts = {
|
|
headers: {
|
|
Authorization: `Bearer ${this.token.access_token}`,
|
|
},
|
|
useProxy: true
|
|
};
|
|
const searchParams = new URLSearchParams({
|
|
q: data.search,
|
|
n: '5',
|
|
start: data.page ? `${(data.page-1)*5}` : '0',
|
|
type: data['search-type'] ?? getDefault('search-type', this.cfg.cli),
|
|
locale: data['search-locale'] ?? getDefault('search-locale', this.cfg.cli),
|
|
}).toString();
|
|
const searchReq = await this.req.getData(`${api.beta_search}?${searchParams}`, searchReqOpts);
|
|
if(!searchReq.ok || ! searchReq.res){
|
|
console.log('[ERROR] Search FAILED!');
|
|
return { isOk: false, reason: new Error('Search failed. No more information provided') };
|
|
}
|
|
const searchResults = JSON.parse(searchReq.res.body) as CrunchySearch;
|
|
if(searchResults.total < 1){
|
|
console.log('[INFO] Nothing Found!');
|
|
return { isOk: true, value: [] };
|
|
}
|
|
|
|
const searchTypesInfo = {
|
|
'top_results': 'Top results',
|
|
'series': 'Found series',
|
|
'movie_listing': 'Found movie lists',
|
|
'episode': 'Found episodes'
|
|
};
|
|
for(const search_item of searchResults.items){
|
|
console.log('[INFO] %s:', searchTypesInfo[search_item.type as keyof typeof searchTypesInfo]);
|
|
// calculate pages
|
|
const itemPad = parseInt(new URL(search_item.__href__, domain.api_beta).searchParams.get('start') || '');
|
|
const pageCur = itemPad > 0 ? Math.ceil(itemPad/5) + 1 : 1;
|
|
const pageMax = Math.ceil(search_item.total/5);
|
|
// pages per category
|
|
if(search_item.total < 1){
|
|
console.log(' [INFO] Nothing Found...');
|
|
}
|
|
if(search_item.total > 0){
|
|
if(pageCur > pageMax){
|
|
console.log(' [INFO] Last page is %s...', pageMax);
|
|
continue;
|
|
}
|
|
for(const item of search_item.items){
|
|
await this.logObject(item);
|
|
}
|
|
console.log(` [INFO] Total results: ${search_item.total} (Page: ${pageCur}/${pageMax})`);
|
|
}
|
|
}
|
|
const toSend = searchResults.items.filter(a => a.type === 'series' || a.type === 'movie_listing');
|
|
return { isOk: true, value: toSend.map(a => {
|
|
return a.items.map((a): SearchResponseItem => {
|
|
fs.writeFileSync('../test.json', JSON.stringify(a.images.poster_tall, null, 2));
|
|
const images = (a.images.poster_tall ?? [[ { source: '/notFound.png' } ]])[0];
|
|
return {
|
|
id: a.id,
|
|
image: images[Math.floor(images.length / 2)].source,
|
|
name: a.title,
|
|
rating: -1,
|
|
desc: a.description
|
|
};
|
|
});
|
|
}).reduce((pre, cur) => pre.concat(cur))};
|
|
}
|
|
|
|
public async logObject(item: ParseItem, pad?: number, getSeries?: boolean, getMovieListing?: boolean){
|
|
if(this.debug){
|
|
console.log(item);
|
|
}
|
|
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__;
|
|
}
|
|
const oTypes = {
|
|
'series': 'Z', // SRZ
|
|
'season': 'S', // VOL
|
|
'episode': 'E', // EPI
|
|
'movie_listing': 'F', // FLM
|
|
'movie': 'M', // MED
|
|
};
|
|
// check title
|
|
item.title = item.title != '' ? item.title : 'NO_TITLE';
|
|
// static data
|
|
const oMetadata = [],
|
|
oBooleans = [],
|
|
tMetadata = item.type + '_metadata',
|
|
iMetadata = (Object.prototype.hasOwnProperty.call(item, tMetadata) ? item[tMetadata as keyof ParseItem] : item) as Record<string, any>,
|
|
iTitle = [ item.title ];
|
|
// set object booleans
|
|
if(iMetadata.duration_ms){
|
|
oBooleans.push(shlp.formatTime(iMetadata.duration_ms/1000));
|
|
}
|
|
if(iMetadata.is_simulcast){
|
|
oBooleans.push('SIMULCAST');
|
|
}
|
|
if(iMetadata.is_mature){
|
|
oBooleans.push('MATURE');
|
|
}
|
|
if(iMetadata.is_subbed){
|
|
oBooleans.push('SUB');
|
|
}
|
|
if(iMetadata.is_dubbed){
|
|
oBooleans.push('DUB');
|
|
}
|
|
if(item.playback && item.type != 'movie_listing'){
|
|
oBooleans.push('STREAM');
|
|
}
|
|
// set object metadata
|
|
if(iMetadata.season_count){
|
|
oMetadata.push(`Seasons: ${iMetadata.season_count}`);
|
|
}
|
|
if(iMetadata.episode_count){
|
|
oMetadata.push(`EPs: ${iMetadata.episode_count}`);
|
|
}
|
|
if(item.season_number && !iMetadata.hide_season_title && !iMetadata.hide_season_number){
|
|
oMetadata.push(`Season: ${item.season_number}`);
|
|
}
|
|
if(item.type == 'episode'){
|
|
if(iMetadata.episode){
|
|
iTitle.unshift(iMetadata.episode);
|
|
}
|
|
if(!iMetadata.hide_season_title && iMetadata.season_title){
|
|
iTitle.unshift(iMetadata.season_title);
|
|
}
|
|
}
|
|
if(item.is_premium_only){
|
|
iTitle[0] = `☆ ${iTitle[0]}`;
|
|
}
|
|
// display metadata
|
|
if(item.hide_metadata){
|
|
iMetadata.hide_metadata = item.hide_metadata;
|
|
}
|
|
const showObjectMetadata = oMetadata.length > 0 && !iMetadata.hide_metadata ? true : false;
|
|
const showObjectBooleans = oBooleans.length > 0 && !iMetadata.hide_metadata ? true : false;
|
|
// make obj ids
|
|
const objects_ids = [];
|
|
objects_ids.push(oTypes[item.type as keyof typeof oTypes] + ':' + item.id);
|
|
if(item.seq_id){
|
|
objects_ids.unshift(item.seq_id);
|
|
}
|
|
if(item.f_num){
|
|
objects_ids.unshift(item.f_num);
|
|
}
|
|
if(item.s_num){
|
|
objects_ids.unshift(item.s_num);
|
|
}
|
|
if(item.external_id){
|
|
objects_ids.push(item.external_id);
|
|
}
|
|
if(item.ep_num){
|
|
objects_ids.push(item.ep_num);
|
|
}
|
|
// show entry
|
|
console.log(
|
|
'%s%s[%s] %s%s%s',
|
|
''.padStart(item.isSelected ? pad-1 : pad, ' '),
|
|
item.isSelected ? '✓' : '',
|
|
objects_ids.join('|'),
|
|
iTitle.join(' - '),
|
|
showObjectMetadata ? ` (${oMetadata.join(', ')})` : '',
|
|
showObjectBooleans ? ` [${oBooleans.join(', ')}]` : '',
|
|
|
|
);
|
|
if(item.last_public){
|
|
console.log(''.padStart(pad+1, ' '), '- Last updated:', item.last_public);
|
|
}
|
|
if(item.subtitle_locales){
|
|
iMetadata.subtitle_locales = item.subtitle_locales;
|
|
}
|
|
if(iMetadata.subtitle_locales && iMetadata.subtitle_locales.length > 0){
|
|
console.log(
|
|
'%s- Subtitles: %s',
|
|
''.padStart(pad + 2, ' '),
|
|
langsData.parseSubtitlesArray(iMetadata.subtitle_locales)
|
|
);
|
|
}
|
|
if(item.availability_notes){
|
|
console.log(
|
|
'%s- Availability notes: %s',
|
|
''.padStart(pad + 2, ' '),
|
|
item.availability_notes.replace(/\[[^\]]*\]?/gm, '')
|
|
);
|
|
}
|
|
if(item.type == 'series' && getSeries){
|
|
await this.logSeriesById(item.id, pad, true);
|
|
console.log();
|
|
}
|
|
if(item.type == 'movie_listing' && getMovieListing){
|
|
await this.logMovieListingById(item.id, pad+2);
|
|
console.log();
|
|
}
|
|
}
|
|
|
|
public async logSeriesById(id: string, pad?: number, hideSeriesTitle?: boolean){
|
|
// parse
|
|
pad = pad || 0;
|
|
hideSeriesTitle = hideSeriesTitle !== undefined ? hideSeriesTitle : false;
|
|
// check token
|
|
if(!this.cmsToken.cms){
|
|
console.log('[ERROR] Authentication required!');
|
|
return;
|
|
}
|
|
// opts
|
|
const seriesReqOpts = [
|
|
api.beta_cms,
|
|
this.cmsToken.cms.bucket,
|
|
'/series/',
|
|
id,
|
|
'?',
|
|
new URLSearchParams({
|
|
'Policy': this.cmsToken.cms.policy,
|
|
'Signature': this.cmsToken.cms.signature,
|
|
'Key-Pair-Id': this.cmsToken.cms.key_pair_id,
|
|
}),
|
|
].join('');
|
|
const seriesSeasonListReqOpts = [
|
|
api.beta_cms,
|
|
this.cmsToken.cms.bucket,
|
|
'/seasons?',
|
|
new URLSearchParams({
|
|
'series_id': id,
|
|
'Policy': this.cmsToken.cms.policy,
|
|
'Signature': this.cmsToken.cms.signature,
|
|
'Key-Pair-Id': this.cmsToken.cms.key_pair_id,
|
|
}),
|
|
].join('');
|
|
// reqs
|
|
if(!hideSeriesTitle){
|
|
const seriesReq = await this.req.getData(seriesReqOpts);
|
|
if(!seriesReq.ok || !seriesReq.res){
|
|
console.log('[ERROR] Series Request FAILED!');
|
|
return;
|
|
}
|
|
const seriesData = JSON.parse(seriesReq.res.body);
|
|
await this.logObject(seriesData, pad, false);
|
|
}
|
|
// seasons list
|
|
const seriesSeasonListReq = await this.req.getData(seriesSeasonListReqOpts);
|
|
if(!seriesSeasonListReq.ok || !seriesSeasonListReq.res){
|
|
console.log('[ERROR] Series Request FAILED!');
|
|
return;
|
|
}
|
|
// parse data
|
|
const seasonsList = JSON.parse(seriesSeasonListReq.res.body) as SeriesSearch;
|
|
if(seasonsList.total < 1){
|
|
console.log('[INFO] Series is empty!');
|
|
return;
|
|
}
|
|
for(const item of seasonsList.items){
|
|
await this.logObject(item, pad+2);
|
|
}
|
|
}
|
|
|
|
public async logMovieListingById(id: string, pad?: number){
|
|
pad = pad || 2;
|
|
if(!this.cmsToken.cms){
|
|
console.log('[ERROR] Authentication required!');
|
|
return;
|
|
}
|
|
const movieListingReqOpts = [
|
|
api.beta_cms,
|
|
this.cmsToken.cms.bucket,
|
|
'/movies?',
|
|
new URLSearchParams({
|
|
'movie_listing_id': id,
|
|
'Policy': this.cmsToken.cms.policy,
|
|
'Signature': this.cmsToken.cms.signature,
|
|
'Key-Pair-Id': this.cmsToken.cms.key_pair_id,
|
|
}),
|
|
].join('');
|
|
const movieListingReq = await this.req.getData(movieListingReqOpts);
|
|
if(!movieListingReq.ok || !movieListingReq.res){
|
|
console.log('[ERROR] Movie Listing Request FAILED!');
|
|
return;
|
|
}
|
|
const movieListing = JSON.parse(movieListingReq.res.body);
|
|
if(movieListing.total < 1){
|
|
console.log('[INFO] Movie Listing is empty!');
|
|
return;
|
|
}
|
|
for(const item of movieListing.items){
|
|
this.logObject(item, pad);
|
|
}
|
|
}
|
|
|
|
public async getNewlyAdded(page?: number){
|
|
if(!this.token.access_token){
|
|
console.log('[ERROR] Authentication required!');
|
|
return;
|
|
}
|
|
const newlyAddedReqOpts = {
|
|
headers: {
|
|
Authorization: `Bearer ${this.token.access_token}`,
|
|
},
|
|
useProxy: true
|
|
};
|
|
const newlyAddedParams = new URLSearchParams({
|
|
sort_by: 'newly_added',
|
|
n: '25',
|
|
start: (page ? (page-1)*25 : 0).toString(),
|
|
}).toString();
|
|
const newlyAddedReq = await this.req.getData(`${api.beta_browse}?${newlyAddedParams}`, newlyAddedReqOpts);
|
|
if(!newlyAddedReq.ok || !newlyAddedReq.res){
|
|
console.log('[ERROR] Get newly added FAILED!');
|
|
return;
|
|
}
|
|
const newlyAddedResults = JSON.parse(newlyAddedReq.res.body);
|
|
console.log('[INFO] Newly added:');
|
|
for(const i of newlyAddedResults.items){
|
|
await this.logObject(i, 2);
|
|
}
|
|
// calculate pages
|
|
const itemPad = parseInt(new URL(newlyAddedResults.__href__, domain.api_beta).searchParams.get('start') as string);
|
|
const pageCur = itemPad > 0 ? Math.ceil(itemPad/5) + 1 : 1;
|
|
const pageMax = Math.ceil(newlyAddedResults.total/5);
|
|
console.log(` [INFO] Total results: ${newlyAddedResults.total} (Page: ${pageCur}/${pageMax})`);
|
|
}
|
|
|
|
public async getSeasonById(id: string, numbers: number, e: string|undefined, but: boolean, all: boolean) : Promise<ResponseBase<CrunchyEpMeta[]>> {
|
|
if(!this.cmsToken.cms){
|
|
console.log('[ERROR] Authentication required!');
|
|
return { isOk: false, reason: new Error('Authentication required') };
|
|
}
|
|
|
|
const showInfoReqOpts = [
|
|
api.beta_cms,
|
|
this.cmsToken.cms.bucket,
|
|
'/seasons/',
|
|
id,
|
|
'?',
|
|
new URLSearchParams({
|
|
'Policy': this.cmsToken.cms.policy,
|
|
'Signature': this.cmsToken.cms.signature,
|
|
'Key-Pair-Id': this.cmsToken.cms.key_pair_id,
|
|
}),
|
|
].join('');
|
|
const showInfoReq = await this.req.getData(showInfoReqOpts);
|
|
if(!showInfoReq.ok || !showInfoReq.res){
|
|
console.log('[ERROR] Show Request FAILED!');
|
|
return { isOk: false, reason: new Error('Show request failed. No more information provided.') };
|
|
}
|
|
const showInfo = JSON.parse(showInfoReq.res.body);
|
|
this.logObject(showInfo, 0);
|
|
const reqEpsListOpts = [
|
|
api.beta_cms,
|
|
this.cmsToken.cms.bucket,
|
|
'/episodes?',
|
|
new URLSearchParams({
|
|
'season_id': id,
|
|
'Policy': this.cmsToken.cms.policy,
|
|
'Signature': this.cmsToken.cms.signature,
|
|
'Key-Pair-Id': this.cmsToken.cms.key_pair_id,
|
|
}),
|
|
].join('');
|
|
const reqEpsList = await this.req.getData(reqEpsListOpts);
|
|
if(!reqEpsList.ok || !reqEpsList.res){
|
|
console.log('[ERROR] Episode List Request FAILED!');
|
|
return { isOk: false, reason: new Error('Episode List request failed. No more information provided.') };
|
|
}
|
|
const episodeList = JSON.parse(reqEpsList.res.body) as CrunchyEpisodeList;
|
|
|
|
const epNumList: {
|
|
ep: number[],
|
|
sp: number
|
|
} = { ep: [], sp: 0 };
|
|
const epNumLen = numbers;
|
|
|
|
if(episodeList.total < 1){
|
|
console.log(' [INFO] Season is empty!');
|
|
return { isOk: true, value: [] };
|
|
}
|
|
|
|
const doEpsFilter = parseSelect(e as string);
|
|
const selectedMedia: CrunchyEpMeta[] = [];
|
|
|
|
episodeList.items.forEach((item) => {
|
|
item.hide_season_title = true;
|
|
if(item.season_title == '' && item.series_title != ''){
|
|
item.season_title = item.series_title;
|
|
item.hide_season_title = false;
|
|
item.hide_season_number = true;
|
|
}
|
|
if(item.season_title == '' && item.series_title == ''){
|
|
item.season_title = 'NO_TITLE';
|
|
}
|
|
// set data
|
|
const epMeta: CrunchyEpMeta = {
|
|
data: [
|
|
{
|
|
mediaId: item.id
|
|
}
|
|
],
|
|
seasonTitle: item.season_title,
|
|
episodeNumber: item.episode,
|
|
episodeTitle: item.title,
|
|
seasonID: item.season_id,
|
|
season: item.season_number,
|
|
showID: id
|
|
};
|
|
if(item.playback){
|
|
epMeta.data[0].playback = item.playback;
|
|
}
|
|
// find episode numbers
|
|
const epNum = item.episode;
|
|
let isSpecial = false;
|
|
item.isSelected = false;
|
|
if(!epNum.match(/^\d+$/) || epNumList.ep.indexOf(parseInt(epNum, 10)) > -1){
|
|
isSpecial = true;
|
|
epNumList.sp++;
|
|
}
|
|
else{
|
|
epNumList.ep.push(parseInt(epNum, 10));
|
|
}
|
|
const selEpId = (
|
|
isSpecial
|
|
? 'S' + epNumList.sp.toString().padStart(epNumLen, '0')
|
|
: '' + parseInt(epNum, 10).toString().padStart(epNumLen, '0')
|
|
);
|
|
if((but && item.playback && !doEpsFilter.isSelected([selEpId, item.id])) || (all && item.playback) || (!but && doEpsFilter.isSelected([selEpId, item.id]) && !item.isSelected && item.playback)){
|
|
selectedMedia.push(epMeta);
|
|
item.isSelected = true;
|
|
}
|
|
// show ep
|
|
item.seq_id = selEpId;
|
|
this.logObject(item);
|
|
});
|
|
|
|
// display
|
|
if(selectedMedia.length < 1){
|
|
console.log('\n[INFO] Episodes not selected!\n');
|
|
}
|
|
|
|
console.log();
|
|
return { isOk: true, value: selectedMedia };
|
|
}
|
|
|
|
public async downloadEpisode(data: CrunchyEpMeta, options: CrunchyDownloadOptions): Promise<boolean> {
|
|
const res = await this.downloadMediaList(data, options);
|
|
if (res === undefined) {
|
|
return false;
|
|
} else {
|
|
this.muxStreams(res.data, { ...options, output: res.fileName });
|
|
downloaded({
|
|
service: 'crunchy',
|
|
type: 's'
|
|
}, data.showID, [data.episodeNumber]);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
public async getObjectById(e?: string, earlyReturn?: boolean): Promise<ObjectInfo|Partial<CrunchyEpMeta>[]|undefined> {
|
|
if(!this.cmsToken.cms){
|
|
console.log('[ERROR] Authentication required!');
|
|
return;
|
|
}
|
|
|
|
const doEpsFilter = parseSelect(e as string);
|
|
|
|
if(doEpsFilter.values.length < 1){
|
|
console.log('\n[INFO] Objects not selected!\n');
|
|
return;
|
|
}
|
|
|
|
// node crunchy-beta -e G6497Z43Y,GRZXCMN1W,G62PEZ2E6,G25FVGDEK,GZ7UVPVX5
|
|
console.log('[INFO] Requested object ID: %s', doEpsFilter.values.join(', '));
|
|
|
|
const objectReqOpts = [
|
|
api.beta_cms,
|
|
this.cmsToken.cms.bucket,
|
|
'/objects/',
|
|
doEpsFilter.values.join(','),
|
|
'?',
|
|
new URLSearchParams({
|
|
'Policy': this.cmsToken.cms.policy,
|
|
'Signature': this.cmsToken.cms.signature,
|
|
'Key-Pair-Id': this.cmsToken.cms.key_pair_id,
|
|
}),
|
|
].join('');
|
|
const objectReq = await this.req.getData(objectReqOpts);
|
|
if(!objectReq.ok || !objectReq.res){
|
|
console.log('[ERROR] Objects Request FAILED!');
|
|
if(objectReq.error && objectReq.error.res && objectReq.error.res.body){
|
|
const objectInfo = JSON.parse(objectReq.error.res.body as string);
|
|
console.log('[INFO] Body:', JSON.stringify(objectInfo, null, '\t'));
|
|
objectInfo.error = true;
|
|
return objectInfo;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const objectInfo = JSON.parse(objectReq.res.body) as ObjectInfo;
|
|
if(earlyReturn){
|
|
return objectInfo;
|
|
}
|
|
|
|
const selectedMedia = [];
|
|
|
|
for(const item of objectInfo.items){
|
|
if(item.type != 'episode' && item.type != 'movie'){
|
|
await this.logObject(item, 2, true, false);
|
|
continue;
|
|
}
|
|
const epMeta: Partial<CrunchyEpMeta> = {};
|
|
switch (item.type) {
|
|
case 'episode':
|
|
item.s_num = 'S:' + item.episode_metadata.season_id;
|
|
epMeta.data = [
|
|
{
|
|
mediaId: 'E:'+ item.id
|
|
}
|
|
];
|
|
epMeta.seasonTitle = item.episode_metadata.season_title;
|
|
epMeta.episodeNumber = item.episode_metadata.episode;
|
|
epMeta.episodeTitle = item.title;
|
|
break;
|
|
case 'movie':
|
|
item.f_num = 'F:' + item.movie_metadata?.movie_listing_id;
|
|
epMeta.data = [
|
|
{
|
|
mediaId: 'M:'+ item.id
|
|
}
|
|
];
|
|
epMeta.seasonTitle = item.movie_metadata?.movie_listing_title;
|
|
epMeta.episodeNumber = 'Movie';
|
|
epMeta.episodeTitle = item.title;
|
|
break;
|
|
}
|
|
if(item.playback){
|
|
epMeta.data[0].playback = item.playback;
|
|
selectedMedia.push(epMeta);
|
|
item.isSelected = true;
|
|
}
|
|
await this.logObject(item, 2);
|
|
}
|
|
|
|
console.log();
|
|
return selectedMedia;
|
|
}
|
|
|
|
public async downloadMediaList(medias: CrunchyEpMeta, options: CrunchyDownloadOptions) : Promise<{
|
|
data: DownloadedMedia[],
|
|
fileName: string
|
|
} | undefined> {
|
|
|
|
let mediaName = '...';
|
|
let fileName;
|
|
const variables: Variable[] = [];
|
|
if(medias.seasonTitle && medias.episodeNumber && medias.episodeTitle){
|
|
mediaName = `${medias.seasonTitle} - ${medias.episodeNumber} - ${medias.episodeTitle}`;
|
|
}
|
|
|
|
const files: DownloadedMedia[] = [];
|
|
|
|
if(medias.data.every(a => !a.playback)){
|
|
console.log('[WARN] Video not available!');
|
|
return undefined;
|
|
}
|
|
|
|
for (const mMeta of medias.data) {
|
|
console.log(`[INFO] Requesting: [${mMeta.mediaId}] ${mediaName}`);
|
|
const playbackReq = await this.req.getData(mMeta.playback as string);
|
|
|
|
if(!playbackReq.ok || !playbackReq.res){
|
|
console.log('[ERROR] Request Stream URLs FAILED!');
|
|
return undefined;
|
|
}
|
|
|
|
const pbData = JSON.parse(playbackReq.res.body) as PlaybackData;
|
|
|
|
variables.push(...([
|
|
['title', medias.episodeTitle],
|
|
['episode', isNaN(parseInt(medias.episodeNumber)) ? medias.episodeNumber : parseInt(medias.episodeNumber)],
|
|
['service', 'CR'],
|
|
['showTitle', medias.seasonTitle],
|
|
['season', medias.season]
|
|
] as [AvailableFilenameVars, string|number][]).map((a): Variable => {
|
|
return {
|
|
name: a[0],
|
|
replaceWith: a[1],
|
|
type: typeof a[1]
|
|
} as Variable;
|
|
}));
|
|
|
|
let streams = [];
|
|
let hsLangs: string[] = [];
|
|
const pbStreams = pbData.streams;
|
|
|
|
for(const s of Object.keys(pbStreams)){
|
|
if(s.match(/hls/) && !s.match(/drm/) && !s.match(/trailer/)){
|
|
const pb = Object.values(pbStreams[s]).map(v => {
|
|
v.hardsub_lang = v.hardsub_locale
|
|
? langsData.fixAndFindCrLC(v.hardsub_locale).locale
|
|
: v.hardsub_locale;
|
|
if(v.hardsub_lang && hsLangs.indexOf(v.hardsub_lang) < 0){
|
|
hsLangs.push(v.hardsub_lang);
|
|
}
|
|
return {
|
|
...v,
|
|
...{ format: s }
|
|
};
|
|
});
|
|
streams.push(...pb);
|
|
}
|
|
}
|
|
|
|
if(streams.length < 1){
|
|
console.log('[WARN] No full streams found!');
|
|
return undefined;
|
|
}
|
|
|
|
const audDub = langsData.findLang(langsData.fixLanguageTag(pbData.audio_locale) || '').code;
|
|
hsLangs = langsData.sortTags(hsLangs);
|
|
|
|
streams = streams.map((s) => {
|
|
s.audio_lang = audDub;
|
|
s.hardsub_lang = s.hardsub_lang ? s.hardsub_lang : '-';
|
|
s.type = `${s.format}/${s.audio_lang}/${s.hardsub_lang}`;
|
|
return s;
|
|
});
|
|
|
|
let dlFailed = false;
|
|
|
|
if(options.hslang != 'none'){
|
|
if(hsLangs.indexOf(options.hslang) > -1){
|
|
console.log('[INFO] Selecting stream with %s hardsubs', langsData.locale2language(options.hslang).language);
|
|
streams = streams.filter((s) => {
|
|
if(s.hardsub_lang == '-'){
|
|
return false;
|
|
}
|
|
return s.hardsub_lang == options.hslang ? true : false;
|
|
});
|
|
}
|
|
else{
|
|
console.log('[WARN] Selected stream with %s hardsubs not available', langsData.locale2language(options.hslang).language);
|
|
if(hsLangs.length > 0){
|
|
console.log('[WARN] Try other hardsubs stream:', hsLangs.join(', '));
|
|
}
|
|
dlFailed = true;
|
|
}
|
|
}
|
|
else{
|
|
streams = streams.filter((s) => {
|
|
if(s.hardsub_lang != '-'){
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
if(streams.length < 1){
|
|
console.log('[WARN] Raw streams not available!');
|
|
if(hsLangs.length > 0){
|
|
console.log('[WARN] Try hardsubs stream:', hsLangs.join(', '));
|
|
}
|
|
dlFailed = true;
|
|
}
|
|
console.log('[INFO] Selecting raw stream');
|
|
}
|
|
|
|
let curStream:
|
|
undefined|typeof streams[0]
|
|
= undefined;
|
|
if(!dlFailed){
|
|
options.kstream = typeof options.kstream == 'number' ? options.kstream : 1;
|
|
options.kstream = options.kstream > streams.length ? 1 : options.kstream;
|
|
|
|
streams.forEach((s, i) => {
|
|
const isSelected = options.kstream == i + 1 ? '✓' : ' ';
|
|
console.log('[INFO] Full stream found! (%s%s: %s )', isSelected, i + 1, s.type);
|
|
});
|
|
|
|
console.log('[INFO] Downloading video...');
|
|
curStream = streams[options.kstream-1];
|
|
|
|
console.log('[INFO] Playlists URL: %s (%s)', curStream.url, curStream.type);
|
|
}
|
|
|
|
if(!options.novids && !dlFailed && curStream !== undefined){
|
|
const streamPlaylistsReq = await this.req.getData(curStream.url);
|
|
if(!streamPlaylistsReq.ok || !streamPlaylistsReq.res){
|
|
console.log('[ERROR] CAN\'T FETCH VIDEO PLAYLISTS!');
|
|
dlFailed = true;
|
|
}
|
|
else{
|
|
const streamPlaylists = m3u8(streamPlaylistsReq.res.body);
|
|
const plServerList: string[] = [],
|
|
plStreams: Record<string, Record<string, string>> = {},
|
|
plQuality: {
|
|
str: string,
|
|
dim: string,
|
|
RESOLUTION: {
|
|
width: number,
|
|
height: number
|
|
}
|
|
}[] = [];
|
|
for(const pl of streamPlaylists.playlists){
|
|
// set quality
|
|
const plResolution = pl.attributes.RESOLUTION;
|
|
const plResolutionText = `${plResolution.width}x${plResolution.height}`;
|
|
// parse uri
|
|
const plUri = new URL(pl.uri);
|
|
let plServer = plUri.hostname;
|
|
// set server list
|
|
if(plUri.searchParams.get('cdn')){
|
|
plServer += ` (${plUri.searchParams.get('cdn')})`;
|
|
}
|
|
if(!plServerList.includes(plServer)){
|
|
plServerList.push(plServer);
|
|
}
|
|
// add to server
|
|
if(!Object.keys(plStreams).includes(plServer)){
|
|
plStreams[plServer] = {};
|
|
}
|
|
if(
|
|
plStreams[plServer][plResolutionText]
|
|
&& plStreams[plServer][plResolutionText] != pl.uri
|
|
&& typeof plStreams[plServer][plResolutionText] != 'undefined'
|
|
){
|
|
console.log(`[WARN] Non duplicate url for ${plServer} detected, please report to developer!`);
|
|
}
|
|
else{
|
|
plStreams[plServer][plResolutionText] = pl.uri;
|
|
}
|
|
// set plQualityStr
|
|
const plBandwidth = Math.round(pl.attributes.BANDWIDTH/1024);
|
|
const qualityStrAdd = `${plResolutionText} (${plBandwidth}KiB/s)`;
|
|
const qualityStrRegx = new RegExp(qualityStrAdd.replace(/(:|\(|\)|\/)/g, '\\$1'), 'm');
|
|
const qualityStrMatch = !plQuality.map(a => a.str).join('\r\n').match(qualityStrRegx);
|
|
if(qualityStrMatch){
|
|
plQuality.push({
|
|
str: qualityStrAdd,
|
|
dim: plResolutionText,
|
|
RESOLUTION: plResolution
|
|
});
|
|
}
|
|
}
|
|
|
|
options.x = options.x > plServerList.length ? 1 : options.x;
|
|
|
|
const plSelectedServer = plServerList[options.x - 1];
|
|
const plSelectedList = plStreams[plSelectedServer];
|
|
plQuality.sort((a, b) => {
|
|
const aMatch = a.dim.match(/[0-9]+/) || [];
|
|
const bMatch = b.dim.match(/[0-9]+/) || [];
|
|
return parseInt(aMatch[0]) - parseInt(bMatch[0]);
|
|
});
|
|
let quality = options.q;
|
|
if (quality > plQuality.length) {
|
|
console.log(`[WARN] The requested quality of ${options.q} is greater than the maximun ${plQuality.length}.\n[WARN] Therefor the maximum will be capped at ${plQuality.length}.`);
|
|
quality = plQuality.length;
|
|
}
|
|
const selPlUrl = quality === 0 ? plSelectedList[plQuality[plQuality.length - 1].dim as string] :
|
|
plSelectedList[plQuality.map(a => a.dim)[quality - 1]] ? plSelectedList[plQuality.map(a => a.dim)[quality - 1]] : '';
|
|
console.log(`[INFO] Servers available:\n\t${plServerList.join('\n\t')}`);
|
|
console.log(`[INFO] Available qualities:\n\t${plQuality.map((a, ind) => `[${ind+1}] ${a.str}`).join('\n\t')}`);
|
|
|
|
if(selPlUrl != ''){
|
|
variables.push({
|
|
name: 'height',
|
|
type: 'number',
|
|
replaceWith: quality === 0 ? plQuality[plQuality.length - 1].RESOLUTION.height as number : plQuality[quality - 1].RESOLUTION.height
|
|
}, {
|
|
name: 'width',
|
|
type: 'number',
|
|
replaceWith: quality === 0 ? plQuality[plQuality.length - 1].RESOLUTION.width as number : plQuality[quality - 1].RESOLUTION.width
|
|
});
|
|
const lang = langsData.languages.find(a => a.code === curStream?.audio_lang);
|
|
if (!lang) {
|
|
console.log(`[ERROR] Unable to find language for code ${curStream.audio_lang}`);
|
|
return;
|
|
}
|
|
console.log(`[INFO] Selected quality: ${Object.keys(plSelectedList).find(a => plSelectedList[a] === selPlUrl)} @ ${plSelectedServer}`);
|
|
console.log('[INFO] Stream URL:', selPlUrl);
|
|
// TODO check filename
|
|
fileName = parseFileName(options.fileName, variables, options.numbers).join(path.sep);
|
|
const outFile = parseFileName(options.fileName + '.' + (mMeta.lang?.name || lang.name), variables, options.numbers).join(path.sep);
|
|
console.log(`[INFO] Output filename: ${outFile}`);
|
|
const chunkPage = await this.req.getData(selPlUrl);
|
|
if(!chunkPage.ok || !chunkPage.res){
|
|
console.log('[ERROR] CAN\'T FETCH VIDEO PLAYLIST!');
|
|
dlFailed = true;
|
|
}
|
|
else{
|
|
const chunkPlaylist = m3u8(chunkPage.res.body);
|
|
const totalParts = chunkPlaylist.segments.length;
|
|
const mathParts = Math.ceil(totalParts / options.partsize);
|
|
const mathMsg = `(${mathParts}*${options.partsize})`;
|
|
console.log('[INFO] Total parts in stream:', totalParts, mathMsg);
|
|
const tsFile = path.isAbsolute(outFile as string) ? outFile : path.join(this.cfg.dir.content, outFile);
|
|
const split = outFile.split(path.sep).slice(0, -1);
|
|
split.forEach((val, ind, arr) => {
|
|
const isAbsolut = path.isAbsolute(outFile as string);
|
|
if (!fs.existsSync(path.join(isAbsolut ? '' : this.cfg.dir.content, ...arr.slice(0, ind), val)))
|
|
fs.mkdirSync(path.join(isAbsolut ? '' : this.cfg.dir.content, ...arr.slice(0, ind), val));
|
|
});
|
|
const dlStreamByPl = await new streamdl({
|
|
output: `${tsFile}.ts`,
|
|
timeout: options.timeout,
|
|
m3u8json: chunkPlaylist,
|
|
// baseurl: chunkPlaylist.baseUrl,
|
|
threads: options.partsize,
|
|
fsRetryTime: options.fsRetryTime * 1000,
|
|
callback: options.callback
|
|
}).download();
|
|
if(!dlStreamByPl.ok){
|
|
console.log(`[ERROR] DL Stats: ${JSON.stringify(dlStreamByPl.parts)}\n`);
|
|
dlFailed = true;
|
|
}
|
|
files.push({
|
|
type: 'Video',
|
|
path: `${tsFile}.ts`,
|
|
lang: lang
|
|
});
|
|
}
|
|
}
|
|
else{
|
|
console.log('[ERROR] Quality not selected!\n');
|
|
dlFailed = true;
|
|
}
|
|
}
|
|
}
|
|
else if(options.novids){
|
|
fileName = parseFileName(options.fileName, variables, options.numbers).join(path.sep);
|
|
console.log('[INFO] Downloading skipped!');
|
|
}
|
|
|
|
|
|
if(options.dlsubs.indexOf('all') > -1){
|
|
options.dlsubs = ['all'];
|
|
}
|
|
|
|
if(options.hslang != 'none'){
|
|
console.log('[WARN] Subtitles downloading disabled for hardsubs streams.');
|
|
options.skipsubs = true;
|
|
}
|
|
|
|
|
|
if(!options.skipsubs && options.dlsubs.indexOf('none') == -1){
|
|
if(pbData.subtitles && Object.values(pbData.subtitles).length > 0){
|
|
const subsData = Object.values(pbData.subtitles);
|
|
const subsDataMapped = subsData.map((s) => {
|
|
const subLang = langsData.fixAndFindCrLC(s.locale);
|
|
return {
|
|
...s,
|
|
locale: subLang,
|
|
language: subLang.locale
|
|
};
|
|
});
|
|
const subsArr = langsData.sortSubtitles<typeof subsDataMapped[0]>(subsDataMapped, 'language');
|
|
for(const subsIndex in subsArr){
|
|
const subsItem = subsArr[subsIndex];
|
|
const langItem = subsItem.locale;
|
|
const sxData: Partial<sxItem> = {};
|
|
sxData.language = langItem;
|
|
sxData.file = langsData.subsFile(fileName as string, subsIndex, langItem);
|
|
sxData.path = path.join(this.cfg.dir.content, sxData.file);
|
|
if (files.some(a => a.type === 'Subtitle' && a.language.code == langItem.code))
|
|
continue;
|
|
if(options.dlsubs.includes('all') || options.dlsubs.includes(langItem.locale)){
|
|
const subsAssReq = await this.req.getData(subsItem.url);
|
|
if(subsAssReq.ok && subsAssReq.res){
|
|
let sBody = '\ufeff' + subsAssReq.res.body;
|
|
const sBodySplit = sBody.split('\r\n');
|
|
sBodySplit.splice(2, 0, 'ScaledBorderAndShadow: yes');
|
|
sBody = sBodySplit.join('\r\n');
|
|
sxData.title = sBody.split('\r\n')[1].replace(/^Title: /, '');
|
|
sxData.title = `${langItem.language} / ${sxData.title}`;
|
|
sxData.fonts = fontsData.assFonts(sBody) as Font[];
|
|
fs.writeFileSync(sxData.path, sBody);
|
|
console.log(`[INFO] Subtitle downloaded: ${sxData.file}`);
|
|
files.push({
|
|
type: 'Subtitle',
|
|
...sxData as sxItem
|
|
});
|
|
}
|
|
else{
|
|
console.log(`[WARN] Failed to download subtitle: ${sxData.file}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else{
|
|
console.log('[WARN] Can\'t find urls for subtitles!');
|
|
}
|
|
}
|
|
else{
|
|
console.log('[INFO] Subtitles downloading skipped!');
|
|
}
|
|
}
|
|
return {
|
|
data: files,
|
|
fileName: fileName ? (path.isAbsolute(fileName) ? fileName : path.join(this.cfg.dir.content, fileName)) || './unknown' : './unknown'
|
|
};
|
|
}
|
|
|
|
public async muxStreams(data: DownloadedMedia[], options: CrunchyMuxOptions) {
|
|
this.cfg.bin = await yamlCfg.loadBinCfg();
|
|
if (options.novids || data.filter(a => a.type === 'Video').length === 0)
|
|
return console.log('[INFO] Skip muxing since no vids are downloaded');
|
|
const merger = new Merger({
|
|
onlyVid: [],
|
|
skipSubMux: options.skipSubMux,
|
|
onlyAudio: [],
|
|
output: `${options}.${options.mp4 ? 'mp4' : 'mkv'}`,
|
|
subtitles: data.filter(a => a.type === 'Subtitle').map((a) : SubtitleInput => {
|
|
if (a.type === 'Video')
|
|
throw new Error('Never');
|
|
return {
|
|
file: a.path,
|
|
language: a.language
|
|
};
|
|
}),
|
|
simul: false,
|
|
fonts: Merger.makeFontsList(this.cfg.dir.fonts, data.filter(a => a.type === 'Subtitle') as sxItem[]),
|
|
videoAndAudio: data.filter(a => a.type === 'Video').map((a) : MergerInput => {
|
|
if (a.type === 'Subtitle')
|
|
throw new Error('Never');
|
|
return {
|
|
lang: a.lang,
|
|
path: a.path,
|
|
};
|
|
})
|
|
});
|
|
const bin = Merger.checkMerger(this.cfg.bin, options.mp4, options.forceMuxer);
|
|
// collect fonts info
|
|
// mergers
|
|
let isMuxed = false;
|
|
if (bin.MKVmerge) {
|
|
const command = merger.MkvMerge();
|
|
shlp.exec('mkvmerge', `"${bin.MKVmerge}"`, command);
|
|
isMuxed = true;
|
|
} else if (bin.FFmpeg) {
|
|
const command = merger.FFmpeg();
|
|
shlp.exec('ffmpeg', `"${bin.FFmpeg}"`, command);
|
|
isMuxed = true;
|
|
} else{
|
|
console.log('\n[INFO] Done!\n');
|
|
return;
|
|
}
|
|
if (isMuxed && !options.nocleanup)
|
|
merger.cleanUp();
|
|
}
|
|
|
|
public async listSeriesID(id: string): Promise<{ list: Episode[], data: Record<string, {
|
|
items: Item[];
|
|
langs: langsData.LanguageItem[];
|
|
}>}> {
|
|
await this.refreshToken();
|
|
const parsed = await this.parseSeriesById(id);
|
|
if (!parsed)
|
|
throw new Error('Unable to parse')
|
|
const result = this.parseSeriesResult(parsed);
|
|
const episodes : Record<string, {
|
|
items: Item[],
|
|
langs: langsData.LanguageItem[]
|
|
}> = {};
|
|
for(const season of Object.keys(result) as unknown as number[]) {
|
|
for (const key of Object.keys(result[season])) {
|
|
const s = result[season][key];
|
|
(await this.getSeasonDataById(s))?.items.forEach(a => {
|
|
if (Object.prototype.hasOwnProperty.call(episodes, `S${a.season_number}E${a.episode_number || a.episode}`)) {
|
|
const item = episodes[`S${a.season_number}E${a.episode_number || a.episode}`];
|
|
item.items.push(a);
|
|
item.langs.push(langsData.languages.find(a => a.code == key) as langsData.LanguageItem);
|
|
} else {
|
|
episodes[`S${a.season_number}E${a.episode_number || a.episode}`] = {
|
|
items: [a],
|
|
langs: [langsData.languages.find(a => a.code == key) as langsData.LanguageItem]
|
|
};
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
const itemIndexes = {
|
|
sp: 1,
|
|
no: 1
|
|
};
|
|
for (const key of Object.keys(episodes)) {
|
|
const item = episodes[key];
|
|
const isSpecial = !item.items[0].episode.match(/^\d+$/);
|
|
episodes[`${isSpecial ? 'S' : 'E'}${itemIndexes[isSpecial ? 'sp' : 'no']}`] = item;
|
|
if (isSpecial)
|
|
itemIndexes.sp++;
|
|
else
|
|
itemIndexes.no++;
|
|
delete episodes[key];
|
|
}
|
|
|
|
for (const key of Object.keys(episodes)) {
|
|
const item = episodes[key];
|
|
console.log(`[${key}] ${
|
|
item.items.find(a => !a.season_title.match(/\(\w+ Dub\)/))?.season_title ?? item.items[0].season_title.replace(/\(\w+ Dub\)/g, '').trimEnd()
|
|
} - Season ${item.items[0].season_number} - ${item.items[0].title} [${
|
|
item.items.map((a, index) => {
|
|
return `${a.is_premium_only ? '☆ ' : ''}${item.langs[index].name}`;
|
|
}).join(', ')
|
|
}]`);
|
|
}
|
|
|
|
return { data: episodes, list: Object.entries(episodes).map(([key, value]) => {
|
|
return {
|
|
e: key.startsWith('E') ? key.slice(1) : key,
|
|
lang: value.langs.map(a => a.code),
|
|
name: value.items[0].title,
|
|
season: value.items[0].season_number.toString(),
|
|
seasonTitle: value.items[0].season_title.replace(/\(\w+ Dub\)/g, '').trimEnd(),
|
|
episode: value.items[0].episode_number?.toString() ?? value.items[0].episode ?? '?',
|
|
id: value.items[0].season_id
|
|
}
|
|
})};
|
|
}
|
|
|
|
public async downloadFromSeriesID(id: string, data: CurnchyMultiDownload) : Promise<ResponseBase<CrunchyEpMeta[]>> {
|
|
const { data: episodes } = await this.listSeriesID(id);
|
|
console.log();
|
|
console.log('-'.repeat(30));
|
|
console.log();
|
|
const selected = this.itemSelectMultiDub(episodes, data.dubLang, data.but, data.all, data.e);
|
|
for (const key of Object.keys(selected)) {
|
|
const item = selected[key];
|
|
console.log(`[S${item.season}E${item.episodeNumber}] - ${item.episodeTitle} [${
|
|
item.data.map(a => {
|
|
return `✓ ${a.lang?.name || 'Unknown Language'}`;
|
|
}).join(', ')
|
|
}]`);
|
|
}
|
|
return { isOk: true, value: Object.values(selected) };
|
|
}
|
|
|
|
public itemSelectMultiDub (eps: Record<string, {
|
|
items: Item[],
|
|
langs: langsData.LanguageItem[]
|
|
}>, dubLang: string[], but?: boolean, all?: boolean, e?: string, ) {
|
|
const doEpsFilter = parseSelect(e as string);
|
|
|
|
const ret: Record<string, CrunchyEpMeta> = {};
|
|
|
|
for (const key of Object.keys(eps)) {
|
|
const itemE = eps[key];
|
|
itemE.items.forEach((item, index) => {
|
|
if (!dubLang.includes(itemE.langs[index].code))
|
|
return;
|
|
item.hide_season_title = true;
|
|
if(item.season_title == '' && item.series_title != ''){
|
|
item.season_title = item.series_title;
|
|
item.hide_season_title = false;
|
|
item.hide_season_number = true;
|
|
}
|
|
if(item.season_title == '' && item.series_title == ''){
|
|
item.season_title = 'NO_TITLE';
|
|
}
|
|
// set data
|
|
const epMeta: CrunchyEpMeta = {
|
|
data: [
|
|
{
|
|
mediaId: item.id
|
|
}
|
|
],
|
|
seasonTitle: itemE.items.find(a => !a.season_title.match(/\(\w+ Dub\)/))?.season_title ?? itemE.items[0].season_title.replace(/\(\w+ Dub\)/g, '').trimEnd(),
|
|
episodeNumber: item.episode,
|
|
episodeTitle: item.title,
|
|
seasonID: item.season_id,
|
|
season: item.season_number,
|
|
showID: item.series_id
|
|
};
|
|
if(item.playback){
|
|
epMeta.data[0].playback = item.playback;
|
|
}
|
|
const epNum = key.startsWith('E') ? key.slice(1) : key;
|
|
// find episode numbers
|
|
if(item.playback && ((but && !doEpsFilter.isSelected([epNum, item.id])) || (all || (doEpsFilter.isSelected([epNum, item.id])) && !but))) {
|
|
if (Object.prototype.hasOwnProperty.call(ret, key)) {
|
|
const epMe = ret[key];
|
|
epMe.data.push({
|
|
lang: itemE.langs[index],
|
|
...epMeta.data[0]
|
|
});
|
|
} else {
|
|
epMeta.data[0].lang = itemE.langs[index];
|
|
ret[key] = {
|
|
...epMeta
|
|
};
|
|
}
|
|
}
|
|
// show ep
|
|
item.seq_id = epNum;
|
|
});
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
public parseSeriesResult (seasonsList: SeriesSearch) : Record<number, Record<string, SeriesSearchItem>> {
|
|
const ret: Record<number, Record<string, SeriesSearchItem>> = {};
|
|
|
|
for (const item of seasonsList.items) {
|
|
for (const lang of langsData.languages) {
|
|
if (!Object.prototype.hasOwnProperty.call(ret, item.season_number))
|
|
ret[item.season_number] = {};
|
|
if (item.title.includes(`(${lang.name} Dub)`)) {
|
|
ret[item.season_number][lang.code] = item;
|
|
} else if (item.is_subbed && !item.is_dubbed && lang.code == 'jpn') {
|
|
ret[item.season_number][lang.code] = item;
|
|
}
|
|
}
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
public async parseSeriesById(id: string) {
|
|
if(!this.cmsToken.cms){
|
|
console.log('[ERROR] Authentication required!');
|
|
return;
|
|
}
|
|
const seriesSeasonListReqOpts = [
|
|
api.beta_cms,
|
|
this.cmsToken.cms.bucket,
|
|
'/seasons?',
|
|
new URLSearchParams({
|
|
'series_id': id,
|
|
'Policy': this.cmsToken.cms.policy,
|
|
'Signature': this.cmsToken.cms.signature,
|
|
'Key-Pair-Id': this.cmsToken.cms.key_pair_id,
|
|
}),
|
|
].join('');
|
|
// seasons list
|
|
const seriesSeasonListReq = await this.req.getData(seriesSeasonListReqOpts);
|
|
if(!seriesSeasonListReq.ok || !seriesSeasonListReq.res){
|
|
console.log('[ERROR] Series Request FAILED!');
|
|
return;
|
|
}
|
|
// parse data
|
|
const seasonsList = JSON.parse(seriesSeasonListReq.res.body) as SeriesSearch;
|
|
if(seasonsList.total < 1){
|
|
console.log('[INFO] Series is empty!');
|
|
return;
|
|
}
|
|
return seasonsList;
|
|
}
|
|
|
|
public async getSeasonDataById(item: SeriesSearchItem, log = false){
|
|
if(!this.cmsToken.cms){
|
|
console.log('[ERROR] Authentication required!');
|
|
return;
|
|
}
|
|
|
|
const showInfoReqOpts = [
|
|
api.beta_cms,
|
|
this.cmsToken.cms.bucket,
|
|
'/seasons/',
|
|
item.id,
|
|
'?',
|
|
new URLSearchParams({
|
|
'Policy': this.cmsToken.cms.policy,
|
|
'Signature': this.cmsToken.cms.signature,
|
|
'Key-Pair-Id': this.cmsToken.cms.key_pair_id,
|
|
}),
|
|
].join('');
|
|
const showInfoReq = await this.req.getData(showInfoReqOpts);
|
|
if(!showInfoReq.ok || !showInfoReq.res){
|
|
console.log('[ERROR] Show Request FAILED!');
|
|
return;
|
|
}
|
|
const showInfo = JSON.parse(showInfoReq.res.body);
|
|
if (log)
|
|
this.logObject(showInfo, 0);
|
|
const reqEpsListOpts = [
|
|
api.beta_cms,
|
|
this.cmsToken.cms.bucket,
|
|
'/episodes?',
|
|
new URLSearchParams({
|
|
'season_id': item.id as string,
|
|
'Policy': this.cmsToken.cms.policy,
|
|
'Signature': this.cmsToken.cms.signature,
|
|
'Key-Pair-Id': this.cmsToken.cms.key_pair_id,
|
|
}),
|
|
].join('');
|
|
const reqEpsList = await this.req.getData(reqEpsListOpts);
|
|
if(!reqEpsList.ok || !reqEpsList.res){
|
|
console.log('[ERROR] Episode List Request FAILED!');
|
|
return;
|
|
}
|
|
const episodeList = JSON.parse(reqEpsList.res.body) as CrunchyEpisodeList;
|
|
|
|
if(episodeList.total < 1){
|
|
console.log(' [INFO] Season is empty!');
|
|
return;
|
|
}
|
|
return episodeList;
|
|
}
|
|
|
|
}
|
|
/*
|
|
|
|
// MULTI DOWNLOADING
|
|
|
|
const
|
|
|
|
const
|
|
*/ |