// modules build-in import fs from 'fs'; import path from 'path'; // package json import packageJson from './package.json'; // modules extra import { console } from './modules/log'; import * as shlp from 'sei-helper'; import m3u8 from 'm3u8-parsed'; import hlsDownload, { HLSCallback } from './modules/hls-download'; // extra import * as appYargs from './modules/module.app-args'; import * as yamlCfg from './modules/module.cfg-loader'; import vttConvert from './modules/module.vttconvert'; // types import type { Item } from './@types/items.js'; // params // Import modules after argv has been exported import getData from './modules/module.getdata'; import merger from './modules/module.merger'; import parseSelect from './modules/module.parseSelect'; import { EpisodeData, MediaChild } from './@types/episode'; import { Subtitle } from './@types/funiTypes'; import { StreamData } from './@types/streamData'; import { DownloadedFile } from './@types/downloadedFile'; import parseFileName, { Variable } from './modules/module.filename'; import { downloaded } from './modules/module.downloadArchive'; import { FunimationMediaDownload } from './@types/funiTypes'; import * as langsData from './modules/module.langsData'; import { TitleElement } from './@types/episode'; import { AvailableFilenameVars } from './modules/module.args'; import { AuthData, AuthResponse, CheckTokenResponse, FuniGetEpisodeData, FuniGetEpisodeResponse, FuniGetShowData, SearchData, FuniSearchReponse, FuniShowResponse, FuniStreamData, FuniSubsData, FuniEpisodeData, ResponseBase } from './@types/messageHandler'; import { ServiceClass } from './@types/serviceClassInterface'; import { SubtitleRequest } from './@types/funiSubtitleRequest'; // program name const api_host = 'https://prod-api-funimationnow.dadcdigital.com/api'; // check page // fn variables let fnEpNum: string|number = 0, fnOutput: string[] = [], season = 0, tsDlPath: { path: string, lang: langsData.LanguageItem }[] = [], stDlPath: Subtitle[] = []; export default class Funi implements ServiceClass { public static epIdLen = 4; public static typeIdLen = 0; public cfg: yamlCfg.ConfigObject; private token: string | boolean; constructor(private debug = false) { this.cfg = yamlCfg.loadCfg(); this.token = yamlCfg.loadFuniToken(); } public checkToken(): CheckTokenResponse { const isOk = typeof this.token === 'string'; return isOk ? { isOk, value: undefined } : { isOk, reason: new Error('Not authenticated') }; } public async cli() : Promise { const argv = appYargs.appArgv(this.cfg.cli); if (argv.debug) this.debug = true; console.info(`\n=== Multi Downloader NX ${packageJson.version} ===\n`); if (argv.allDubs) { argv.dubLang = langsData.dubLanguageCodes; } // select mode if (argv.silentAuth && !argv.auth) { const data: AuthData = { username: argv.username ?? await shlp.question('[Q] LOGIN/EMAIL'), password: argv.password ?? await shlp.question('[Q] PASSWORD ') }; await this.auth(data); } if(argv.auth){ const data: AuthData = { username: argv.username ?? await shlp.question('[Q] LOGIN/EMAIL'), password: argv.password ?? await shlp.question('[Q] PASSWORD ') }; await this.auth(data); } else if(argv.search){ this.searchShow(true, { search: argv.search }); } else if(argv.s && !isNaN(parseInt(argv.s)) && parseInt(argv.s) > 0){ const data = await this.getShow(true, { id: parseInt(argv.s), but: argv.but, all: argv.all, e: argv.e }); if (!data.isOk) { console.error(`${data.reason.message}`); return false; } let ok = true; for (const episodeData of data.value) { if ((await this.getEpisode(true, { subs: { dlsubs: argv.dlsubs, nosubs: argv.nosubs, sub: false, ccTag: argv.ccTag }, dubLang: argv.dubLang, fnSlug: episodeData, s: argv.s, simul: argv.simul }, { ass: false, ...argv })).isOk !== true) ok = false; } return ok; } else{ console.info('No option selected or invalid value entered. Try --help.'); } } public async auth(data: AuthData): Promise { const authOpts = { user: data.username, pass: data.password }; const authData = await getData({ baseUrl: api_host, url: '/auth/login/', auth: authOpts, debug: this.debug, }); if(authData.ok && authData.res){ const resJSON = JSON.parse(authData.res.body); if(resJSON.token){ console.info('Authentication success, your token: %s%s\n', resJSON.token.slice(0,8),'*'.repeat(32)); yamlCfg.saveFuniToken({'token': resJSON.token}); this.token = resJSON.token; return { isOk: true, value: undefined }; } else { console.info('[ERROR]%s\n', ' No token found'); if (this.debug) { console.info(resJSON); } return { isOk: false, reason: new Error(resJSON) }; } } return { isOk: false, reason: new Error('Login request failed') }; } public async searchShow(log: boolean, data: SearchData): Promise { const qs = {unique: true, limit: 100, q: data.search, offset: 0 }; const searchData = await getData({ baseUrl: api_host, url: '/source/funimation/search/auto/', querystring: qs, token: this.token, useToken: true, debug: this.debug, }); if(!searchData.ok || !searchData.res){ return { isOk: false, reason: new Error('Request is not ok') }; } const searchDataJSON = JSON.parse(searchData.res.body); if(searchDataJSON.detail){ console.error(`${searchDataJSON.detail}`); return { isOk: false, reason: new Error(searchDataJSON.defail) }; } if(searchDataJSON.items && searchDataJSON.items.hits && log){ const shows = searchDataJSON.items.hits; console.info('Search Results:'); for(const ssn in shows){ console.info(`[#${shows[ssn].id}] ${shows[ssn].title}` + (shows[ssn].tx_date?` (${shows[ssn].tx_date})`:'')); } } if (log) console.info('Total shows found: %s\n',searchDataJSON.count); return { isOk: true, value: searchDataJSON }; } public async listShowItems(id: number) : Promise> { const showData = await getData({ baseUrl: api_host, url: `/source/catalog/title/${id}`, token: this.token, useToken: true, debug: this.debug, }); // check errors if(!showData.ok || !showData.res){ return { isOk: false, reason: new Error('ShowData is not ok') }; } const showDataJSON = JSON.parse(showData.res.body); if(showDataJSON.status){ console.error('Error #%d: %s\n', showDataJSON.status, showDataJSON.data.errors[0].detail); return { isOk: false, reason: new Error(showDataJSON.data.errors[0].detail) }; } else if(!showDataJSON.items || showDataJSON.items.length<1){ console.error('Show not found\n'); return { isOk: false, reason: new Error('Show not found') }; } const showDataItem = showDataJSON.items[0]; console.info('[#%s] %s (%s)',showDataItem.id,showDataItem.title,showDataItem.releaseYear); // show episodes const qs: { limit: number, sort: string, sort_direction: string, title_id: number, language?: string } = { limit: -1, sort: 'order', sort_direction: 'ASC', title_id: id }; const episodesData = await getData({ baseUrl: api_host, url: '/funimation/episodes/', querystring: qs, token: this.token, useToken: true, debug: this.debug, }); if(!episodesData.ok || !episodesData.res){ return { isOk: false, reason: new Error('episodesData is not ok') }; } let epsDataArr: Item[] = JSON.parse(episodesData.res.body).items; const epNumRegex = /^([A-Z0-9]*[A-Z])?(\d+)$/i; const parseEpStr = (epStr: string) => { const match = epStr.match(epNumRegex); if (!match) { console.error('No match found'); return ['', '']; } if(match.length > 2){ const spliced = [...match].splice(1); spliced[0] = spliced[0] ? spliced[0] : ''; return spliced; } else return [ '', match[0] ]; }; epsDataArr = epsDataArr.map(e => { const baseId = e.ids.externalAsianId ? e.ids.externalAsianId : e.ids.externalEpisodeId; e.id = baseId.replace(new RegExp('^' + e.ids.externalShowId), ''); if(e.id.match(epNumRegex)){ const epMatch = parseEpStr(e.id); Funi.epIdLen = epMatch[1].length > Funi.epIdLen ? epMatch[1].length : Funi.epIdLen; Funi.typeIdLen = epMatch[0].length > Funi.typeIdLen ? epMatch[0].length : Funi.typeIdLen; e.id_split = epMatch; } else{ Funi.typeIdLen = 3 > Funi.typeIdLen? 3 : Funi.typeIdLen; console.error('FAILED TO PARSE: ', e.id); e.id_split = [ 'ZZZ', 9999 ]; } return e; }); epsDataArr.sort((a, b) => { if (a.item.seasonOrder < b.item.seasonOrder && a.id.localeCompare(b.id) < 0) { return -1; } if (a.item.seasonOrder > b.item.seasonOrder && a.id.localeCompare(b.id) > 0) { return 1; } return 0; }); return { isOk: true, value: epsDataArr }; } public async getShow(log: boolean, data: FuniGetShowData) : Promise { const showList = await this.listShowItems(data.id); if (!showList.isOk) return showList; const eps = showList.value; const epSelList = parseSelect(data.e as string, data.but); const fnSlug: FuniEpisodeData[] = [], epSelEpsTxt: string[] = []; let is_selected = false; for(const e in eps){ eps[e].id_split[1] = parseInt(eps[e].id_split[1].toString()).toString().padStart(Funi.epIdLen, '0'); let epStrId = eps[e].id_split.join(''); // select is_selected = false; if (data.all || epSelList.isSelected(epStrId)) { fnSlug.push({ title:eps[e].item.titleSlug, episode:eps[e].item.episodeSlug, episodeID:epStrId, epsiodeNumber: eps[e].item.episodeNum, seasonTitle: eps[e].item.seasonTitle, seasonNumber: eps[e].item.seasonNum, ids: { episode: eps[e].ids.externalEpisodeId, season: eps[e].ids.externalSeasonId, show: eps[e].ids.externalShowId }, image: eps[e].item.poster }); epSelEpsTxt.push(epStrId); is_selected = true; } // console vars const tx_snum = eps[e].item.seasonNum=='1'?'':` S${eps[e].item.seasonNum}`; const tx_type = eps[e].mediaCategory != 'episode' ? eps[e].mediaCategory : ''; const tx_enum = eps[e].item.episodeNum && eps[e].item.episodeNum !== '' ? `#${(parseInt(eps[e].item.episodeNum) < 10 ? '0' : '')+eps[e].item.episodeNum}` : '#'+eps[e].item.episodeId; const qua_str = eps[e].quality.height ? eps[e].quality.quality + eps[e].quality.height : 'UNK'; const aud_str = eps[e].audio.length > 0 ? `, ${eps[e].audio.join(', ')}` : ''; const rtm_str = eps[e].item.runtime !== '' ? eps[e].item.runtime : '??:??'; // console string eps[e].id_split[0] = eps[e].id_split[0].toString().padStart(Funi.typeIdLen, ' '); epStrId = eps[e].id_split.join(''); let conOut = `[${epStrId}] `; conOut += `${eps[e].item.titleName+tx_snum} - ${tx_type+tx_enum} ${eps[e].item.episodeName} `; conOut += `(${rtm_str}) [${qua_str+aud_str}]`; conOut += is_selected ? ' (selected)' : ''; conOut += eps.length-1 == parseInt(e) ? '\n' : ''; console.info(conOut); } if(fnSlug.length < 1){ if (log) console.info('Episodes not selected!\n'); return { isOk: true, value: [] } ; } else{ if (log) console.info('Selected Episodes: %s\n',epSelEpsTxt.join(', ')); return { isOk: true, value: fnSlug }; } } public async getEpisode(log: boolean, data: FuniGetEpisodeData, downloadData: FuniStreamData) : Promise { const episodeData = await getData({ baseUrl: api_host, url: `/source/catalog/episode/${data.fnSlug.title}/${data.fnSlug.episode}/`, token: this.token, useToken: true, debug: this.debug, }); if(!episodeData.ok || !episodeData.res){return { isOk: false, reason: new Error('Unable to get episodeData') }; } const ep = JSON.parse(episodeData.res.body).items[0] as EpisodeData, streamIds: { id: number, lang: langsData.LanguageItem }[] = []; // build fn season = parseInt(ep.parent.seasonNumber); if(ep.mediaCategory != 'Episode'){ ep.number = ep.number !== '' ? ep.mediaCategory+ep.number : ep.mediaCategory+'#'+ep.id; } fnEpNum = isNaN(parseInt(ep.number)) ? ep.number : parseInt(ep.number); // is uncut const uncut = { Japanese: false, English: false }; // end if (log) { console.info( '%s - S%sE%s - %s', ep.parent.title, (ep.parent.seasonNumber ? ep.parent.seasonNumber : '?'), (ep.number ? ep.number : '?'), ep.title ); console.info('Available streams (Non-Encrypted):'); } // map medias const media = await Promise.all(ep.media.map(async (m) =>{ if(m.mediaType == 'experience'){ if(m.version.match(/uncut/i) && m.language){ uncut[m.language] = true; } return { id: m.id, language: m.language, version: m.version, type: m.experienceType, subtitles: await this.getSubsUrl(m.mediaChildren, m.language, data.subs, ep.ids.externalEpisodeId, data.subs.ccTag) }; } else{ return { id: 0, type: '' }; } })); // select stDlPath = []; for(const m of media){ let selected = false; if(m.id > 0 && m.type == 'Non-Encrypted'){ const dub_type = m.language; if (!dub_type) continue; let localSubs: Subtitle[] = []; const selUncut = !data.simul && uncut[dub_type] && m.version?.match(/uncut/i) ? true : (!uncut[dub_type] || data.simul && m.version?.match(/simulcast/i) ? true : false); for (const curDub of data.dubLang) { const item = langsData.languages.find(a => a.code === curDub); if(item && (dub_type === item.funi_name_lagacy || dub_type === (item.funi_name ?? item.name)) && selUncut){ streamIds.push({ id: m.id, lang: item }); stDlPath.push(...m.subtitles); localSubs = m.subtitles; selected = true; } } if (log) { const subsToDisplay: langsData.LanguageItem[] = []; localSubs.forEach(a => { if (!subsToDisplay.includes(a.lang)) subsToDisplay.push(a.lang); }); console.info(`[#${m.id}] ${dub_type} [${m.version}]${(selected?' (selected)':'')}${ localSubs && localSubs.length > 0 && selected ? ` (using ${subsToDisplay.map(a => `'${a.name}'`).join(', ')} for subtitles)` : '' }`); } } } const already: string[] = []; stDlPath = stDlPath.filter(a => { if (already.includes(`${a.closedCaption ? 'cc' : ''}-${a.lang.code}`)) { return false; } else { already.push(`${a.closedCaption ? 'cc' : ''}-${a.lang.code}`); return true; } }); if(streamIds.length < 1){ if (log) console.error('Track not selected\n'); return { isOk: false, reason: new Error('Track not selected') }; } else{ tsDlPath = []; for (const streamId of streamIds) { const streamData = await getData({ baseUrl: api_host, url: `/source/catalog/video/${streamId.id}/signed`, token: this.token, dinstid: 'uuid', useToken: true, debug: this.debug, }); if(!streamData.ok || !streamData.res){return { isOk: false, reason: new Error('Unable to get streamdata') };} const streamDataRes = JSON.parse(streamData.res.body) as StreamData; if(streamDataRes.errors){ if (log) console.info('Error #%s: %s\n',streamDataRes.errors[0].code,streamDataRes.errors[0].detail); return { isOk: false, reason: new Error(streamDataRes.errors[0].detail) }; } else{ for(const u in streamDataRes.items){ if(streamDataRes.items[u].videoType == 'm3u8'){ tsDlPath.push({ path: streamDataRes.items[u].src, lang: streamId.lang }); break; } } } } if(tsDlPath.length < 1){ if (log) console.error('Unknown error\n'); return { isOk: false, reason: new Error('Unknown error') }; } else{ const res = await this.downloadStreams(true, { id: data.fnSlug.episodeID, title: ep.title, showTitle: ep.parent.title, image: ep.thumb }, downloadData); if (res === true) { downloaded({ service: 'funi', type: 's' }, data.s, [data.fnSlug.episodeID]); return { isOk: res, value: undefined }; } return { isOk: false, reason: new Error('Unknown download error') }; } } } public async downloadStreams(log: boolean, episode: FunimationMediaDownload, data: FuniStreamData): Promise { // req playlist const purvideo: DownloadedFile[] = []; const puraudio: DownloadedFile[] = []; const audioAndVideo: DownloadedFile[] = []; for (const streamPath of tsDlPath) { const plQualityReq = await getData({ url: streamPath.path, debug: this.debug, }); if(!plQualityReq.ok || !plQualityReq.res){return;} const plQualityLinkList = m3u8(plQualityReq.res.body); const mainServersList = [ 'vmfst-api.prd.funimationsvc.com', 'd33et77evd9bgg.cloudfront.net', 'd132fumi6di1wa.cloudfront.net', 'funiprod.akamaized.net', ]; const plServerList: string[] = [], plStreams: Record = {}, plLayersStr: string[] = [], plLayersRes: Record = {}; let plMaxLayer = 1, plNewIds = 1, plAud: undefined|{ uri: string language: langsData.LanguageItem }; // new uris const vplReg = /streaming_video_(\d+)_(\d+)_(\d+)_index\.m3u8/; if(plQualityLinkList.playlists[0].uri.match(vplReg)){ const audioKey = Object.keys(plQualityLinkList.mediaGroups.AUDIO).pop(); if (!audioKey) return console.error('No audio key found'); if(plQualityLinkList.mediaGroups.AUDIO[audioKey]){ const audioDataParts = plQualityLinkList.mediaGroups.AUDIO[audioKey], audioEl = Object.keys(audioDataParts); const audioData = audioDataParts[audioEl[0]]; let language = langsData.languages.find(a => a.code === audioData.language || a.locale === audioData.language); if (!language) { language = langsData.languages.find(a => a.funi_name_lagacy === audioEl[0] || ((a.funi_name ?? a.name) === audioEl[0])); if (!language) { if (log) console.error(`Unable to find language for locale ${audioData.language} or name ${audioEl[0]}`); return; } } plAud = { uri: audioData.uri, language: language }; } plQualityLinkList.playlists.sort((a, b) => { const aMatch = a.uri.match(vplReg), bMatch = b.uri.match(vplReg); if (!aMatch || !bMatch) { console.info('Unable to match'); return 0; } const av = parseInt(aMatch[3]); const bv = parseInt(bMatch[3]); if(av > bv){ return 1; } if (av < bv) { return -1; } return 0; }); } for(const s of plQualityLinkList.playlists){ if(s.uri.match(/_Layer(\d+)\.m3u8/) || s.uri.match(vplReg)){ // set layer and max layer let plLayerId: number|string = 0; const match = s.uri.match(/_Layer(\d+)\.m3u8/); if(match){ plLayerId = parseInt(match[1]); } else{ plLayerId = plNewIds, plNewIds++; } plMaxLayer = plMaxLayer < plLayerId ? plLayerId : plMaxLayer; // set urls and servers const plUrlDl = s.uri; const plServer = new URL(plUrlDl).host; if(!plServerList.includes(plServer)){ plServerList.push(plServer); } if(!Object.keys(plStreams).includes(plServer)){ plStreams[plServer] = {}; } if(plStreams[plServer][plLayerId] && plStreams[plServer][plLayerId] != plUrlDl){ console.warn(`Non duplicate url for ${plServer} detected, please report to developer!`); } else{ plStreams[plServer][plLayerId] = plUrlDl; } // set plLayersStr const plResolution = s.attributes.RESOLUTION; plLayersRes[plLayerId] = plResolution; const plBandwidth = Math.round(s.attributes.BANDWIDTH/1024); if(plLayerId<10){ plLayerId = plLayerId.toString().padStart(2,' '); } const qualityStrAdd = `${plLayerId}: ${plResolution.width}x${plResolution.height} (${plBandwidth}KiB/s)`; const qualityStrRegx = new RegExp(qualityStrAdd.replace(/(:|\(|\)|\/)/g,'\\$1'),'m'); const qualityStrMatch = !plLayersStr.join('\r\n').match(qualityStrRegx); if(qualityStrMatch){ plLayersStr.push(qualityStrAdd); } } else { console.info(s.uri); } } for(const s of mainServersList){ if(plServerList.includes(s)){ plServerList.splice(plServerList.indexOf(s), 1); plServerList.unshift(s); break; } } const plSelectedServer = plServerList[data.x-1]; const plSelectedList = plStreams[plSelectedServer]; plLayersStr.sort(); if (log) { console.info(`Servers available:\n\t${plServerList.join('\n\t')}`); console.info(`Available qualities:\n\t${plLayersStr.join('\n\t')}`); } const selectedQuality = data.q === 0 || data.q > Object.keys(plLayersRes).length ? Object.keys(plLayersRes).pop() as string : data.q; const videoUrl = data.x < plServerList.length+1 && plSelectedList[selectedQuality] ? plSelectedList[selectedQuality] : ''; if(videoUrl != ''){ if (log) { console.info(`Selected layer: ${selectedQuality} (${plLayersRes[selectedQuality].width}x${plLayersRes[selectedQuality].height}) @ ${plSelectedServer}`); console.info('Stream URL:',videoUrl); } fnOutput = parseFileName(data.fileName, ([ ['episode', isNaN(parseInt(fnEpNum as string)) ? fnEpNum : parseInt(fnEpNum as string), true], ['title', episode.title, true], ['showTitle', episode.showTitle, true], ['season', season, false], ['width', plLayersRes[selectedQuality].width, false], ['height', plLayersRes[selectedQuality].height, false], ['service', 'Funimation', false] ] as [AvailableFilenameVars, string|number, boolean][]).map((a): Variable => { return { name: a[0], replaceWith: a[1], type: typeof a[1], sanitize: a[2] } as Variable; }), data.numbers, data.override); if (fnOutput.length < 1) throw new Error(`Invalid path generated for input ${data.fileName}`); if (log) console.info(`Output filename: ${fnOutput.join(path.sep)}.ts`); } else if(data.x > plServerList.length){ if (log) console.error('Server not selected!\n'); return; } else{ if (log) console.error('Layer not selected!\n'); return; } let dlFailed = false; let dlFailedA = false; await fs.promises.mkdir(path.join(this.cfg.dir.content, ...fnOutput.slice(0, -1)), { recursive: true }); video: if (!data.novids) { if (plAud && (purvideo.length > 0 || audioAndVideo.length > 0)) { break video; } else if (!plAud && (audioAndVideo.some(a => a.lang === streamPath.lang) || puraudio.some(a => a.lang === streamPath.lang))) { break video; } // download video const reqVideo = await getData({ url: videoUrl, debug: this.debug, }); if (!reqVideo.ok || !reqVideo.res) { break video; } const chunkList = m3u8(reqVideo.res.body); const tsFile = path.join(this.cfg.dir.content, ...fnOutput.slice(0, -1), `${fnOutput.slice(-1)}.video${(plAud?.uri ? '' : '.' + streamPath.lang.code )}`); dlFailed = !await this.downloadFile(tsFile, chunkList, data.timeout, data.partsize, data.fsRetryTime, data.force, data.callbackMaker ? data.callbackMaker({ fileName: `${fnOutput.slice(-1)}.video${(plAud?.uri ? '' : '.' + streamPath.lang.code )}.ts`, parent: { title: episode.showTitle }, title: episode.title, image: episode.image, language: streamPath.lang, }) : undefined); if (!dlFailed) { if (plAud) { purvideo.push({ path: `${tsFile}.ts`, lang: plAud.language }); } else { audioAndVideo.push({ path: `${tsFile}.ts`, lang: streamPath.lang }); } } } else{ if (log) console.info('Skip video downloading...\n'); } audio: if (plAud && !data.noaudio) { // download audio if (audioAndVideo.some(a => a.lang === plAud?.language) || puraudio.some(a => a.lang === plAud?.language)) break audio; const reqAudio = await getData({ url: plAud.uri, debug: this.debug, }); if (!reqAudio.ok || !reqAudio.res) { return; } const chunkListA = m3u8(reqAudio.res.body); const tsFileA = path.join(this.cfg.dir.content, ...fnOutput.slice(0, -1), `${fnOutput.slice(-1)}.audio.${plAud.language.code}`); dlFailedA = !await this.downloadFile(tsFileA, chunkListA, data.timeout, data.partsize, data.fsRetryTime, data.force, data.callbackMaker ? data.callbackMaker({ fileName: `${fnOutput.slice(-1)}.audio.${plAud.language.code}.ts`, parent: { title: episode.showTitle }, title: episode.title, image: episode.image, language: plAud.language }) : undefined); if (!dlFailedA) puraudio.push({ path: `${tsFileA}.ts`, lang: plAud.language }); } } // add subs const subsExt = !data.mp4 || data.mp4 && data.ass ? '.ass' : '.srt'; let addSubs = true; // download subtitles if(stDlPath.length > 0){ if (log) console.info('Downloading subtitles...'); for (const subObject of stDlPath) { const subsSrc = await getData({ url: subObject.url, debug: this.debug, }); if(subsSrc.ok && subsSrc.res){ const assData = vttConvert(subsSrc.res.body, (subsExt == '.srt' ? true : false), subObject.lang.name, data.fontSize, data.fontName); subObject.out = path.join(this.cfg.dir.content, ...fnOutput.slice(0, -1), `${fnOutput.slice(-1)}.subtitle${subObject.ext}${subsExt}`); fs.writeFileSync(subObject.out, assData); } else{ if (log) console.error('Failed to download subtitles!'); addSubs = false; break; } } if (addSubs && log) console.info('Subtitles downloaded!'); } if((puraudio.length < 1 && audioAndVideo.length < 1) || (purvideo.length < 1 && audioAndVideo.length < 1)){ if (log) console.info('\nUnable to locate a video AND audio file\n'); return; } if(data.skipmux){ if (log) console.info('Skipping muxing...'); return; } // check exec this.cfg.bin = await yamlCfg.loadBinCfg(); const mergerBin = merger.checkMerger(this.cfg.bin, data.mp4, data.forceMuxer); if ( data.novids ){ if (log) console.info('Video not downloaded. Skip muxing video.'); } const ffext = !data.mp4 ? 'mkv' : 'mp4'; const mergeInstance = new merger({ onlyAudio: puraudio, onlyVid: purvideo, output: `${path.join(this.cfg.dir.content, ...fnOutput)}.${ffext}`, subtitles: stDlPath.map(a => { return { file: a.out as string, language: a.lang, title: a.lang.name, closedCaption: a.closedCaption }; }), videoAndAudio: audioAndVideo, simul: data.simul, skipSubMux: data.skipSubMux, videoTitle: data.videoTitle, options: { ffmpeg: data.ffmpegOptions, mkvmerge: data.mkvmergeOptions }, defaults: { audio: data.defaultAudio, sub: data.defaultSub }, ccTag: data.ccTag }); if(mergerBin.MKVmerge){ await mergeInstance.merge('mkvmerge', mergerBin.MKVmerge); } else if(mergerBin.FFmpeg){ await mergeInstance.merge('ffmpeg', mergerBin.FFmpeg); } else{ if (log) console.info('\nDone!\n'); return true; } if (data.nocleanup) { return true; } mergeInstance.cleanUp(); if (log) console.info('\nDone!\n'); return true; } public async downloadFile(filename: string, chunkList: { segments: Record[], }, timeout: number, partsize: number, fsRetryTime: number, override?: 'Y' | 'y' | 'N' | 'n' | 'C' | 'c', callback?: HLSCallback) { const downloadStatus = await new hlsDownload({ m3u8json: chunkList, output: `${filename + '.ts'}`, timeout: timeout, threads: partsize, fsRetryTime: fsRetryTime * 1000, override, callback }).download(); return downloadStatus.ok; } public async getSubsUrl(m: MediaChild[], parentLanguage: TitleElement|undefined, data: FuniSubsData, episodeID: string, ccTag: string) : Promise { if((data.nosubs && !data.sub) || data.dlsubs.includes('none')){ return []; } const subs = await getData({ baseUrl: 'https://playback.prd.funimationsvc.com/v1/play', url: `/${episodeID}`, token: this.token, useToken: true, debug: this.debug, querystring: { deviceType: 'web' } }); if (!subs.ok || !subs.res || !subs.res.body) { console.error('Subtitle Request failed.'); return []; } const parsed: SubtitleRequest = JSON.parse(subs.res.body); const found: { isCC: boolean; url: string; lang: langsData.LanguageItem; }[] = parsed.primary.subtitles.filter(a => a.fileExt === 'vtt').map(subtitle => { return { isCC: subtitle.contentType === 'cc', url: subtitle.filePath, lang: langsData.languages.find(a => a.funi_locale === subtitle.languageCode || a.locale === subtitle.languageCode) }; }).concat(m.filter(a => a.filePath.split('.').pop() === 'vtt').map(media => { const lang = langsData.languages.find(a => media.language === a.funi_name_lagacy || media.language === (a.funi_name || a.name)); const pLang = langsData.languages.find(a => parentLanguage === a.funi_name_lagacy || (a.funi_name || a.name) === parentLanguage); return { isCC: pLang?.code === lang?.code, url: media.filePath, lang }; })).filter((a) => a.lang !== undefined) as { isCC: boolean; url: string; lang: langsData.LanguageItem; }[]; const ret = found.filter(item => { return data.dlsubs.includes('all') || data.dlsubs.some(a => a === item.lang.locale); }); return ret.map(a => ({ ext: `.${a.lang.code}${a.isCC ? `.${ccTag}` : ''}`, lang: a.lang, url: a.url, closedCaption: a.isCC })); } }