UNPKG

oimi-helper

Version:

this is a helper for ffandown

434 lines (414 loc) 19.1 kB
const FfmpegHelper = require('./core/index') const helper = require('./utils/helper') const dbOperation = require('./sql/index') const path = require('path') const os = require('os') const { v4: uuidv4 } = require('uuid') const log = require('./utils/logger') require('dotenv').config() class Oimi { OUTPUT_DIR maxDownloadNum thread missionList parserPlugins helper verbose dbOperation // event callback stopMission resumeMission eventCallback constructor (OUTPUT_DIR, { thread = true, verbose = false, maxDownloadNum = 5, eventCallback }) { this.helper = helper this.dbOperation = dbOperation if (OUTPUT_DIR) this.OUTPUT_DIR = this.helper.ensurePath(OUTPUT_DIR) this.missionList = [] // 终止的任务 this.stopMission = [] this.resumeMission = [] this.parserPlugins = [] this.thread = thread && this.getCpuNum() this.maxDownloadNum = maxDownloadNum || 5 this.verbose = verbose this.eventCallback = eventCallback } /** * @description register event callback | 注册回调事件 * @param {function} eventCallback */ registerEventCallback (eventCallback) { if (eventCallback && typeof eventCallback === 'function') { this.eventCallback = eventCallback } } /** * @description callback mission status | 回调下载任务的状态 * @param {object} data * @returns {void} */ callbackStatus (data) { if (this.eventCallback && typeof this.eventCallback === 'function') { this.eventCallback(data) } } /** * @description before create mission need operation: download dependency and sync db data | 准备工作 * @returns void */ async ready () { await this.helper.downloadDependency() await this.dbOperation.sync() await this.initMission() } /** * @description get current device cpu numbers * @returns {number} cpu numbers */ getCpuNum () { return os.cpus().length } /** * @description get file download path by name * @param {string} name * @param {string} dir * @param {string} outputFormat * @param {boolean} enableTimeSuffix * @returns {{fileName: string, filePath: string}} path */ getDownloadFilePathAndName (name, dir, outputFormat, enableTimeSuffix = false) { const tm = String(new Date().getTime()) let fileName = name ? name.split('/').pop() : tm const dirPath = path.join(this.OUTPUT_DIR ?? process.cwd(), dir ?? '') this.helper.ensureMediaDir(dirPath) const getFileName = () => { const fileFormat = outputFormat || 'mp4' if (name && enableTimeSuffix) return name + '_' + tm + `.${fileFormat}` if (name && !enableTimeSuffix) return name + `.${fileFormat}` return tm + `.${fileFormat}` } const filePath = path.join(dirPath, getFileName()) return { fileName, filePath } } /** * @description update upload mission data/更新下载任务 * @param {string} uid primary key / 主键 * @param {object} info mission information / 任务信息 * @param {boolean} finish is finish download mission / 是否完成下载任务 */ async updateMission (uid, info, finish = false) { const oldMission = this.missionList.find(i => i.uid === uid) const { percent, currentMbs, timemark, targetSize, status, name, message } = info // status 任务更新为的状态 try { // 下载任务管理内存在下载任务 if (oldMission) { // 如果下载没有完成,并且当前下载任务的状态不是完成状态, 更新任务的状态 // 2. 停止下载 3. 完成下载 4 错误导致下载失败 // 更新的状态值不是完成状态(初始化、下载中、等待中)并且更新的状态为(初始化、下载中、等待中) if (!finish && !['2', '3', '4'].includes(status) && !['2', '3', '4'].includes(oldMission.status)) { oldMission.status = status || '1' // 更新任务的状态:如果状态丢失那么默认为初始化状态 await this.dbOperation.update(uid, { name, percent, speed: currentMbs, timemark, size: targetSize, message, status: status || '1' }) // this.callbackStatus({ uid, status: status || '1' }) } else if ((finish || ['3', '4'].includes(status)) && status !== 2) { // 更新任务状态为下载完成(下载失败、完成下载):只需要更新下载状态 log.info(`mission finished or error happend to be stoped, change current mission status: ${oldMission.status} to ${status}`) oldMission.status = status const updateOption = { status: oldMission.status } // 如果是完成下载,将下载进度更新为 100 if (status === '3') updateOption.percent = '100' // 如果是下载失败,添加错误的信息 if (status === '4') updateOption.message = message await this.dbOperation.update(uid, updateOption) this.callbackStatus({ uid, status: updateOption.status }) // 从missionList内移除任务 this.missionList = this.missionList.filter(i => i.uid !== uid) this.insertNewMission() } else if (!finish && status === '2') { log.info('manual stop mission') // 手动停止下载 oldMission.status = '2' await this.dbOperation.update(uid, { status: '2' }) this.callbackStatus({ uid, status: '2' }) // 终止下载是异步逻辑,需要通过 stopMission内的终止任务的 callback 来回调终止成功的信息 if (this.stopMission.findIndex(i => i.uid === uid) !== -1) { const missionToStop = this.stopMission.find(i => i.uid === uid) missionToStop && missionToStop?.callback() } this.missionList = this.missionList.filter(i => i.uid !== uid) this.insertNewMission() } } else { // 如果没有下载任务管理内不存在任务, 直接更新库的数据 await this.dbOperation.update(uid, { name, percent, speed: currentMbs, timemark, size: targetSize, message, status: status || '1' }) this.callbackStatus({ uid, status: status || '1' }) } } catch (e) { log.error(e) } } /** * @description insert download mission to database for waiting download / 将下载任务添加到数据库状态为等待 * @async * @param {*} mission * @returns {*} */ async insertWaitingMission (mission) { await this.dbOperation.create(mission) } /** * @description 初始化任务:继续下载没有完成的任务,并且如果任务数量没有超过限制,添加等待的下载任务 * @async * @returns {*} */ async initMission () { const allMissions = await this.dbOperation.queryMissionByType('needResume') // 继续恢复下载任务 const missions = allMissions.slice(0, this.maxDownloadNum) for (let mission of missions) { const ffmpegHelper = new FfmpegHelper({ VERBOSE: this.verbose }) this.missionList.push({ ...mission.dataValues, ffmpegHelper }) log.info('initMission for start download') await this.startDownload({ ffmpegHelper, mission, outputformat: '', preset: mission.preset }, false) } } /** * @description insert new mission from waiting mission list * @date 2024/2/23 - 22:55:39 * @param {*} mission */ async insertNewMission () { log.info('insertNewMission') const waitingMissions = await this.dbOperation.queryMissionByType() const missionListLen = this.missionList.length // 插入的任务的数量 log.info('waitingMissions length', waitingMissions.length, 'current Mission List length', missionListLen) const insertMissionNum = this.maxDownloadNum - missionListLen log.info('insertMissionNum: ', insertMissionNum) if (waitingMissions.length > 0) { const insertMissions = waitingMissions.slice(0, insertMissionNum) for (let mission of insertMissions) { log.info('add new mission') const ffmpegHelper = new FfmpegHelper({ VERBOSE: this.verbose }) // mission.dataValues is json data this.missionList.push({ ...mission.dataValues, ffmpegHelper }) await this.startDownload({ ffmpegHelper, mission, outputformat: '', preset: mission.preset }, false) } } } /** * @description 开始下载任务 * * */ async startDownload ({ mission, ffmpegHelper, outputformat, preset }, isNeedInsert = true) { log.info('startDownload') const uid = mission.uid try { if (isNeedInsert) await this.dbOperation.create(mission) ffmpegHelper.setInputFile(mission.url, mission.useragent) ffmpegHelper.setOutputFile(mission.filePath) .setUserAgent(mission.useragent) .setThreads(this.thread) .setPreset(preset) .setOutputFormat(outputformat) .start(params => { // 实时更新任务信息 this.updateMission(uid, { ...mission, status: params.percent >= 100 ? '3' : '1', ...params }) }).then(() => { // todo: create download mission support downloaded callback this.updateMission(uid, { ...mission, percent: 100, status: '3' }, true) }).catch((e) => { log.warn('catched downloading error:', String(e)) // 为什么终止下载会执行多次 catch // 下载中发生错误 if (['ffmpeg was killed with signal SIGKILL', 'ffmpeg exited with code'].some(error => String(e).indexOf(error) !== -1)) { // 任务被暂停 this.updateMission(uid, { ...mission, status: '2', message: 'mission stopped' }) } else { this.updateMission(uid, { ...mission, status: '4', message: String(e) }) } }) return 'mission created' } catch (e) { log.warn('downloading error:', e) await this.updateMission(uid, { ...mission, status: '4', message: String(e) }) } } /** * @description create download mission * @param {object} query url: download url, name: download mission name outputformat */ async createDownloadMission (query) { let enableTimeSuffix = false const { name, url, outputformat, preset, useragent, dir } = query if (query?.enableTimeSuffix !== undefined && typeof query?.enableTimeSuffix === 'boolean') enableTimeSuffix = query.enableTimeSuffix if (!url) throw new Error('url is required') const { fileName, filePath } = this.getDownloadFilePathAndName(name, dir, outputformat, enableTimeSuffix) const mission = { uid: uuidv4(), name: fileName, url, status: '0', filePath, percent: 0, message: '', useragent, } // over max download mission if (this.missionList.length >= this.maxDownloadNum) { log.warn('over max download mission, insert to missionList db', JSON.stringify(this.missionList.map(i => ({ uid: i.uid, name: i.name })))) mission.status = '5' // set mission status is waiting // add misson to db await this.insertWaitingMission(mission) return { uid: mission.uid, name: mission.name } } else { // continue download log.info('start downloading mission') // 创建下载任务实例 const ffmpegHelper = new FfmpegHelper({ VERBOSE: this.verbose }) this.missionList.push({ ...mission, ffmpegHelper }) await this.startDownload({ ffmpegHelper, mission, outputformat, preset }, true) log.verbose(`current missionList have ${this.missionList.length}s missions`) return { uid: mission.uid, name: mission.name } } } /** * @description pause download mission / 暂停下载任务 * @param {string} uid */ async pauseMission (uid) { try { const mission = this.missionList.find(i => i.uid === uid) if (mission) { mission.ffmpegHelper.kill('SIGSTOP') this.updateMission(uid, { ...mission, status: '2' }) } return 'mission paused' } catch (e) { return e } } /** * @description resume download mission/恢复下载任务 * @param {string} uid */ resumeDownload (uid) { return new Promise((resolve, reject) => { (async () => { // 恢复下载任务存在两种情况 missionList里面已经存在数据 直接调用kill('恢复') const mission = this.missionList.find(i => i.uid === uid) if (mission) { mission.ffmpegHelper.kill('SIGCONT') resolve('resume download') } else { const mission = await this.dbOperation.queryOne(uid) if (mission) { try { const suffix = this.helper.getUrlFileExt(mission.filePath) const ffmpegHelper = new FfmpegHelper() this.missionList.push({ ...mission, ffmpegHelper }) await ffmpegHelper.setInputFile(mission.url) .setOutputFile(mission.filePath) .setThreads(this.thread) .setTimeMark(mission.timemark) .setOutputFormat(suffix) .start(params => { this.updateMission(uid, { ...mission, ...params, status: params.percent >= 100 ? '3' : '1', }) resolve('resume download') }).then(() => { // todo: create download mission support downloaded callback this.updateMission(uid, { ...mission, percent: 100, status: '3' }, true) }).catch(e => { log.warn('downloading error:', e) if (['ffmpeg was killed with signal SIGKILL', 'ffmpeg exited with code'].some(error => String(e).indexOf(error) !== -1)) { // 任务被暂停 this.updateMission(uid, { ...mission, status: '2', message: 'mission stopped' }) } else { this.updateMission(uid, { ...mission, status: '4', message: String(e) }) } }) } catch (e) { this.updateMission(uid, { ...mission, status: '4', message: String(e) }) reject(e) } } else { reject(new Error('mission not found')) } } })() }) } /** * @description delete download mission / 删除下载任务v * @param {string} uid */ deleteDownload (uid) { return new Promise((resolve, reject) => { try { // 存在正在进行中的任务,那么需要将任务暂停并且删除掉 const missionIndex = this.missionList.findIndex(i => i.uid === uid) if (missionIndex !== -1) { const mission = this.missionList[missionIndex] mission.ffmpegHelper.kill() // 删除任务 this.missionList.splice(missionIndex, 1) // 数据库内删除 } this.dbOperation.delete(uid).then(() => resolve()).catch(e => reject(e)) } catch (e) { reject(e) } }) } /** * @description stop mission download, mission can be play event though it's not finished download / 终止下载任务 * @param {strig} uid */ stopDownload (uid) { log.info(`stopDownload Mision uid: ${uid}`) return new Promise((resolve, reject) => { try { const mission = this.missionList.find(i => i.uid === uid) if (mission) { log.info(`stop download mission by uid: ${mission?.uid}`) // SIGKILL 下载, 这里需要判断下载任务的类型,才可以使用不同的终止方式 this.stopMission.push({ uid, callback: () => { log.info('成功终止') resolve(0) }, }) mission.ffmpegHelper.kill() } else { log.info(`mission uid: ${uid} is not found in missionList`) // if mission is not found in missionList, to find the mission in db, change the status // 还在等待中的下载任务终止,直接更新内容即可 this.updateMission(uid, { status: '2' }).then(() => resolve(0)).catch((e) => reject(e)) } } catch (e) { reject(e) } }) } /** * @description kill all download mission / 杀死所有的下载任务 */ async killAll () { for (const mission of this.missionList) { mission.ffmpegHelper.kill() if (mission && mission.uid && mission.status === '1') { try { await this.updateMission(mission.uid, { ...mission, status: '2' }) } catch (e) { log.warn(e.message) } } } } } module.exports = Oimi