diff --git a/README.MD b/README.MD index 0fc9f1f..f8bacb0 100644 --- a/README.MD +++ b/README.MD @@ -23,7 +23,7 @@ - 支持指定多个 m3u8 地址批量下载。 - 支持缓存续传。下载失败会保留缓存,重试时只下载失败的片段。 - 支持常见的 AES 加密视频流解密。 -- 自动转换为 mp4。**需全局安装 ffmpeg** +- 自动转换为 mp4。**需全局安装 [ffmpeg](https://ffmpeg.org/download.html)** - `[NEW!]`支持指定采集站标准 API,以命令行交互的方式搜索和下载。 ## 安装(Install) diff --git a/package.json b/package.json index 3606dcc..e8131a2 100644 --- a/package.json +++ b/package.json @@ -45,23 +45,23 @@ }, "devDependencies": { "@lzwme/fed-lint-helper": "^2.3.2", - "@types/node": "^20.2.0", - "@typescript-eslint/eslint-plugin": "^5.59.6", - "@typescript-eslint/parser": "^5.59.6", - "eslint": "^8.40.0", + "@types/node": "^20.3.2", + "@typescript-eslint/eslint-plugin": "^5.60.1", + "@typescript-eslint/parser": "^5.60.1", + "eslint": "^8.43.0", "eslint-config-prettier": "^8.8.0", "eslint-plugin-prettier": "^4.2.1", "husky": "^8.0.3", "prettier": "^2.8.8", "standard-version": "^9.5.0", - "typescript": "^5.0.4" + "typescript": "^5.1.6" }, "dependencies": { "@lzwme/fe-utils": "^1.5.1", - "commander": "^10.0.1", + "commander": "^11.0.0", "console-log-colors": "^0.4.0", "enquirer": "^2.3.6", - "m3u8-parser": "^6.1.0" + "m3u8-parser": "^6.2.0" }, "files": [ "cjs", diff --git a/src/cli.ts b/src/cli.ts index f8eec53..c372525 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,27 +1,21 @@ import { resolve } from 'node:path'; import { program } from 'commander'; -import { cyanBright, color, greenBright, gray, green } from 'console-log-colors'; +import { cyanBright } from 'console-log-colors'; import { PackageJson, readJsonFileSync } from '@lzwme/fe-utils'; -import { prompt } from 'enquirer'; import { logger } from './lib/utils.js'; import { m3u8BatchDownload } from './m3u8-batch-download'; -import type { M3u8DLOptions } from './types/m3u8'; -import { VideoSearch } from './lib/video-search.js'; - -interface POptions extends M3u8DLOptions { - silent?: boolean; - progress?: boolean; -} +import type { CliOptions } from './types/m3u8'; +import { VideoSerachAndDL } from './lib/video-search.js'; const pkg = readJsonFileSync(resolve(__dirname, '../package.json')); process.on('unhandledRejection', (r, p) => { - console.log('[退出]UnhandledPromiseRejection', r, p); + console.log('[退出][unhandledRejection]', r, p); process.exit(); }); process.on('SIGINT', signal => { - logger.info('强制退出', signal); + logger.info('[SIGINT]强制退出', signal); process.exit(); }); @@ -63,7 +57,7 @@ program program.parse(process.argv); function getOptions() { - const options = program.opts(); + const options = program.opts(); if (options.debug) { logger.updateOptions({ levelType: 'debug' }); } else if (options.silent) { @@ -72,108 +66,3 @@ function getOptions() { } return options; } - -async function VideoSerachAndDL(keyword: string, options: { url?: string[] }, baseOpts: POptions): Promise { - const vs = new VideoSearch(); - await vs.updateOptions({ api: options.url || [], force: baseOpts.force }); - const apis = vs.api; - let apiUrl = apis[0]; - - if (!options.url && vs.api.length > 0) { - await prompt<{ k: string }>({ - type: 'select', - name: 'k', - message: '请选择 API 站点', - choices: apis.map(d => ({ name: d.url, message: d.desc })) as never, - validate: value => value.length >= 1, - }).then(v => (apiUrl = vs.apiMap.get(v.k))); - } - - await prompt<{ k: string }>({ - type: 'input', - name: 'k', - message: '请输入关键字', - validate: value => value.length > 1, - initial: keyword, - }).then(v => (keyword = v.k)); - - const sRes = await vs.search(keyword, apiUrl); - logger.debug(sRes); - if (!sRes.total) { - console.log(color.green(`[${keyword}]`), `没有搜到结果`); - return VideoSerachAndDL(keyword, options, baseOpts); - } - - const choices = sRes.list.map((d, idx) => ({ - name: d.vod_id, - message: `${idx + 1}. [${d.type_name}] ${d.vod_name}`, - hint: `${d.vod_remarks}(${d.vod_time})`, - })); - const answer1 = await prompt<{ vid: number }>({ - type: 'select', - name: 'vid', - pointer: '👉', - message: `查找到了 ${color.greenBright(sRes.list.length)} 条结果,请选择:`, - choices: choices.concat({ name: -1, message: greenBright('重新搜索'), hint: '' }) as never, - } as never); - - if (answer1.vid === -1) return VideoSerachAndDL(keyword, options, baseOpts); - - const vResult = await vs.getVideoList(answer1.vid, apiUrl); - if (!vResult.list?.length) { - logger.error('获取视频信息失败!', vResult.msg); - return VideoSerachAndDL(keyword, options, baseOpts); - } else { - const info = vResult.list[0]; - const urls = info.vod_play_url - .split(info.vod_play_note) - .find(d => d.includes('.m3u8')) - .split('#'); - - logger.debug(info, urls); - const r = (key: keyof typeof info, desc: string) => (info[key] ? ` [${desc}] ${greenBright(info[key])}` : ''); - console.log( - [ - `\n [名称] ${cyanBright(info.vod_name)}`, - r('vod_sub', '别名'), - ` [更新] ${greenBright(info.vod_remarks)}(${gray(info.vod_time)})`, - r('vod_total', '总集数'), - r('type_name', '分类'), - r('vod_class', '类别'), - r('vod_writer', '作者'), - r('vod_area', '地区'), - r('vod_lang', '语言'), - r('vod_year', '年份'), - r('vod_douban_score', '评分'), - r('vod_pubdate', '上映日期'), - `\n${green((info.vod_content || info.vod_blurb).replace(/<\/?.+?>/g, ''))}\n`, // 描述 - ] - .filter(Boolean) - .join('\n'), - '\n' - ); - - const answer = await prompt<{ url: string }>({ - type: 'select', - name: 'url', - choices: [ - { name: '1', message: green('全部下载') }, - { name: '-1', message: cyanBright('重新搜索') }, - ].concat(urls.map((d, i) => ({ name: d, message: `${i + 1}. ${d}` }))), - message: `获取到了 ${color.magentaBright(urls.length)} 条视频下载地址,请选择:`, - }); - - if (answer.url !== '-1') { - const p = await prompt<{ play: boolean }>({ - type: 'confirm', - name: 'play', - initial: baseOpts.play, - message: `【${greenBright(info.vod_name)}】是否边下边播?`, - }); - baseOpts.play = p.play; - await m3u8BatchDownload(answer.url === '1' ? urls : [answer.url], { filename: info.vod_name.replaceAll(' ', '_'), ...baseOpts }); - } - - return VideoSerachAndDL(keyword, options, baseOpts); - } -} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index d9f5f81..cf8d774 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -1,13 +1,17 @@ import { LiteStorage } from '@lzwme/fe-utils'; -import { type VSOptions } from './video-search'; import { resolve } from 'node:path'; import { homedir } from 'node:os'; +import { type VSOptions } from './video-search'; +import type { M3u8DLOptions } from '../types'; export interface M3u8StorConfig extends VSOptions { /** 播放地址缓存 */ api?: string[]; + /** 远程加载的配置信息 */ remoteConfig?: { + /** 最近一次更新的时间。默认缓存1小时 */ updateTime?: number; + /** 远程配置缓存 */ data?: { apiSites: { url: string; @@ -17,6 +21,13 @@ export interface M3u8StorConfig extends VSOptions { }[]; }; }; + /** 最近一次搜索下载的信息缓存 */ + latestSearchDL?: { + keyword: string; + name: string; + urls: string[]; + dlOptions: M3u8DLOptions; + }; } export const stor = LiteStorage.getInstance({ uuid: 'm3u8dl', filepath: resolve(homedir(), '.liteStorage/m3u8dl.json') }); diff --git a/src/lib/video-search.ts b/src/lib/video-search.ts index ca2604b..56c0d3c 100644 --- a/src/lib/video-search.ts +++ b/src/lib/video-search.ts @@ -1,7 +1,10 @@ import { Request } from '@lzwme/fe-utils'; -import type { VideoListResult, VideoSearchResult } from '../types'; +import type { VideoListResult, VideoSearchResult, CliOptions } from '../types'; import { stor, type M3u8StorConfig } from './storage.js'; import { logger } from './utils.js'; +import { m3u8BatchDownload } from '../m3u8-batch-download.js'; +import { prompt } from 'enquirer'; +import { cyanBright, color, greenBright, gray, green } from 'console-log-colors'; const req = new Request(null, { 'content-type': 'application/json; charset=UTF-8', @@ -13,6 +16,20 @@ export interface VSOptions { force?: boolean; } +/** + * @example + * ```ts + * const v = new VideoSearch({ api: ['https://api.xinlangapi.com/xinlangapi.php/provide/vod/'] }); + * v.search('三体') + * .then(d => { + * console.log(d.total, d.list); + * return v.getVideoList(d.list[0].vod_id); + * }) + * .then(d => { + * console.log('detail:', d.total, d.list[0]); + * }); + * ``` + */ export class VideoSearch { public apiMap = new Map(); public get api() { @@ -21,9 +38,7 @@ export class VideoSearch { constructor(protected options: VSOptions = {}) { if (!options.api?.length) options.api = []; if (process.env.VAPI) options.api.push(...process.env.VAPI.split('$$$')); - this.updateOptions(options).then(() => { - if (!this.apiMap.size) throw Error('没有可用站点,请添加或指定'); - }); + this.updateOptions(options); } async updateOptions(options: VSOptions) { const cache = stor.get(); @@ -31,9 +46,7 @@ export class VideoSearch { await this.formatUrl(apis); - if (options.api?.length) { - stor.set({ api: [...(cache.api || []), ...options.api] }); - } + if (options.api?.length) stor.set({ api: apis }); (cache.api || []).forEach(url => { this.apiMap.set(url, { url, desc: url }); @@ -41,6 +54,8 @@ export class VideoSearch { await this.updateApiFromRemote(options.force); + if (!this.apiMap.size) throw Error('没有可用的 API 站点,请添加或指定'); + return this; } async search(wd: string, api = this.api[0]) { @@ -101,11 +116,12 @@ export class VideoSearch { logger.debug('加载远程配置', data); if (Array.isArray(data.apiSites)) { - cache.remoteConfig = { - updateTime: Date.now(), - data, - }; - stor.save(cache); + stor.set({ + remoteConfig: { + updateTime: Date.now(), + data, + }, + }); } } @@ -125,12 +141,134 @@ export class VideoSearch { } } -// const v = new VideoSearch({ api: ['https://api.xinlangapi.com/xinlangapi.php/provide/vod/'] }); -// v.search('三体') -// .then(d => { -// console.log(d.total, d.list); -// return v.getVideoList(d.list[0].vod_id); -// }) -// .then(d => { -// console.log('detail:', d.total, d.list[0]); -// }); +export async function VideoSerachAndDL(keyword: string, options: { url?: string[] }, baseOpts: CliOptions): Promise { + const cache = stor.get(); + if (cache.latestSearchDL?.keyword) { + const p = await prompt<{ k: boolean }>({ + type: 'confirm', + name: 'k', + initial: baseOpts.play, + message: `存在上次未完成的下载【${greenBright(cache.latestSearchDL.name)}】,是否继续?`, + }); + + if (p.k) { + await m3u8BatchDownload(cache.latestSearchDL.urls, cache.latestSearchDL.dlOptions); + } + stor.set({ latestSearchDL: null }); + } + + const vs = new VideoSearch(); + await vs.updateOptions({ api: options.url || [], force: baseOpts.force }); + const apis = vs.api; + let apiUrl = options.url?.length ? { url: options.url[0] } : apis[0]; + + if (!options.url && apis.length > 0) { + await prompt<{ k: string }>({ + type: 'select', + name: 'k', + message: '请选择 API 站点', + choices: apis.map(d => ({ name: d.url, message: d.desc })) as never, + validate: value => value.length >= 1, + }).then(v => (apiUrl = apis.find(d => d.url === v.k))); + } + + await prompt<{ k: string }>({ + type: 'input', + name: 'k', + message: '请输入关键字', + validate: value => value.length > 1, + initial: keyword, + }).then(v => (keyword = v.k)); + + const sRes = await vs.search(keyword, apiUrl); + logger.debug(sRes); + if (!sRes.total) { + console.log(color.green(`[${keyword}]`), `没有搜到结果`); + return VideoSerachAndDL(keyword, options, baseOpts); + } + + const choices = sRes.list.map((d, idx) => ({ + name: d.vod_id, + message: `${idx + 1}. [${d.type_name}] ${d.vod_name}`, + hint: `${d.vod_remarks}(${d.vod_time})`, + })); + const answer1 = await prompt<{ vid: number }>({ + type: 'select', + name: 'vid', + pointer: '👉', + message: `查找到了 ${color.greenBright(sRes.list.length)} 条结果,请选择:`, + choices: choices.concat({ name: -1, message: greenBright('重新搜索'), hint: '' }) as never, + } as never); + + if (answer1.vid === -1) return VideoSerachAndDL(keyword, options, baseOpts); + + const vResult = await vs.getVideoList(answer1.vid, apiUrl); + if (!vResult.list?.length) { + logger.error('获取视频信息失败!', vResult.msg); + return VideoSerachAndDL(keyword, options, baseOpts); + } else { + const info = vResult.list[0]; + const urls = info.vod_play_url + .split(info.vod_play_note) + .find(d => d.includes('.m3u8')) + .split('#'); + + logger.debug(info, urls); + const r = (key: keyof typeof info, desc: string) => (info[key] ? ` [${desc}] ${greenBright(info[key])}` : ''); + console.log( + [ + `\n [名称] ${cyanBright(info.vod_name)}`, + r('vod_sub', '别名'), + ` [更新] ${greenBright(info.vod_remarks)}(${gray(info.vod_time)})`, + r('vod_total', '总集数'), + r('type_name', '分类'), + r('vod_class', '类别'), + r('vod_writer', '作者'), + r('vod_area', '地区'), + r('vod_lang', '语言'), + r('vod_year', '年份'), + r('vod_douban_score', '评分'), + r('vod_pubdate', '上映日期'), + `\n${green((info.vod_content || info.vod_blurb).replace(/<\/?.+?>/g, ''))}\n`, // 描述 + ] + .filter(Boolean) + .join('\n'), + '\n' + ); + + const answer = await prompt<{ url: string }>({ + type: 'select', + name: 'url', + choices: [ + { name: '1', message: green('全部下载') }, + { name: '-1', message: cyanBright('重新搜索') }, + ].concat(urls.map((d, i) => ({ name: d, message: `${i + 1}. ${d}` }))), + message: `获取到了 ${color.magentaBright(urls.length)} 条视频下载地址,请选择:`, + }); + + if (answer.url !== '-1') { + const p = await prompt<{ play: boolean }>({ + type: 'confirm', + name: 'play', + initial: baseOpts.play, + message: `【${greenBright(info.vod_name)}】是否边下边播?`, + }); + baseOpts.play = p.play; + try { + cache.latestSearchDL = { + keyword, + name: info.vod_name, + urls: answer.url === '1' ? urls : [answer.url], + dlOptions: { filename: info.vod_name.replaceAll(' ', '_'), ...baseOpts }, + }; + stor.save({ latestSearchDL: cache.latestSearchDL }); + await m3u8BatchDownload(cache.latestSearchDL.urls, cache.latestSearchDL.dlOptions); + stor.set({ latestSearchDL: null }); + } catch (error) { + logger.info('cachel download'); + } + } + + return VideoSerachAndDL(keyword, options, baseOpts); + } +} diff --git a/src/types/m3u8.ts b/src/types/m3u8.ts index 86ddeb2..6f20423 100644 --- a/src/types/m3u8.ts +++ b/src/types/m3u8.ts @@ -58,3 +58,8 @@ export interface WorkerTaskInfo { crypto: M3u8Crypto; options: M3u8DLOptions; } + +export interface CliOptions extends M3u8DLOptions { + silent?: boolean; + progress?: boolean; +}