diff --git a/packages/main/package.json b/packages/main/package.json index a583afa3..299414fb 100644 --- a/packages/main/package.json +++ b/packages/main/package.json @@ -60,6 +60,7 @@ "lodash": "^4.17.21", "node-pty": "^1.0.0", "reflect-metadata": "^0.2.1", + "strip-ansi": "^7.1.0", "ts-node": "^10.9.2", "typeorm": "^0.3.19" } diff --git a/packages/main/src/helper/index.ts b/packages/main/src/helper/index.ts index 9d2b841f..de060963 100644 --- a/packages/main/src/helper/index.ts +++ b/packages/main/src/helper/index.ts @@ -23,7 +23,7 @@ export function getLocalIP() { return localIP; } -export { sleep, stripColors, formatHeaders } from "./utils"; +export { sleep, formatHeaders } from "./utils"; export * from "./variables"; export { on, handle } from "./decorator"; export { convertToAudio } from "./ffmpeg"; diff --git a/packages/main/src/helper/utils.ts b/packages/main/src/helper/utils.ts index f3f346e8..ff33cbed 100644 --- a/packages/main/src/helper/utils.ts +++ b/packages/main/src/helper/utils.ts @@ -4,14 +4,6 @@ export async function sleep(second = 1): Promise { return new Promise((resolve) => setTimeout(resolve, second * 1000)); } -export function stripColors(str: string) { - // 匹配控制台颜色字符的正则表达式 - // eslint-disable-next-line no-control-regex - const colorRegex = /\x1b\[(\d+)(;\d+)*m/g; - // 将所有颜色字符替换为空字符串 - return str.replace(colorRegex, ""); -} - export function formatHeaders(headersStr: string): string { const headers: Record | null = JSON.parse(headersStr); if (!headers) return ""; diff --git a/packages/main/src/interfaces.ts b/packages/main/src/interfaces.ts index 50584c10..d0be1325 100644 --- a/packages/main/src/interfaces.ts +++ b/packages/main/src/interfaces.ts @@ -43,8 +43,7 @@ export type Task = { export interface DownloadProgress { id: number; type: string; - cur: string; - total: string; + percent: string; speed: string; isLive: boolean; } diff --git a/packages/main/src/services/DownloadService.ts b/packages/main/src/services/DownloadService.ts index 7869bb26..a8e36187 100644 --- a/packages/main/src/services/DownloadService.ts +++ b/packages/main/src/services/DownloadService.ts @@ -10,22 +10,118 @@ import { TYPES } from "../types"; import ElectronLogger from "../vendor/ElectronLogger"; import ElectronStore from "../vendor/ElectronStore"; import VideoRepository from "../repository/VideoRepository"; -import { - biliDownloaderBin, - formatHeaders, - isWin, - m3u8DownloaderBin, - stripColors, -} from "../helper"; +import { biliDownloaderBin, m3u8DownloaderBin } from "../helper"; import * as pty from "node-pty"; +import stripAnsi from "strip-ansi"; export interface DownloadOptions { abortSignal: AbortController; encoding?: string; - onMessage?: (message: string) => void; + onMessage?: (ctx: any, message: string) => void; id: number; } +interface Schema { + args: Record; + consoleReg: { + percent: string; + speed: string; + error: string; + start: string; + isLive: string; + }; + bin: string; + platform: string[]; + type: string; +} + +const processList: Schema[] = [ + { + type: "m3u8", + platform: ["win32"], + bin: m3u8DownloaderBin, + args: { + url: { + argsName: null, + }, + localDir: { + argsName: ["--workDir"], + }, + name: { + argsName: ["--saveName"], + }, + // headers: { + // argsName: ["--headers"], + // }, + deleteSegments: { + argsName: ["--enableDelAfterDone"], + }, + proxy: { + argsName: ["--proxyAddress"], + }, + }, + consoleReg: { + percent: "([\\d.]+)%", + speed: "([\\d.]+\\s[GMK]B/s)", + error: "ERROR", + start: "开始下载文件", + isLive: "识别为直播流, 开始录制", + }, + }, + { + type: "m3u8", + platform: ["darwin"], + bin: m3u8DownloaderBin, + args: { + url: { + argsName: null, + }, + localDir: { + argsName: ["--tmp-dir", "--save-dir"], + }, + name: { + argsName: ["--save-name"], + }, + // headers: { + // argsName: ["--headers"], + // }, + deleteSegments: { + argsName: ["--del-after-done"], + }, + proxy: { + argsName: ["--custom-proxy"], + }, + }, + consoleReg: { + percent: "([\\d.]+)%", + speed: "([\\d.]+[GMK]Bps)", + error: "ERROR", + start: "保存文件名:", + isLive: "检测到直播流", + }, + }, + { + type: "bilibili", + platform: ["win32", "darwin"], + bin: biliDownloaderBin, + args: { + url: { + argsName: null, + }, + localDir: { + argsName: ["--work-dir"], + }, + }, + consoleReg: { + speed: "([\\d.]+\\s[GMK]B/s)", + percent: "([\\d.]+)%", + error: "ERROR", + start: "保存文件名:", + isLive: "检测到直播流", + }, + }, +]; + @injectable() export default class DownloadService extends EventEmitter { private queue: Task[] = []; @@ -173,10 +269,11 @@ export default class DownloadService extends EventEmitter { }); if (onMessage) { + const ctx = {}; ptyProcess.onData((data) => { try { this.emit("download-message", id, data); - onMessage(data); + onMessage(ctx, stripAnsi(data)); } catch (err) { reject(err); } @@ -198,65 +295,7 @@ export default class DownloadService extends EventEmitter { }); } - async biliDownloader(params: DownloadParams): Promise { - const { id, abortSignal, url, local, callback } = params; - // const progressReg = /([\d.]+)% .*? ([\d.\w]+?) /g; - const progressReg = /([\d.]+)%/g; - const errorReg = /ERROR/g; - const startDownloadReg = /保存文件名:/g; - const isLiveReg = /检测到直播流/g; - - const spawnParams = [url, "--work-dir", local]; - - const onMessage = (message: string) => { - if (isLiveReg.test(message) || startDownloadReg.test(message)) { - callback({ - id, - type: "ready", - isLive: false, - cur: "", - total: "", - speed: "", - }); - } - - const log = stripColors(message); - if (errorReg.test(log)) { - throw new Error(log); - } - - const result = progressReg.exec(log); - if (!result) { - return; - } - - const [, percentage, speed] = result; - const cur = String(Number(percentage) * 10); - if (cur === "0") { - return; - } - - const total = "1000"; - // FIXME: 无法获取是否为直播流 - const progress: DownloadProgress = { - id, - type: "progress", - cur, - total, - speed, - isLive: false, - }; - callback(progress); - }; - - await this._execa(biliDownloaderBin, spawnParams, { - id, - abortSignal, - onMessage, - }); - } - - async m3u8Downloader(params: DownloadParams): Promise { + async downloader(params: DownloadParams, schema: Schema): Promise { const { id, abortSignal, @@ -268,169 +307,104 @@ export default class DownloadService extends EventEmitter { callback, proxy, } = params; - const progressReg = /([\d.]+)%/g; - const errorReg = /ERROR/g; - const startDownloadReg = /保存文件名:/g; - const isLiveReg = /检测到直播流/g; - const spawnParams = [ - url, - "--tmp-dir", - local, - "--save-dir", - local, - "--save-name", - name, - "--auto-select", - ]; - - if (headers) { - // const h: Record = JSON.parse(headers); - // Object.entries(h).forEach(([k, v]) => { - // spawnParams.push("--header", `${k}: ${v}`); - // }); - } - - if (deleteSegments) { - spawnParams.push("--del-after-done"); - } - - if (proxy) { - spawnParams.push("--custom-proxy", proxy); - } - - let isLive = false; - const onMessage = (message: string) => { - if (isLiveReg.test(message) || startDownloadReg.test(message)) { - callback({ - id, - type: "ready", - isLive, - cur: "", - total: "", - speed: "", - }); - isLive = true; + const spawnParams = []; + for (const key of Object.keys(schema.args)) { + const { argsName } = schema.args[key]; + if (key === "url") { + argsName && spawnParams.push(...argsName); + spawnParams.push(url); } - - const log = stripColors(message); - - if (errorReg.test(log)) { - throw new Error(log); + if (key === "localDir") { + argsName && argsName.forEach((i) => spawnParams.push(i, local)); } - - const result = progressReg.exec(log); - if (!result) { - return; + if (key === "name") { + argsName && argsName.forEach((i) => spawnParams.push(i, name)); } - const [, precentage, speed] = result; - const cur = String(Number(precentage) * 10); - if (cur === "0") { - return; + if (key === "headers") { + if (headers) { + const h: Record = JSON.parse(headers); + Object.entries(h).forEach(([k, v]) => { + spawnParams.push("--header", `${k}: ${v}`); + }); + } } - const total = "1000"; - // FIXME: 无法获取是否为直播流 - const progress: DownloadProgress = { - id, - type: "progress", - cur, - total, - speed, - isLive, - }; - callback(progress); - }; - - await this._execa(m3u8DownloaderBin, spawnParams, { - id, - abortSignal, - onMessage, - }); - } - - async m3u8DownloaderWin32(params: DownloadParams): Promise { - const { - id, - abortSignal, - url, - local, - name, - deleteSegments, - headers, - callback, - proxy, - } = params; - const progressReg = /Progress:\s(\d+)\/(\d+)\s\(.+?\).+?\((.+?\/s).*?\)/g; - const isLiveReg = /识别为直播流, 开始录制/g; - const startDownloadReg = /开始下载文件/g; - - const spawnParams = [url, "--workDir", local, "--saveName", name]; - - if (headers) { - spawnParams.push("--headers", formatHeaders(headers)); - } + if (key === "deleteSegments" && deleteSegments) { + argsName && spawnParams.push(...argsName); + } - if (deleteSegments) { - spawnParams.push("--enableDelAfterDone"); + if (key === "proxy" && proxy) { + argsName && argsName.forEach((i) => spawnParams.push(i, proxy)); + } } - if (proxy) { - spawnParams.push("--proxyAddress", proxy); - } + const { consoleReg } = schema; + const isLiveReg = RegExp(consoleReg.isLive, "g"); + const startDownloadReg = RegExp(consoleReg.start, "g"); + const errorReg = RegExp(consoleReg.error, "g"); + const speedReg = RegExp(consoleReg.speed, "g"); + const percentReg = RegExp(consoleReg.percent, "g"); + + const onMessage = (ctx: any, message: string) => { + // 解析是否为直播资源 + if (isLiveReg.test(message)) { + ctx.isLive = true; + } + // 解析下载进度 + const [, percent] = percentReg.exec(message) || []; + if (percent && Number(ctx.percent || 0) < Number(percent)) { + ctx.percent = percent; + } + // 解析下载速度 + const [, speed] = speedReg.exec(message) || []; + if (speed) { + ctx.speed = speed; + } - let isLive = false; - const onMessage = (message: string) => { - if (isLiveReg.test(message) || startDownloadReg.test(message)) { + if (startDownloadReg.test(message)) { callback({ id, type: "ready", - isLive, - cur: "", - total: "", + isLive: !!ctx.isLive, speed: "", + percent: "", }); - isLive = true; + return; } - const result = progressReg.exec(message); - if (!result) { - return; + if (errorReg.test(message)) { + throw new Error(message); } - const [, cur, total, speed] = result; - const progress: DownloadProgress = { + // FIXME: 无法获取是否为直播流 + callback({ id, type: "progress", - cur, - total, - speed, - isLive, - }; - callback(progress); + percent: ctx.percent || "", + speed: ctx.speed || "", + isLive: !!ctx.isLive, + }); }; - await this._execa(m3u8DownloaderBin, spawnParams, { + await this._execa(schema.bin, spawnParams, { id, abortSignal, onMessage, }); } - process(params: DownloadParams): Promise { - if (params.type === "bilibili") { - return this.biliDownloader(params); - } + async process(params: DownloadParams): Promise { + const program = processList + .filter((i) => i.platform.includes(process.platform)) + .filter((i) => i.type === params.type); - if (params.type === "m3u8") { - if (isWin) { - return this.m3u8DownloaderWin32(params); - } else { - return this.m3u8Downloader(params); - } + if (program.length === 0) { + return Promise.reject(new Error("不支持的下载类型")); } - return Promise.reject(); + const schema = program[0]; + await this.downloader(params, schema); } } diff --git a/packages/renderer/src/components/Terminal/index.tsx b/packages/renderer/src/components/Terminal/index.tsx index 0b19d64f..0852bc2a 100644 --- a/packages/renderer/src/components/Terminal/index.tsx +++ b/packages/renderer/src/components/Terminal/index.tsx @@ -50,10 +50,16 @@ const Terminal: FC = ({ className, title, id, log }) => { } }; + const resize = () => { + fitAddon.fit(); + }; + addIpcListener("download-message", onDownloadMessage); + window.addEventListener("resize", resize); return () => { removeIpcListener("download-message", onDownloadMessage); + window.removeEventListener("resize", resize); terminal.dispose(); }; }, [id]); diff --git a/packages/renderer/src/nodes/HomePage/index.tsx b/packages/renderer/src/nodes/HomePage/index.tsx index 2d246629..9dc3c6a1 100644 --- a/packages/renderer/src/nodes/HomePage/index.tsx +++ b/packages/renderer/src/nodes/HomePage/index.tsx @@ -379,12 +379,14 @@ const HomePage: FC = ({ filter = DownloadFilter.list }) => { const renderDescription = (dom: ReactNode, item: DownloadItem): ReactNode => { if (progress[item.id] && filter === DownloadFilter.list) { const curProgress = progress[item.id]; - const { cur, total, speed } = curProgress; - const percent = Math.round((Number(cur) / Number(total)) * 100); + const { percent, speed } = curProgress; return ( - +
{speed}
); diff --git a/packages/renderer/src/renderer.d.ts b/packages/renderer/src/renderer.d.ts index a0178dbb..49580a10 100644 --- a/packages/renderer/src/renderer.d.ts +++ b/packages/renderer/src/renderer.d.ts @@ -85,9 +85,8 @@ declare interface BrowserStore { declare interface DownloadProgress { id: number; - cur: string; - total: string; speed: string; + percent: string; isLive: boolean; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 754efdee..ac277c0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,6 +127,9 @@ importers: reflect-metadata: specifier: ^0.2.1 version: 0.2.1 + strip-ansi: + specifier: ^7.1.0 + version: 7.1.0 ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.11.5)(typescript@5.3.3)