pull/13/head
士子☀️ 2 years ago
parent 3364b572d1
commit 5641d28f28

@ -1,4 +0,0 @@
VITE_APP_TDID=
VITE_APP_SENTRY_DSN=
NODE_ENV=development

@ -1,4 +0,0 @@
VITE_APP_TDID=
VITE_APP_SENTRY_DSN=
NODE_ENV=production

@ -1,38 +1,10 @@
{
"name": "m3u8-downloader",
"version": "1.0.0",
"description": "m3u8 视频在线提取工具 流媒体下载 m3u8下载 桌面客户端 windows mac。 可以直接在线获取 m3u8 链接地址,无需使用使用网络抓包,无需安装浏览器插件,可以直接带出请求标头……",
"version": "0.6.0",
"description": "m3u8 视频在线提取工具 流媒体下载 m3u8下载 桌面客户端 windows mac。\r 可以直接在线获取 m3u8 链接地址,无需使用使用网络抓包,无需安装浏览器插件,可以直接带出请求标头……",
"main": "index.js",
"dependencies": {
"ast-types-flow": "^0.0.7",
"babel-eslint": "^10.1.0",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.5.0",
"eslint-config-react-app": "^6.0.0",
"eslint-import-resolver-node": "^0.3.6",
"eslint-module-utils": "^2.7.3",
"eslint-plugin-flowtype": "^5.10.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-prettier": "^3.4.1",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.4.0",
"eslint-scope": "^5.1.1",
"eslint-utils": "^2.1.0",
"eslint-visitor-keys": "^1.3.0",
"mime-types": "^2.1.35",
"prop-types": "^15.8.1",
"style-value-types": "^4.1.5",
"typescript": "^4.6.3",
"typescript-compare": "^0.0.2",
"typescript-logic": "^0.0.0",
"typescript-tuple": "^2.2.1"
},
"scripts": {
"dev": "pnpm -F mediago-node run build && pnpm -r --parallel -F app-main -F app-renderer run dev",
"dist": "pnpm -F mediago-node run build && pnpm -r --parallel -F app-main -F app-renderer run dist",
"pack": "pnpm -F app run pack",
"build": "pnpm -F app run build"
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
@ -44,11 +16,9 @@
"url": "https://github.com/caorushizi/m3u8-downloader/issues"
},
"homepage": "https://github.com/caorushizi/m3u8-downloader#readme",
"devDependencies": {
"@babel/core": ">=7.0.0 <8.0.0",
"@typescript-eslint/eslint-plugin": "^4.0.0",
"@typescript-eslint/parser": "^4.0.0",
"prettier": ">=1.13.0",
"rollup": "^1.20.0"
"pnpm": {
"patchedDependencies": {
"react-split-pane@0.1.92": "patches/react-split-pane@0.1.92.patch"
}
}
}

@ -1,6 +0,0 @@
.eslintrc.js
esbuild.config.js
babel.config.js
webpack.config.js
script/
vite.config.ts

@ -1,19 +0,0 @@
module.exports = {
root: true,
env: {
browser: true,
es6: true,
node: true,
},
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "react"],
extends: [
"plugin:@typescript-eslint/recommended",
"react-app",
"plugin:prettier/recommended",
],
rules: {
"@typescript-eslint/ban-ts-comment": "warn",
"prettier/prettier": "error",
},
};

@ -1,5 +0,0 @@
.bin
node_modules
build
.electron
dist

@ -1,31 +0,0 @@
import Store from "electron-store/index";
import { BrowserWindow } from "electron";
import { Windows } from "./utils/variables";
declare interface IWindowManager {
create: (name: Windows) => Promise<BrowserWindow | null>;
get: (name: Windows) => BrowserWindow | null;
has: (name: Windows) => boolean;
deleteById: (id: number) => void;
}
declare interface IWindowListItem {
url: string;
options: () => Electron.BrowserWindowConstructorOptions;
callback: (
window: BrowserWindow,
windowManager: IWindowManager
) => Promise<void>;
}
export { IWindowManager, IWindowListItem };
declare global {
namespace NodeJS {
interface Global {
__bin__: string;
store: Store<AppStore>;
}
}
}

@ -1,39 +0,0 @@
{
"name": "app-main",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "cross-env NODE_ENV=development node script/dev.js",
"dist": "cross-env NODE_ENV=production node script/build.js"
},
"author": "",
"license": "ISC",
"devDependencies": {
"chalk": "^4.1.2",
"cross-env": "^7.0.3",
"dotenv": "^8.2.0",
"electron": "16.0.4",
"esbuild": "^0.9.5",
"rimraf": "^3.0.2"
},
"dependencies": {
"@sentry/electron": "^2.5.4",
"electron-devtools-installer": "^3.2.0",
"electron-is-dev": "^2.0.0",
"electron-log": "^4.3.2",
"electron-squirrel-startup": "^1.0.0",
"electron-store": "^7.0.2",
"electron-updater": "^4.6.1",
"fs-extra": "^10.0.1",
"glob": "^7.1.6",
"mediago-node": "workspace:^1.0.0",
"moment": "^2.29.1",
"nanoid": "^3.1.30",
"qs": "^6.10.1",
"semver": "^7.3.4",
"sequelize": "^6.19.0",
"spawn-args": "^0.2.0",
"sqlite3": "^5.0.6"
}
}

@ -1,37 +0,0 @@
const fs = require("fs");
const { resolve } = require("path");
const rimraf = require("rimraf");
rimraf.sync(resolve(__dirname, "../build"));
process.env.NODE_ENV = "production";
let envPath = resolve(__dirname, `../../../.env.${process.env.NODE_ENV}.local`);
if (!fs.existsSync(envPath)) {
envPath = resolve(__dirname, `../../../.env.${process.env.NODE_ENV}`);
}
const { parsed } = require("dotenv").config({ path: envPath });
const mainDefined = Object.keys(parsed || {}).reduce((prev, cur) => {
prev[`process.env.${[cur]}`] = JSON.stringify(parsed[cur]);
return prev;
}, {});
require("esbuild").build({
entryPoints: [
resolve(__dirname, "../src/index.ts"),
resolve(__dirname, "../src/preload.ts"),
],
bundle: true,
platform: "node",
sourcemap: false,
target: ["node16.13"],
external: ["electron", "pg-hstore"],
outdir: resolve(__dirname, "../dist"),
loader: { ".png": "file" },
define: {
...mainDefined,
},
});

@ -1,92 +0,0 @@
const fs = require("fs");
const { resolve, join } = require("path");
const { spawn } = require("child_process");
const electron = require("electron");
let electronProcess = null;
let manualRestart = false;
process.env.NODE_ENV = "development";
let envPath = resolve(__dirname, `../../../.env.${process.env.NODE_ENV}.local`);
if (!fs.existsSync(envPath)) {
envPath = resolve(__dirname, `../../../.env.${process.env.NODE_ENV}`);
}
require("dotenv").config({ path: envPath });
function startMain() {
return require("esbuild").build({
entryPoints: [
resolve(__dirname, "../src/index.ts"),
resolve(__dirname, "../src/preload.ts"),
],
bundle: true,
platform: "node",
sourcemap: true,
target: ["node16.13"],
external: ["electron", "pg-hstore"],
define: {
// 开发环境中二进制可执行文件的路径
__bin__: `"${resolve(__dirname, "../.bin").replace(/\\/g, "\\\\")}"`,
},
outdir: resolve(__dirname, "../dist"),
loader: { ".png": "file" },
watch: {
onRebuild(error, result) {
if (error) {
console.error("watch build failed:", error);
} else {
console.log("watch build succeed.");
if (electronProcess && electronProcess.kill) {
manualRestart = true;
process.kill(electronProcess.pid);
electronProcess = null;
startElectron();
setTimeout(() => {
manualRestart = false;
}, 5000);
}
}
},
},
});
}
function startElectron() {
let args = ["--inspect=5858", join(__dirname, "../dist/index.js")];
electronProcess = spawn(String(electron), args);
electronProcess.stdout.on("data", (data) => {
electronLog(data, "blue");
});
electronProcess.stderr.on("data", (data) => {
electronLog(data, "red");
});
electronProcess.on("close", () => {
if (!manualRestart) process.exit();
});
}
function electronLog(data, color) {
let log = "";
data = data.toString().split(/\r?\n/);
data.forEach((line) => {
if (line.trim()) log += `${line}\n`;
});
console.log(log);
}
(async () => {
try {
await startMain();
await startElectron();
} catch (e) {
console.error(e);
process.exit();
}
})();

@ -1,69 +0,0 @@
import { windowManager } from "./window";
import { BrowserView } from "electron";
import { Sessions, Windows } from "../utils/variables";
import { nanoid } from "nanoid";
import { sessionList } from "./session";
import logger from "./logger";
const createBrowserView = (): void => {
const browserWindow = windowManager.get(Windows.BROWSER_WINDOW);
const view = new BrowserView({
webPreferences: {
partition: Sessions.PERSIST_MEDIAGO,
},
});
browserWindow.setBrowserView(view);
view.setBounds({ x: 0, y: 0, height: 0, width: 0 });
const { webContents } = view;
if (process.env.NODE_ENV === "development") webContents.openDevTools();
webContents.on("dom-ready", () => {
const title = webContents.getTitle();
const url = webContents.getURL();
browserWindow.webContents.send("dom-ready", { title, url });
webContents.setWindowOpenHandler((details) => {
webContents.loadURL(details.url);
return { action: "deny" };
});
});
const filter = { urls: ["*://*/*"] };
sessionList
.get(Sessions.PERSIST_MEDIAGO)!
.webRequest.onBeforeSendHeaders(
filter,
(
details,
callback: (beforeSendResponse: Electron.BeforeSendResponse) => void
) => {
const m3u8Reg = /\.m3u8$/;
let cancel = false;
const myURL = new URL(details.url);
if (m3u8Reg.test(myURL.pathname)) {
logger.info("在窗口中捕获 m3u8 链接: ", details.url);
const { webContents: mainWindow } = windowManager.get(
Windows.MAIN_WINDOW
);
const value: SourceUrl = {
id: nanoid(),
title: webContents.getTitle(),
url: details.url,
headers: details.requestHeaders,
duration: 0,
};
mainWindow.send("m3u8-notifier", value);
cancel = true;
}
callback({
cancel,
requestHeaders: details.requestHeaders,
});
}
);
};
export default createBrowserView;

@ -1,95 +0,0 @@
import Runner from "./runner";
import glob from "glob";
import { binDir } from "../utils/variables";
import semver from "semver";
import { pathExists } from "fs-extra";
import path from "path";
export class Downloader {
protected bin = ""; // 可执行文件地址
protected args = ""; // runner 参数
constructor(public type: string) {}
handle(runner: Runner): void {
runner.setDownloader(this);
}
async parseArgs(args: Record<string, string>): Promise<void> {
// empty
}
getBin(): string {
return this.bin;
}
getArgs(): string {
return this.args;
}
}
// mediago 下载器
class MediaGoDownloader extends Downloader {
constructor() {
super("mediago");
this.bin = process.platform === "win32" ? "mediago" : "./mediago";
}
async parseArgs(args: Record<string, string>): Promise<void> {
this.args = Object.entries(args)
.reduce((prev: string[], [key, value]) => {
if (value) prev.push(`-${key} "${value}"`);
return prev;
}, [])
.join(" ");
}
}
// N_m3u8DL-CLI 下载器
class NM3u8DlCliDownloader extends Downloader {
constructor() {
super("N_m3u8DL-CLI");
const binNameList = glob.sync("N_m3u8DL-CLI*.exe", {
cwd: binDir,
});
const [version] = binNameList
.map((item) => /N_m3u8DL-CLI_v(.*).exe/.exec(item)?.[1] || "0.0.0")
.filter((item) => semver.valid(item))
.sort((a, b) => (semver.gt(a, b) ? -1 : 1));
if (!version) throw new Error("没有找到 N_m3u8DL-CLI");
this.bin = `N_m3u8DL-CLI_v${version}`;
if (process.platform === "win32") {
this.bin = `${this.bin}.exe`;
}
}
async parseArgs(args: Record<string, string>): Promise<void> {
const binExist = await pathExists(path.resolve(binDir, this.bin));
if (!binExist) throw new Error("没有找到 N_m3u8DL-CLI");
const argsStr = Object.entries(args)
.reduce((prev: string[], [key, value]) => {
if (key === "url") return prev;
if (value && typeof value === "boolean") prev.push(`--${key}`);
if (value && (typeof value === "string" || typeof value === "number"))
prev.push(`--${key} "${value}"`);
return prev;
}, [])
.join(" ");
this.args = `"${args.url}" ${argsStr}`;
}
}
const createDownloader = (type: string): Downloader => {
if (type === "mediago") {
return new MediaGoDownloader();
}
if (type === "N_m3u8DL-CLI") {
return new NM3u8DlCliDownloader();
}
throw new Error("暂不支持该下载方式");
};
export { createDownloader };

@ -1,12 +0,0 @@
import path from "path";
import moment from "moment";
import logger from "electron-log";
import { workspace } from "../utils/variables";
const datetime = moment().format("YYYY-MM-DD");
const logPath = path.resolve(workspace, `logs/${datetime}-mediago.log`);
logger.transports.console.format = "{h}:{i}:{s} {text}";
logger.transports.file.getFile();
logger.transports.file.resolvePath = () => logPath;
export default logger;

@ -1,61 +0,0 @@
import { spawn, SpawnOptions } from "child_process";
import { workspace } from "../utils/variables";
import argsBuilder from "spawn-args";
import logger from "../core/logger";
import { Downloader } from "./downloader";
// runner
class Runner {
private static instance: Runner;
private constructor(private downloader?: Downloader) {}
static getInstance(): Runner {
if (!Runner.instance) {
Runner.instance = new Runner();
}
return Runner.instance;
}
setDownloader(downloader: Downloader): void {
this.downloader = downloader;
}
run(options: SpawnOptions): Promise<void> {
const command = this.downloader?.getBin();
const args = this.downloader?.getArgs();
if (!command || !args) throw new Error("请先初始化downloader");
logger.info("下载参数:", options.cwd, command, args);
return new Promise((resolve, reject) => {
const spawnCommand = spawn(command, argsBuilder(args), {
cwd: workspace,
detached: true,
shell: true,
...options,
});
spawnCommand.stdout?.on("data", (data) => {
const value = data.toString().trim();
console.log(`stdout: ${value}`);
});
spawnCommand.stderr?.on("data", (data) => {
const value = data.toString().trim();
console.error(`stderr: ${value}`);
});
spawnCommand.on("close", (code) => {
if (code !== 0) reject(new Error(`调用 ${command} 可执行文件执行失败`));
else resolve();
});
spawnCommand.on("error", (err) => {
console.error(`err: ${err}`);
});
});
}
}
export default Runner;

@ -1,10 +0,0 @@
import { session, Session } from "electron";
import { Sessions } from "../utils/variables";
const sessionList = new Map<Sessions, Session>();
function createSession(partition: Sessions): void {
sessionList.set(partition, session.fromPartition(partition));
}
export { sessionList, createSession };

@ -1,101 +0,0 @@
import { BrowserWindow } from "electron";
import { Windows } from "../utils/variables";
import { resolve } from "path";
import { IWindowListItem, IWindowManager } from "../../main";
const windowList = new Map<Windows, IWindowListItem>();
windowList.set(Windows.MAIN_WINDOW, {
url:
process.env.NODE_ENV === "development"
? "http://localhost:7789/main"
: "mediago://index.html/main",
options(): Electron.BrowserWindowConstructorOptions {
return {
width: 800,
minWidth: 800,
height: 600,
minHeight: 600,
show: false,
frame: false,
webPreferences: {
nodeIntegration: true,
contextIsolation: true,
preload: resolve(__dirname, "./preload.js"),
},
};
},
async callback(window) {
if (process.env.NODE_ENV === "development") {
window.webContents.openDevTools();
}
window.once("ready-to-show", () => {
window.show();
});
},
});
windowList.set(Windows.BROWSER_WINDOW, {
url:
process.env.NODE_ENV === "development"
? "http://localhost:7789/browser"
: "mediago://index.html/browser",
options(): Electron.BrowserWindowConstructorOptions {
return {
width: 800,
height: 600,
show: false,
frame: false,
webPreferences: {
nodeIntegration: true,
contextIsolation: true,
preload: resolve(__dirname, "./preload.js"),
},
};
},
async callback(window) {
if (process.env.NODE_ENV === "development") {
window.webContents.openDevTools();
}
},
});
class Window implements IWindowManager {
private windowMap: Map<Windows | string, BrowserWindow> = new Map();
private windowIdMap: Map<number, Windows | string> = new Map();
async create(name: Windows) {
const windowConfig: IWindowListItem = windowList.get(name)!;
const window = new BrowserWindow(windowConfig.options());
const { id } = window;
this.windowMap.set(name, window);
this.windowIdMap.set(window.id, name);
window.loadURL(windowConfig.url);
windowConfig.callback(window, this);
window.on("close", () => {
this.deleteById(id);
});
return window;
}
get(name: Windows) {
return this.windowMap.get(name)!;
}
has(name: Windows) {
return this.windowMap.has(name);
}
deleteById = (id: number) => {
const name = this.windowIdMap.get(id);
if (name) {
this.windowMap.delete(name);
this.windowIdMap.delete(id);
}
};
}
const windowManager = new Window();
export { windowManager, windowList };

@ -1,17 +0,0 @@
import { db } from "../utils/variables";
import { DataTypes, Sequelize } from "sequelize";
export const sequelize = new Sequelize({
dialect: "sqlite",
storage: db,
});
export const Favorite = sequelize.define("Favorite", {
title: DataTypes.STRING,
url: DataTypes.STRING,
});
export const Video = sequelize.define("Video", {
title: DataTypes.STRING,
url: DataTypes.STRING,
});

@ -1,5 +0,0 @@
import { sequelize } from "./db";
(async function () {
await sequelize.sync();
})();

@ -1,16 +0,0 @@
// import installExtension, {
// REACT_DEVELOPER_TOOLS,
// REDUX_DEVTOOLS,
// } from "electron-devtools-installer";
import logger from "../core/logger";
export default async function handleExtension(): Promise<void> {
if (process.env.NODE_ENV === "development") {
try {
// await installExtension(REACT_DEVELOPER_TOOLS);
// await installExtension(REDUX_DEVTOOLS);
} catch (e) {
logger.info(e);
}
}
}

@ -1,142 +0,0 @@
import { app, ipcMain, Menu } from "electron";
import { failFn, successFn } from "../utils";
import { windowManager } from "../core/window";
import { binDir, Windows } from "../utils/variables";
import Runner from "../core/runner";
import { createDownloader } from "../core/downloader";
import { downloader as mediaNode } from "mediago-node";
const handleIpc = (): void => {
ipcMain.on("close-main-window", async () => {
app.quit();
});
ipcMain.on("open-browser-window", (e, url) => {
// 开始计算主窗口的位置
const browserWindow = windowManager.get(Windows.BROWSER_WINDOW);
const browserView = browserWindow.getBrowserView();
browserView?.webContents.loadURL(url || "https://baidu.com");
browserWindow.show();
});
ipcMain.on("close-browser-window", () => {
const browserWindow = windowManager.get(Windows.BROWSER_WINDOW);
browserWindow.hide();
});
ipcMain.on("set-browser-view-bounds", (e, rect) => {
const currentWindow = windowManager.get(Windows.BROWSER_WINDOW);
const view = currentWindow.getBrowserView();
if (view) view.setBounds(rect);
});
ipcMain.on("browser-view-go-back", (e) => {
const currentWindow = windowManager.get(Windows.BROWSER_WINDOW);
const view = currentWindow.getBrowserView();
if (view) {
const canGoBack = view.webContents.canGoBack();
if (canGoBack) view.webContents.goBack();
}
});
ipcMain.on("browser-view-reload", (e) => {
const currentWindow = windowManager.get(Windows.BROWSER_WINDOW);
const view = currentWindow.getBrowserView();
if (view) view.webContents.reload();
});
ipcMain.on("browser-view-load-url", (e, url: string) => {
const currentWindow = windowManager.get(Windows.BROWSER_WINDOW);
const view = currentWindow.getBrowserView();
if (view) view.webContents.loadURL(url || "https://baidu.com");
});
ipcMain.on("open-download-item-context-menu", (e, item: SourceItem) => {
const mainWin = windowManager.get(Windows.MAIN_WINDOW);
const menu = Menu.buildFromTemplate([
{
label: "详情",
click: () => {
e.sender.send("download-context-menu-detail", item);
},
},
{ type: "separator" },
{
label: "下载",
click: () => {
e.sender.send("download-context-menu-download", item);
},
},
{
label: "删除",
click: () => {
e.sender.send("download-context-menu-delete", item);
},
},
{ type: "separator" },
{
label: "清空列表",
click: () => {
e.sender.send("download-context-menu-clear-all");
},
},
]);
menu.popup({
window: mainWin,
});
});
ipcMain.on("window-minimize", (e, name) => {
let window;
if (name === "main") {
window = windowManager.get(Windows.MAIN_WINDOW);
} else {
window = windowManager.get(Windows.BROWSER_WINDOW);
}
window.minimize();
});
ipcMain.handle(
"exec-command",
async (event, exeFile: string, args: Record<string, string>) => {
try {
if (exeFile === "mediago") {
await mediaNode({
name: args["name"],
path: args["path"],
url: args["url"],
});
return successFn("success");
}
const runner = Runner.getInstance();
const downloader = createDownloader(exeFile);
downloader.handle(runner);
await downloader.parseArgs(args);
const result = await runner.run({ cwd: binDir });
return successFn(result);
} catch (e: any) {
return failFn(-1, e.message);
}
}
);
ipcMain.handle("get-bin-dir", async () => binDir);
ipcMain.handle("set-store", (e, key, value) => global.store.set(key, value));
ipcMain.handle("get-store", (e, key) => global.store.get(key));
ipcMain.handle("get-path", (e, name) => app.getPath(name));
ipcMain.handle("get-current-window", (e) => {
const currentWindow = windowManager.get(Windows.BROWSER_WINDOW);
return currentWindow.getBrowserView();
});
ipcMain.handle("ipc:data", (e, { path: string, params: any }) => {
console.log(123);
});
};
export default handleIpc;

@ -1,43 +0,0 @@
import { protocol } from "electron";
import { defaultScheme } from "../utils/variables";
import { URL } from "url";
import { readFile, pathExists } from "fs-extra";
import { extname, join } from "path";
import isDev from "electron-is-dev";
export default function handleProtocol() {
if (isDev) return;
protocol.registerBufferProtocol(defaultScheme, async (request, callback) => {
let pathName = new URL(request.url).pathname;
pathName = decodeURI(pathName);
const filePath = join(__dirname, "../renderer", pathName);
const fileExist = await pathExists(filePath);
if (fileExist) {
const data = await readFile(filePath);
const extension = extname(pathName).toLowerCase();
let mimeType = "";
if (extension === ".js") {
mimeType = "text/javascript";
} else if (extension === ".html") {
mimeType = "text/html";
} else if (extension === ".css") {
mimeType = "text/css";
} else if (extension === ".svg" || extension === ".svgz") {
mimeType = "image/svg+xml";
} else if (extension === ".json") {
mimeType = "application/json";
}
callback({ mimeType, data });
} else {
// 如果没有找到文件,直接返回 index.html react history 模式
const filePath = join(__dirname, "../renderer/index.html");
const data = await readFile(filePath);
callback({ mimeType: "text/html", data });
}
});
}

@ -1,54 +0,0 @@
import Store from "electron-store";
import { Sessions, workspace } from "../utils/variables";
import { sessionList } from "../core/session";
import logger from "../core/logger";
export default function handleStore(): void {
let exeFile = "";
if (process.platform === "win32") {
exeFile = "N_m3u8DL-CLI";
} else {
exeFile = "mediago";
}
global.store = new Store<AppStore>({
name: "config",
cwd: workspace,
fileExtension: "json",
watch: true,
defaults: {
workspace: "",
exeFile,
tip: true,
proxy: "",
useProxy: false,
statistics: true,
},
});
// 设置软件代理
const setProxy = (isInit?: boolean) => {
try {
const webviewSession = sessionList.get(Sessions.PERSIST_MEDIAGO)!;
const proxy = global.store.get("proxy");
const useProxy = global.store.get("useProxy");
if (proxy && useProxy) {
logger.info(
`[proxy] ${isInit ? "初始化" : "开启"}成功,代理地址为${proxy}`
);
webviewSession.setProxy({ proxyRules: proxy });
} else {
if (!isInit) logger.info(`[proxy] 关闭成功`);
webviewSession.setProxy({});
}
} catch (e: any) {
logger.error(
`[proxy] ${isInit ? "初始化" : ""}设置代理失败:\n${e.message}`
);
}
};
setProxy(true);
global.store.onDidChange("useProxy", setProxy);
}

@ -1,22 +0,0 @@
import { autoUpdater } from "electron-updater";
import logger from "../core/logger";
import { protocol } from "electron";
import { defaultScheme } from "../utils/variables";
protocol.registerSchemesAsPrivileged([
{
scheme: defaultScheme,
privileges: {
secure: true,
standard: true,
},
},
]);
export default function handleUpdater(): void {
autoUpdater.logger = logger;
autoUpdater.checkForUpdatesAndNotify({
title: "发现新版本",
body: "已经下载完成,下次打开时安装~",
});
}

@ -1,11 +0,0 @@
import { windowManager } from "../core/window";
import { Windows } from "../utils/variables";
import createBrowserView from "../core/browser";
export default async function handleWindows(): Promise<void> {
await Promise.all([
windowManager.create(Windows.MAIN_WINDOW),
await windowManager.create(Windows.BROWSER_WINDOW),
]);
await createBrowserView();
}

@ -1,49 +0,0 @@
import { app, BrowserWindow, crashReporter } from "electron";
import handleIpc from "./helper/handleIpc";
import { Sessions } from "./utils/variables";
import handleStore from "./helper/handleStore";
import handleExtension from "./helper/handleExtension";
import handleWindows from "./helper/handleWindows";
import * as Sentry from "@sentry/electron/dist/main";
import { author, name } from "../package.json";
import { createSession } from "./core/session";
import handleProtocol from "./helper/handleProtocol";
import handleUpdater from "./helper/handleUpdater";
import "./db";
Sentry.init({ dsn: process.env.VITE_APP_SENTRY_DSN });
if (process.env.VITE_APP_SENTRY_DSN) {
crashReporter.start({
companyName: author,
productName: name,
ignoreSystemCrashHandler: true,
submitURL: process.env.VITE_APP_SENTRY_DSN,
});
}
if (require("electron-squirrel-startup")) {
app.quit();
}
app.whenReady().then(async () => {
app.on("activate", async () => {
if (BrowserWindow.getAllWindows().length === 0) {
await handleWindows();
}
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
handleUpdater();
handleProtocol();
createSession(Sessions.PERSIST_MEDIAGO);
handleWindows();
handleExtension();
handleStore();
handleIpc();
});

@ -1,52 +0,0 @@
import { contextBridge, dialog, ipcRenderer, shell } from "electron";
import { resolve } from "path";
const apiKey = "electron";
const api: ElectronApi = {
store: {
set(key, value) {
return ipcRenderer.invoke("set-store", key, value);
},
get(key) {
return ipcRenderer.invoke("get-store", key);
},
},
isWindows: process.platform === "win32",
isMacos: process.platform === "darwin",
ipcExec: (exeFile, args) => ipcRenderer.invoke("exec-command", exeFile, args),
openBinDir: async () => {
const binDir = await ipcRenderer.invoke("get-bin-dir");
await shell.openPath(binDir);
},
openPath: (workspace) => shell.openPath(workspace),
openConfigDir: async () => {
const appName =
process.env.NODE_ENV === "development"
? "media downloader dev"
: "media downloader";
const appPath = await ipcRenderer.invoke("get-path", "appData");
await shell.openPath(resolve(appPath, appName));
},
openExternal: (url, options) => shell.openExternal(url, options),
openBrowserWindow: (url) => ipcRenderer.send("open-browser-window", url),
closeBrowserWindow: () => ipcRenderer.send("close-browser-window"),
getPath: (name) => ipcRenderer.invoke("get-path", name),
showOpenDialog: (options) => {
return dialog.showOpenDialog(options);
},
getBrowserView: () => ipcRenderer.invoke("get-current-window"),
addEventListener: (channel, listener) => ipcRenderer.on(channel, listener),
removeEventListener: (channel, listener) =>
ipcRenderer.removeListener(channel, listener),
setBrowserViewRect: (rect) =>
ipcRenderer.send("set-browser-view-bounds", rect),
closeMainWindow: () => ipcRenderer.send("close-main-window"),
browserViewGoBack: () => ipcRenderer.send("browser-view-go-back"),
browserViewReload: () => ipcRenderer.send("browser-view-reload"),
browserViewLoadURL: (url) => ipcRenderer.send("browser-view-load-url", url),
itemContextMenu: (item) =>
ipcRenderer.send("open-download-item-context-menu", item),
minimize: (name) => ipcRenderer.send("window-minimize", name),
};
contextBridge.exposeInMainWorld(apiKey, api);

@ -1 +0,0 @@
declare module "spawn-args";

@ -1,8 +0,0 @@
const successFn = (data: unknown): IpcResponse => ({ code: 0, msg: "", data });
const failFn = (code: number, msg: string): IpcResponse => ({
code,
msg,
data: null,
});
export { successFn, failFn };

@ -1,29 +0,0 @@
import { app } from "electron";
import path, { resolve } from "path";
declare const __bin__: string;
if (process.env.NODE_ENV !== "development") {
global.__bin__ = resolve(app.getAppPath(), "../.bin").replace(/\\/g, "\\\\");
}
export const appData = app.getPath("appData");
export const appName =
process.env.NODE_ENV === "development"
? "media downloader dev"
: "media downloader";
export const workspace = path.resolve(appData, appName);
export const defaultScheme = "mediago";
export enum Windows {
MAIN_WINDOW = "MAIN_WINDOW",
BROWSER_WINDOW = "BROWSER_WINDOW",
}
export enum Sessions {
PERSIST_MEDIAGO = "persist:mediago",
}
export const binDir = __bin__;
export const db = path.resolve(workspace, "database.sqlite");

@ -1,23 +0,0 @@
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"baseUrl": "./src",
"resolveJsonModule": true,
"typeRoots": [
"node_modules/@types",
"src/types"
]
},
"include": [
"./src/**/*"
],
"files": [
"./main.d.ts",
"../global.d.ts"
]
}

@ -1,6 +0,0 @@
.eslintrc.js
esbuild.config.js
babel.config.js
webpack.config.js
script/
vite.config.ts

@ -1,19 +0,0 @@
module.exports = {
root: true,
env: {
browser: true,
es6: true,
node: true,
},
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "react"],
extends: [
"plugin:@typescript-eslint/recommended",
"react-app",
"plugin:prettier/recommended",
],
rules: {
"@typescript-eslint/ban-ts-comment": "warn",
"prettier/prettier": "error",
},
};

@ -1,12 +0,0 @@
node_modules
.DS_Store
dist
dist-ssr
.idea
.bin
.vscode
yarn.lock
yarn-error.log
build
devtools/*

@ -1,14 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="referrer" content="never">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>在线视频下载</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

@ -1,82 +0,0 @@
{
"name": "app-renderer",
"version": "1.1.5",
"description": "在线视频下载",
"author": "caorushizi",
"main": "dist/main/index.js",
"license": "MIT",
"homepage": "./",
"scripts": {
"dev": "cross-env NODE_ENV=development vite",
"dist": "cross-env NODE_ENV=production tsc && vite build"
},
"dependencies": {
"@ant-design/icons": "^4.6.2",
"@ant-design/pro-form": "^1.49.6",
"@ant-design/pro-table": "^2.59.2",
"@chakra-ui/react": "^1.7.2",
"@emotion/react": "^11",
"@emotion/styled": "^11",
"@sentry/electron": "^2.5.4",
"@sentry/react": "^6.16.1",
"@sentry/tracing": "^6.16.1",
"antd": "^4.17.2",
"classnames": "^2.3.1",
"connected-react-router": "^6.9.1",
"framer-motion": "^4",
"history": "^4.10.1",
"localforage": "^1.9.0",
"m3u8-parser": "^4.7.0",
"moment": "^2.29.1",
"nanoid": "^3.1.30",
"prop-types": "^15.7.2",
"qs": "^6.10.1",
"re-resizable": "^6.9.1",
"react": "^17.0.0",
"react-dom": "^17.0.0",
"react-file-drop": "^3.1.2",
"react-redux": "^7.2.4",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-virtualized-auto-sizer": "^1.0.6",
"react-window": "^1.8.6",
"redux": "^4.1.1",
"redux-devtools-extension": "^2.13.9",
"redux-saga": "^1.1.3"
},
"devDependencies": {
"@rollup/plugin-eslint": "^8.0.1",
"@types/glob": "^7.1.3",
"@types/node": "^14.14.35",
"@types/prop-types": "^15.7.5",
"@types/qs": "^6.9.7",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react-redux": "^7.1.24",
"@types/react-router": "^5.1.16",
"@types/react-router-dom": "^5.1.8",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@typescript-eslint/eslint-plugin": "^4.0.0",
"@typescript-eslint/parser": "^4.0.0",
"@vitejs/plugin-react-refresh": "^1.3.1",
"babel-eslint": "^10.0.0",
"cross-env": "^7.0.3",
"electron": "16.0.4",
"eslint": "^7.5.0",
"eslint-config-prettier": "^8.1.0",
"eslint-config-react-app": "^6.0.0",
"eslint-plugin-flowtype": "^5.2.0",
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-jsx-a11y": "^6.3.1",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.20.3",
"eslint-plugin-react-hooks": "^4.0.8",
"less": "^4.1.2",
"prettier": "2.2.1",
"rimraf": "^3.0.2",
"sass": "^1.32.8",
"typescript": "^4.2.4",
"vite": "^2.5.0"
}
}

@ -1,140 +0,0 @@
interface TdApp {
onEvent: (eventId: string, label: string, mapKv: any) => void;
}
declare module "history";
interface ElectronIs {
readonly macos: boolean;
readonly linux: boolean;
readonly windows: boolean;
readonly main: boolean;
readonly renderer: boolean;
readonly usingAsar: boolean;
readonly development: boolean;
readonly macAppStore: boolean;
readonly windowsStore: boolean;
}
// M3u8DL 全部参数
declare interface M3u8DLArgs {
url: string; // 视频地址
workDir: string; // 设定程序工作目录
saveName: string; // 设定存储文件名(不包括后缀)
baseUrl?: string; // 设定Baseurl
headers?: string; // 设定请求头,格式 key:value 使用|分割不同的key&value
maxThreads?: number; // 设定程序的最大线程数(默认为32)
minThreads?: number; // 设定程序的最小线程数(默认为16)
retryCount?: number; // 设定程序的重试次数(默认为15)
timeOut?: number; // 设定程序网络请求的超时时间(单位为秒默认为10秒)
muxSetJson?: string; // 使用外部json文件定义混流选项
useKeyFile?: string; // 使用外部16字节文件定义AES-128解密KEY
useKeyBase64?: string; // 使用Base64字符串定义AES-128解密KEY
useKeyIV?: string; // 使用HEX字符串定义AES-128解密IV
downloadRange?: string; // 仅下载视频的一部分分片或长度
liveRecDur?: string; // 直播录制时,达到此长度自动退出软件
stopSpeed?: number; // 当速度低于此值时,重试(单位为KB/s)
maxSpeed?: number; // 设置下载速度上限(单位为KB/s)
proxyAddress?: string; // 设置HTTP代理, 如 http://127.0.0.1:8080
enableDelAfterDone?: boolean; // 开启下载后删除临时文件夹的功能
enableMuxFastStart?: boolean; // 开启混流mp4的FastStart特性
enableBinaryMerge?: boolean; // 开启二进制合并分片
enableParseOnly?: boolean; // 开启仅解析模式(程序只进行到meta.json)
enableAudioOnly?: boolean; // 合并时仅封装音频轨道
disableDateInfo?: boolean; // 关闭混流中的日期写入
noMerge?: boolean; // 禁用自动合并
noProxy?: boolean; // 不自动使用系统代理
}
// mediago 全部参数
declare interface MediaGoArgs {
path: string;
name: string;
url: string;
headers?: string;
}
interface IpcRendererResp {
code: number;
msg: string;
data: any;
}
interface BrowserViewRect {
x: number;
y: number;
height: number;
width: number;
}
declare interface Window {
electron: Readonly<ElectronApi>;
TDAPP: TdApp;
}
declare interface Manifest {
allowCache: boolean;
endList: boolean;
mediaSequence: number;
discontinuitySequence: number;
playlistType: string;
custom: Record<string, unknown>;
playlists: [
{
attributes: Record<string, unknown>;
Manifest: Manifest;
}
];
mediaGroups: {
AUDIO: {
"GROUP-ID": {
NAME: {
default: boolean;
autoselect: boolean;
language: string;
uri: string;
instreamId: string;
characteristics: string;
forced: boolean;
};
};
};
VIDEO: Record<string, unknown>;
"CLOSED-CAPTIONS": Record<string, unknown>;
SUBTITLES: Record<string, unknown>;
};
dateTimeString: string;
dateTimeObject: Date;
targetDuration: number;
totalDuration: number;
discontinuityStarts: [number];
segments: [
{
byterange: {
length: number;
offset: number;
};
duration: number;
attributes: Record<string, unknown>;
discontinuity: number;
uri: string;
timeline: number;
key: {
method: string;
uri: string;
iv: string;
};
map: {
uri: string;
byterange: {
length: number;
offset: number;
};
};
"cue-out": string;
"cue-out-cont": string;
"cue-in": string;
custom: Record<string, unknown>;
}
];
}

@ -1,39 +0,0 @@
$border-color: rgb(217, 217, 217);
.header-edit {
* {
user-select: none;
}
.header-field-container {
border: 1px solid $border-color;
.header-item-container {
border-bottom: 1px solid $border-color;
&:last-child {
border-bottom: 0;
}
.ant-select {
.ant-select-selector {
border-radius: 0;
border: 0;
border-right: 1px solid rgb(217, 217, 217);
}
}
.ant-input {
border-radius: 0;
border: 0;
margin-left: 1px;
}
.form-item-action {
display: flex;
align-items: center;
justify-content: center;
}
}
}
}

@ -1,194 +0,0 @@
import "./index.scss";
import React, { FC, useEffect, useState } from "react";
import { AutoComplete, Form, Input, Row, Col, Button } from "antd";
import { DeleteOutlined, PlusOutlined } from "@ant-design/icons";
import { Box } from "@chakra-ui/react";
import { nanoid } from "nanoid";
export interface HeaderEditProps {
label: string;
name: string;
valuePropName?: string;
placeholder?: string;
}
interface HeaderFieldInputProps {
value?: Record<string, string>;
onChange?: (value: Record<string, string>) => void;
}
const options = [
{ value: "Accept" },
{ value: "Accept-Charset" },
{ value: "Accept-Encoding" },
{ value: "Accept-Language" },
{ value: "Accept-Ranges" },
{ value: "Authorization" },
{ value: "Cache-Control" },
{ value: "Connection" },
{ value: "Cookie" },
{ value: "Content-Length" },
{ value: "Content-Type" },
{ value: "Date" },
{ value: "From" },
{ value: "Host" },
{ value: "If-Match" },
{ value: "If-Modified-Since" },
{ value: "If-None-Match" },
{ value: "If-Range" },
{ value: "If-Unmodified-Since" },
{ value: "Max-Forwards" },
{ value: "Pragma" },
{ value: "Proxy-Authorization" },
{ value: "Range" },
{ value: "Referer" },
{ value: "TE" },
{ value: "Upgrade" },
{ value: "User-Agent" },
{ value: "Via" },
{ value: "Warning" },
];
interface FormItem {
id: string;
key: string;
value: string;
}
const renderItem = (
item: FormItem,
onChange: (item: FormItem) => void,
onDelete: (item: FormItem) => void
) => {
return (
<Row className={"header-item-container"} key={item.id}>
<Col span={8}>
<AutoComplete
value={item.key}
style={{ width: "100%" }}
options={options}
dropdownMatchSelectWidth={false}
filterOption={(inputValue, option) => {
if (!option) return false;
return (
option.value.toUpperCase().indexOf(inputValue.toUpperCase()) !==
-1
);
}}
onChange={(value) => {
onChange({ ...item, key: value });
}}
/>
</Col>
<Col span={14}>
<Input
value={item.value}
onChange={(e) => {
onChange({ ...item, value: e.target.value });
}}
/>
</Col>
<Col span={1} className={"form-item-action"}>
<DeleteOutlined
style={{ color: "#F56C6C", cursor: "pointer" }}
onClick={() => onDelete(item)}
/>
</Col>
</Row>
);
};
// 编辑 header
const HeaderFieldInput: FC<HeaderFieldInputProps> = ({ value, onChange }) => {
const [formValues, setFormValues] = useState<FormItem[]>([]);
useEffect(() => {
const values = processHeader(value);
setFormValues(values);
}, []);
const processHeader = (value?: Record<string, string>) => {
if (!value) return [];
return Object.entries(value).map(([key, value]) => ({
id: nanoid(),
key,
value,
}));
};
const postHeader = (values: FormItem[]) => {
return values.reduce((cur: Record<string, string>, prev) => {
if (!prev.key) return cur;
cur[prev.key] = prev.value;
return cur;
}, {});
};
const onInputChange = (item: FormItem): void => {
const copiedFormValues = formValues.slice();
const changeItem = copiedFormValues.find((i) => i.id === item.id);
if (!changeItem) return;
changeItem.key = item.key;
changeItem.value = item.value;
setFormValues(copiedFormValues);
onChange?.(postHeader(copiedFormValues));
};
const onInputDelete = (item: FormItem) => {
const changeItemIndex = formValues.findIndex((i) => i.id === item.id);
if (changeItemIndex < 0) return;
const copiedFormValues = formValues.slice();
copiedFormValues.splice(changeItemIndex, 1);
setFormValues(copiedFormValues);
onChange?.(postHeader(copiedFormValues));
};
const onInputAdd = () => {
const changedValue = [...formValues, { id: nanoid(), key: "", value: "" }];
setFormValues(changedValue);
};
return (
<Box>
{formValues.length > 0 && (
<Box className={"header-field-container"} mb={6}>
{formValues.map((formItem) => {
return renderItem(formItem, onInputChange, onInputDelete);
})}
</Box>
)}
<Box d={"flex"} justifyContent={"space-between"} alignItems={"center"}>
<Box>{formValues.length <= 0 && "点击添加 header"}</Box>
<Button
size={"small"}
type={"link"}
icon={<PlusOutlined />}
onClick={onInputAdd}
>
</Button>
</Box>
</Box>
);
};
// Http Header 编辑组件
const HeaderEdit: FC<HeaderEditProps> = ({ label, valuePropName, name }) => {
return (
<Form.Item
className={"header-edit"}
name={name}
label={label}
valuePropName={valuePropName}
>
<HeaderFieldInput />
</Form.Item>
);
};
export default HeaderEdit;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 355 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 B

@ -1,105 +0,0 @@
@import "../../main";
.window-tool-bar {
-webkit-app-region: drag;
padding-left: 10px;
z-index: 1000;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
user-select: none;
height: 30px;
&-left {
display: flex;
flex-direction: row;
align-items: center;
font-size: 14px;
font-weight: 600;
@include text-ellipsis();
.mac-btn {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin: 0 3px;
cursor: pointer;
font-size: 10px;
text-align: center;
color: #fff;
&.close {
background: rgb(250, 100, 94);
}
&.min {
background: rgb(232, 189, 90);
}
&.max {
background: rgb(106, 187, 83);
}
&:hover {
&.close {
background-image: url(assets/btn-close.png);
background-repeat: no-repeat;
background-position: center center;
background-size: 6px 6px;
background-color: rgb(250, 100, 94);
}
&.min {
background-image: url(assets/btn-min.png);
background-repeat: no-repeat;
background-position: center center;
background-size: 6px 2px;
background-color: rgb(232, 189, 90);
}
&.max {
background-image: url(assets/btn-max.png);
background-repeat: no-repeat;
background-position: center center;
background-size: 6px 6px;
background-color: rgb(106, 187, 83);
}
}
}
}
&-title {
max-width: 400px;
@include text-ellipsis();
}
&-right {
display: flex;
flex-direction: row;
align-items: center;
-webkit-app-region: no-drag;
.btn {
height: 29px;
line-height: 30px;
width: 45px;
text-align: center;
font-size: 14px;
transition: background-color 0.3s ease;
&:hover {
background: #e5e5e5;
}
&.close {
&:hover {
background: #e81123;
color: #fff;
}
}
}
}
}

@ -1,43 +0,0 @@
import React, { FC, PropsWithChildren } from "react";
import "./index.scss";
import { CloseOutlined, MinusOutlined } from "@ant-design/icons";
import useElectron from "../../hooks/electron";
interface Props {
color?: string;
onClose?: () => void;
onMinimize?: () => void;
}
const WindowToolBar: FC<PropsWithChildren<Props>> = ({
onClose,
onMinimize,
color = "#fff",
children,
}) => {
const { isMacos, isWindows } = useElectron();
return (
<div className="window-tool-bar" style={{ background: color }}>
<div className="window-tool-bar-left">
{isMacos && <div className="mac-btn close" onClick={onClose} />}
{isMacos && <div className="mac-btn min" onClick={onMinimize} />}
</div>
<div className="window-tool-bar-title">{children}</div>
<div className="window-tool-bar-right">
{isWindows && (
<div className="btn" onClick={onMinimize}>
<MinusOutlined />
</div>
)}
{isWindows && (
<div className="btn close" onClick={onClose}>
<CloseOutlined />
</div>
)}
</div>
</div>
);
};
export default WindowToolBar;

@ -1,5 +0,0 @@
const useElectron = (): ElectronApi => {
return window.electron;
};
export default useElectron;

@ -1,41 +0,0 @@
@mixin text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@mixin scrollbar($c: rgba(0, 0, 0, 0.15)) {
&::-webkit-scrollbar {
height: 8px;
width: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: $c;
}
}
@mixin window-common {
html,
body,
#root {
padding: 0;
margin: 0;
height: 100vh;
width: 100vw;
overflow: hidden;
}
}
.ant-modal-wrap {
@include scrollbar;
}
* {
font-family: Alibaba-PuHuiTi-Regular;
@include scrollbar();
}

@ -1,31 +0,0 @@
import ReactDOM from "react-dom";
import React from "react";
import { BrowserRouter as Router } from "react-router-dom";
import { Route } from "react-router";
import tdApp from "./utils/td";
import "antd/dist/antd.css";
import { Provider } from "react-redux";
import BrowserPage from "./nodes/browser";
import MainPage from "./nodes/main";
import "./main.scss";
import store from "./store";
import * as Sentry from "@sentry/react";
import { Integrations } from "@sentry/tracing";
Sentry.init({
dsn: String(import.meta.env.VITE_APP_SENTRY_DSN || ""),
integrations: [new Integrations.BrowserTracing()],
tracesSampleRate: 1.0,
});
tdApp.init();
ReactDOM.render(
<Provider store={store}>
<Router>
<Route path="/main" component={MainPage} />
<Route path="/browser" component={BrowserPage} />
</Router>
</Provider>,
document.getElementById("root")
);

@ -1,42 +0,0 @@
.search-bar {
display: flex;
flex-direction: row;
align-items: center;
height: 30px;
padding: 0 5px;
.search-inner {
flex: 1;
height: 25px;
line-height: 25px;
border: 1px solid #dcdfe6;
outline: none;
padding: 0 10px;
font-size: 14px;
font-weight: 600;
margin: 0 5px;
&:focus {
border-color: #66afe9;
outline: 0;
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075),
0 0 8px rgba(102, 175, 233, 0.6);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075),
0 0 8px rgba(102, 175, 233, 0.6);
}
}
.btn {
width: 25px;
height: 25px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
transition: background-color 0.3s ease;
&:hover {
background: #e5e5e5;
}
}
}

@ -1,86 +0,0 @@
import React, { ChangeEvent } from "react";
import "./index.scss";
import {
ArrowLeftOutlined,
ArrowRightOutlined,
HomeOutlined,
ReloadOutlined,
StarFilled,
StarOutlined,
} from "@ant-design/icons";
import classNames from "classnames";
import PropTypes from "prop-types";
interface Props {
onGoBack: () => void;
onReload: () => void;
onGoBackHome: () => void;
handleEnter: () => void;
url: string;
onUrlChange: (url: string) => void;
className: string;
isFav: boolean;
handleClickFav: () => void;
}
const SearchBar: React.FC<Props> = (props) => {
const {
onGoBack,
onReload,
onGoBackHome,
url,
onUrlChange,
className,
handleEnter,
isFav,
handleClickFav,
} = props;
// 搜索框变化
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
onUrlChange(e.target.value);
};
// 搜索框键盘事件
const handleEnterPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
handleEnter();
}
};
return (
<div className={classNames("search-bar", className)}>
<div className="btn">
<ArrowLeftOutlined className="icon" onClick={onGoBack} />
</div>
<div className="btn">
<ReloadOutlined className="icon" onClick={onReload} />
</div>
<div className="btn">
<HomeOutlined className="icon home" onClick={onGoBackHome} />
</div>
<div className="btn" onClick={handleClickFav}>
{isFav ? <StarFilled /> : <StarOutlined />}
</div>
<input
className="search-inner"
placeholder="请在此输入网址"
type="text"
value={url}
onKeyPress={handleEnterPress}
onChange={handleSearchChange}
/>
<div className="btn">
<ArrowRightOutlined className="button" onClick={handleEnter} />
</div>
</div>
);
};
SearchBar.propTypes = {
onGoBack: PropTypes.func.isRequired,
};
SearchBar.defaultProps = {};
export default SearchBar;

@ -1,39 +0,0 @@
@import "../../main";
@include window-common;
$playlist-width: 200px;
.browser-window {
height: 100%;
display: flex;
flex-direction: column;
.webview-container {
flex: 1;
width: 100vw;
max-height: 100vh;
display: flex;
overflow: hidden;
flex-direction: column;
.webview-nav {
}
.webview-inner {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: row;
#videoView {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #f2f6fc;
}
}
}
}

@ -1,154 +0,0 @@
import React, { FC, useEffect, useRef, useState } from "react";
import "./index.scss";
import { Spin } from "antd";
import tdApp from "../../utils/td";
import "antd/dist/antd.css";
import { insertFav, isFavFunc, removeFav } from "../../utils/localforge";
import WindowToolBar from "../../components/WindowToolBar";
import SearchBar from "./elements/SearchBar";
import onEvent from "../../utils/td-utils";
import useElectron from "../../hooks/electron";
tdApp.init();
const computeRect = ({
left,
top,
width,
height,
}: {
left: number;
top: number;
width: number;
height: number;
}) => ({
x: Math.floor(left),
y: Math.floor(top),
width: Math.floor(width),
height: Math.floor(height),
});
const BrowserWindow: FC = () => {
const [url, setUrl] = useState<string>("");
const [title, setTitle] = useState<string>("");
const [isFav, setIsFav] = useState<boolean>(false);
const webviewRef = useRef<HTMLDivElement>();
const resizeObserver = useRef<ResizeObserver>();
const {
browserViewGoBack,
browserViewReload,
browserViewLoadURL,
addEventListener,
setBrowserViewRect,
removeEventListener,
closeBrowserWindow,
minimize,
} = useElectron();
useEffect(() => {
initWebView();
addEventListener("dom-ready", handleViewDOMReady);
return () => {
setBrowserViewRect({ x: 0, y: 0, height: 0, width: 0 });
removeEventListener("dom-ready", handleViewDOMReady);
resizeObserver.current?.disconnect();
};
}, []);
const handleViewDOMReady = async (
e: Electron.IpcRendererEvent,
{ url, title }: { url: string; title: string }
): Promise<void> => {
const isFav = await isFavFunc(url);
setUrl(url);
setTitle(title);
setIsFav(isFav);
document.title = title;
};
const initWebView = () => {
if (webviewRef.current) {
const rect = computeRect(webviewRef.current.getBoundingClientRect());
setBrowserViewRect(rect);
// 监控 webview 元素的大小
resizeObserver.current = new ResizeObserver((entries) => {
const entry = entries[0];
const viewRect = computeRect(entry.contentRect);
viewRect.x += rect.x;
viewRect.y += rect.y;
setBrowserViewRect(viewRect);
});
resizeObserver.current.observe(webviewRef.current);
}
};
const onGoBack = () => {
onEvent.browserPageGoBack();
browserViewGoBack();
};
const onReload = () => {
onEvent.browserPageReload();
browserViewReload();
};
const onGoBackHome = () => {
browserViewLoadURL();
};
const onUrlChange = (url: string) => {
setUrl(url);
};
const handleEnter = () => {
browserViewLoadURL(url);
};
const handleClickFav = async () => {
const isFav = await isFavFunc(url);
if (isFav) {
await removeFav({ title, url });
} else {
await insertFav({ title, url });
}
setIsFav((fav) => !fav);
};
return (
<div className="browser-window">
<WindowToolBar
onClose={() => {
closeBrowserWindow();
}}
onMinimize={() => {
minimize("");
}}
>
{title}
</WindowToolBar>
<div className="webview-container">
<SearchBar
className="webview-nav"
url={url}
isFav={isFav}
onUrlChange={onUrlChange}
onGoBack={onGoBack}
onReload={onReload}
onGoBackHome={onGoBackHome}
handleEnter={handleEnter}
handleClickFav={handleClickFav}
/>
<div className="webview-inner">
<div id="videoView" ref={webviewRef as any}>
<Spin />
</div>
</div>
</div>
</div>
);
};
export default BrowserWindow;

@ -1,69 +0,0 @@
@import "../../../main";
.download-list-container {
.new-download-list {
.list-item-container {
cursor: pointer;
.list-item-inner {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
.favorite-menu {
.ant-dropdown-menu-title-content {
width: 100%;
}
}
.ant-form {
.ant-form-item {
margin-bottom: 8px;
}
}
}
.file-drop {
height: 100%;
width: 100%;
> .file-drop-target {
height: 100%;
width: 100%;
position: relative;
&.file-drop-dragging-over-frame {
&::after {
z-index: 10000;
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.8);
}
}
&.file-drop-dragging-over-target {
&::before {
z-index: 10100;
content: "拖拽到这里新建下载";
position: absolute;
top: 10px;
left: 10px;
bottom: 10px;
right: 10px;
border: 2px #00b3ff dashed;
border-radius: 10px;
color: #fff;
display: flex;
justify-content: center;
align-items: center;
}
}
}
}

@ -1,978 +0,0 @@
import React, {
DragEvent as ReactDragEvent,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { FixedSizeList as List } from "react-window";
import AutoSizer from "react-virtualized-auto-sizer";
import { Resizable } from "re-resizable";
import "./index.scss";
import { Box } from "@chakra-ui/react";
import classNames from "classnames";
import {
Button,
Checkbox,
Col,
Divider,
Dropdown,
Empty,
Form,
Input,
InputNumber,
Menu,
message,
Modal,
Row,
Space,
Switch,
Tooltip,
} from "antd";
import onEvent from "../../../../utils/td-utils";
import {
AppstoreAddOutlined,
BlockOutlined,
CloseOutlined,
DownOutlined,
PlusOutlined,
UpOutlined,
} from "@ant-design/icons";
import { processHeaders } from "../../../../utils/utils";
import {
getFavs,
insertFav,
insertVideo,
removeFav,
removeVideo,
removeVideos,
updateVideoStatus,
} from "../../../../utils/localforge";
import { ModalForm, ProFormSelect, ProFormText } from "@ant-design/pro-form";
import { isUrl } from "../../../../utils";
import { useDispatch, useSelector } from "react-redux";
import { AppState } from "../../../../store/reducers";
import { FileDrop } from "react-file-drop";
import ProForm from "@ant-design/pro-form";
import useElectron from "../../../../hooks/electron";
import { nanoid } from "nanoid";
import { Settings } from "../../../../store/actions/settings.actions";
import { updateNotifyCount } from "../../../../store/actions/main.actions";
import HeaderEdit from "../../../../components/HeaderEdit";
import { downloaderOptions } from "../../../../utils/variables";
import { SourceStatus, SourceType } from "../../../../types";
type ActionButton = {
key: string;
text: string | ReactNode;
tooltip?: string;
title?: string;
showTooltip?: boolean;
cb: () => void;
};
interface Props {
tableData: SourceItem[];
changeSourceStatus: (
source: SourceItem,
status: SourceStatus
) => Promise<void>;
workspace: string;
updateTableData: () => Promise<void>;
}
const colorMap = {
ready: "#108ee9",
downloading: "#2db7f5",
failed: "#f50",
success: "#87d068",
};
const titleMap = {
ready: "未下载",
downloading: "正在下载",
failed: "下载失败",
success: "下载成功",
};
const winWidth = document.documentElement.clientWidth;
// 待下载列表页
const DownloadList: React.FC<Props> = ({
tableData,
changeSourceStatus,
workspace,
updateTableData,
}) => {
const [isModalVisible, setIsModalVisible] = useState<boolean>(false);
const [favsList, setFavsList] = useState<Fav[]>([]);
const [maxWidth, setMaxWidth] = useState<number>(winWidth);
const [expanded, setExpanded] = useState<boolean>(true);
const [moreOptions, setMoreOptions] = useState<boolean>(false); // todo: 初始化判断mediago
const [
currentSourceItem,
setCurrentSourceItem,
] = useState<SourceItem | null>();
const settings = useSelector<AppState, Settings>((state) => state.settings);
const dispatch = useDispatch();
const tableDataRef = useRef<SourceItem[]>([]);
tableDataRef.current = tableData;
const {
itemContextMenu,
addEventListener,
removeEventListener,
ipcExec,
} = useElectron();
const { exeFile } = settings;
const [formRef] = Form.useForm();
const [detailForm] = Form.useForm();
const calcMaxWidth = useCallback(() => {
const max = document.documentElement.clientWidth - 300;
setMaxWidth(max);
}, []);
useEffect(() => {
initData();
window.addEventListener("resize", calcMaxWidth);
addEventListener("download-context-menu-detail", contextMenuDetail);
addEventListener("download-context-menu-download", contextMenuDownload);
addEventListener("download-context-menu-delete", contextMenuDelete);
addEventListener("download-context-menu-clear-all", contextMenuClearAll);
return () => {
window.removeEventListener("resize", calcMaxWidth);
removeEventListener("download-context-menu-detail", contextMenuDetail);
removeEventListener(
"download-context-menu-download",
contextMenuDownload
);
removeEventListener("download-context-menu-delete", contextMenuDelete);
removeEventListener(
"download-context-menu-clear-all",
contextMenuClearAll
);
};
}, []);
const contextMenuDetail = (
e: Electron.IpcRendererEvent,
item: SourceItem
) => {
setCurrentSourceItem(item);
detailForm.setFieldsValue(item);
calcMaxWidth();
};
const contextMenuDownload = (
e: Electron.IpcRendererEvent,
item: SourceItem
) => {
downloadFile(item);
};
const contextMenuDelete = async (
event: Electron.IpcRendererEvent,
item: SourceItem
) => {
await removeVideo(item.id);
await updateTableData();
};
const contextMenuClearAll = async () => {
const ids = tableDataRef.current.map((item) => item.id);
await removeVideos(ids);
await updateTableData();
};
const initData = async () => {
const favs = await getFavs();
setFavsList(favs);
};
// 渲染视频下载的状态
const renderStatus = (item: SourceItem) => {
const status = item.status;
return (
<Tooltip title={titleMap[status]} placement={"right"}>
<Box h={8} w={8} borderRadius={4} mr={8} bg={colorMap[status]} />
</Tooltip>
);
};
// 点击取消新建下载
const handleCancel = (): void => {
setIsModalVisible(false);
};
// 新建下载
const newDownload = () => {
onEvent.mainPageNewSource();
setIsModalVisible(true);
};
// 打开浏览器
const openBrowser = () => {
onEvent.mainPageOpenBrowserPage();
window.electron.openBrowserWindow();
};
// 向列表中插入一条数据并且请求详情
const insertUpdateTableData = async (
item: SourceItemForm
): Promise<SourceItem> => {
const { workspace, exeFile } = settings;
const sourceItem: SourceItem = {
id: nanoid(),
status: SourceStatus.Ready,
type: SourceType.M3u8,
exeFile,
directory: workspace,
title: item.title,
duration: 0,
url: item.url,
createdAt: Date.now(),
deleteSegments: item.delete,
};
if (item.headers) {
sourceItem.headers = processHeaders(item.headers);
}
await insertVideo(sourceItem);
await updateTableData();
setIsModalVisible(false);
return sourceItem;
};
// 渲染添加按钮
const renderAddFav = () => {
return (
<ModalForm<Fav>
width={500}
layout="horizontal"
title="添加收藏"
trigger={
<Button
type="link"
style={{ padding: 0 }}
size={"small"}
icon={<PlusOutlined />}
>
</Button>
}
onFinish={async (fav) => {
onEvent.favPageAddFav();
await insertFav(fav);
const favs = await getFavs();
setFavsList(favs);
return true;
}}
>
<ProFormText
required
name="title"
label="链接名称"
placeholder="请输入链接名称"
rules={[{ required: true, message: "请输入链接名称" }]}
/>
<ProFormText
required
name="url"
label="链接地址"
placeholder="请输入链接地址"
rules={[
{ required: true, message: "请输入链接地址" },
{
validator(rule, value: string, callback) {
if (!isUrl(value)) callback("请输入正确的 url 格式");
else callback();
},
},
]}
/>
</ModalForm>
);
};
// 下载文件
const downloadFile = async (item: SourceItem): Promise<void> => {
await changeSourceStatus(item, SourceStatus.Downloading);
onEvent.tableStartDownload();
const { title, headers, url, exeFile: formExeFile } = item;
const exeFile = formExeFile || (await window.electron.store.get("exeFile"));
const workspace = await window.electron.store.get("workspace");
let args: MediaGoArgs | M3u8DLArgs;
if (exeFile === "mediago") {
const headersString = Object.entries(headers || {})
.map(([key, value]) => `${key}~${value}`)
.join("|");
args = {
url,
path: workspace, // 设定程序工作目录
name: title, // 设定存储文件名(不包括后缀)
headers: headersString,
};
} else {
const {
checkbox,
maxThreads,
minThreads,
retryCount,
timeOut,
stopSpeed,
maxSpeed,
} = item;
const checkboxObj = Object.values(checkbox! || []).reduce(
(prev: Record<string, boolean>, cur) => {
prev[cur] = true;
return prev;
},
{}
);
const headersString = Object.entries(headers || {})
.map(([key, value]) => `${key}:${value}`)
.join("|");
args = {
url,
workDir: workspace, // 设定程序工作目录
saveName: title, // 设定存储文件名(不包括后缀)
headers: headersString,
enableDelAfterDone: item.deleteSegments,
...checkboxObj,
maxThreads,
minThreads,
retryCount,
timeOut,
stopSpeed,
maxSpeed,
};
}
console.log("args: ", exeFile, args);
const { code, msg } = await ipcExec(exeFile, args);
if (code === 0) {
await changeSourceStatus(item, SourceStatus.Success);
onEvent.mainPageDownloadSuccess();
} else {
message.error(msg);
await changeSourceStatus(item, SourceStatus.Failed);
onEvent.mainPageDownloadFail();
}
};
// 新建下载窗口点击确定按钮
const handleOk = async (): Promise<void> => {
if (formRef && (await formRef.validateFields())) {
const item = formRef.getFieldsValue();
formRef.resetFields();
onEvent.addSourceAddSource();
await insertUpdateTableData(item);
}
};
// 新建下载窗口点击立即下载
const handleDownload = async (): Promise<void> => {
if (formRef && (await formRef.validateFields())) {
const item = formRef.getFieldsValue();
formRef.resetFields();
onEvent.addSourceDownload();
const sourceItem = await insertUpdateTableData(item);
await downloadFile(sourceItem);
}
};
// 删除收藏
const handleDelete = async (fav: Fav): Promise<void> => {
Modal.confirm({
title: "确认要删除这个收藏吗?",
onOk: async () => {
onEvent.favPageDeleteLink();
await removeFav(fav);
const favs = await getFavs();
setFavsList(favs);
},
okText: "删除",
okButtonProps: { danger: true },
cancelText: "取消",
});
};
const browserMenu = () => {
return (
<Menu className={"favorite-menu"} style={{ width: 250 }}>
{favsList.map((fav, i) => (
<Menu.Item key={i} style={{ overflow: "hidden" }}>
<Box
display={"flex"}
alignItems={"center"}
justifyContent={"space-between"}
width={"100%"}
>
<Box
flex={1}
overflow={"hidden"}
whiteSpace={"nowrap"}
textOverflow={"ellipsis"}
onClick={() => {
onEvent.favPageOpenLink();
window.electron.openBrowserWindow(fav.url);
}}
title={fav.title}
>
{fav.title}
</Box>
<Button type="link" danger onClick={() => handleDelete(fav)}>
</Button>
</Box>
</Menu.Item>
))}
{favsList.length > 0 && <Menu.Divider />}
<Menu.Item key="add">{renderAddFav()}</Menu.Item>
</Menu>
);
};
// 渲染页面上方的按钮
const renderToolBar = () => {
return (
<Box p={10} borderBottom={"1px solid #EBEEF5"}>
<Space>
<Button
key={"1"}
onClick={newDownload}
icon={<AppstoreAddOutlined />}
size={"middle"}
>
</Button>
<Dropdown.Button
size={"middle"}
key={"2"}
trigger={["click"]}
onClick={openBrowser}
overlay={browserMenu}
icon={<BlockOutlined />}
>
</Dropdown.Button>
</Space>
</Box>
);
};
// 打开所在文件夹
const openDirectory = () => {
window.electron.openPath(workspace);
};
// 渲染操作按钮
const renderActionButtons = (row: SourceItem): ReactNode => {
const buttons: ActionButton[] = [];
switch (row.status) {
case SourceStatus.Success:
// 下载成功
buttons.push({
key: "1",
text: (
<Button type={"link"} size={"small"}>
</Button>
),
title: "打开文件位置",
cb: openDirectory,
});
buttons.push({
key: "2",
text: (
<Button type={"link"} size={"small"}>
</Button>
),
title: "重新下载",
cb: () => downloadFile(row),
});
break;
case SourceStatus.Failed:
// 下载失败
buttons.push({
key: "3",
text: (
<Button type={"link"} size={"small"}>
</Button>
),
title: "重新下载",
cb: () => downloadFile(row),
});
break;
case SourceStatus.Downloading:
// 正在下载
buttons.push({
key: "5",
text: (
<Button type={"link"} size={"small"}>
</Button>
),
title: "重置状态",
showTooltip: true,
tooltip:
"如果下载过程中将主程序关闭,那么主程序将无法接收到下载成功的消息,可以通过重置状态将状态改为未下载状态",
cb: async () => {
onEvent.tableReNewStatus();
await updateVideoStatus(row, SourceStatus.Ready);
await updateTableData();
},
});
break;
default:
// 准备状态
buttons.push({
key: "6",
text: (
<Button type={"link"} size={"small"}>
</Button>
),
title: "下载",
cb: () => downloadFile(row),
});
break;
}
return (
<Box display={"flex"}>
{buttons.map((button) =>
button.showTooltip ? (
<Tooltip title={button.tooltip} placement={"left"}>
<Box
pl={10}
key={button.key}
onClick={button.cb}
title={button.title}
>
{button.text}
</Box>
</Tooltip>
) : (
<Box
pl={10}
key={button.key}
onClick={button.cb}
title={button.title}
>
{button.text}
</Box>
)
)}
</Box>
);
};
// 文件放入事件
const onDrop = async (
files: FileList | null,
event: ReactDragEvent<HTMLDivElement>
) => {
if (files?.length === 1) {
// 只有一个文件被拽入
await setIsModalVisible(true);
const [file] = files;
formRef?.setFieldsValue({ url: file.path });
}
};
return (
<FileDrop onDrop={onDrop}>
<Box
className={"download-list-container"}
h={"100%"}
w={"100%"}
display={"flex"}
flexDirection={"column"}
>
{renderToolBar()}
{tableData.length > 0 ? (
<Box
flex={1}
display={"flex"}
overflow={"hidden"}
flexDirection={"row"}
>
<Resizable
as={Box}
enable={{ right: true }}
minHeight={"100%"}
minWidth={currentSourceItem ? "350px" : "100%"}
maxWidth={currentSourceItem ? maxWidth : "100%"}
style={{
borderRight: "1px solid rgb(235, 238, 245)",
}}
>
<AutoSizer className={"new-download-list"}>
{({ height, width }) => (
<List<SourceItem[]>
height={height}
itemSize={35}
width={width}
itemData={tableDataRef.current}
itemCount={tableDataRef.current.length}
itemKey={(index, data) => {
const item = data[index];
return item.id || `${item.title}-${index}`;
}}
>
{({ index, style, data }) => {
const item = data[index];
return (
<Box
className={classNames("list-item-container")}
_hover={{ bg: "#EBEEF5" }}
style={style}
title={item.title}
display={"flex"}
flexDirection={"row"}
alignItems={"center"}
px={15}
onContextMenu={() => {
itemContextMenu(item);
}}
>
{renderStatus(item)}
<Box
flex={1}
className={"list-item-inner"}
onClick={() => {
const { exeFile } = settings;
setCurrentSourceItem(item);
detailForm.setFieldsValue({ ...item, exeFile });
calcMaxWidth();
setMoreOptions(exeFile !== "mediago");
dispatch(updateNotifyCount(0));
}}
>
{item.title}
</Box>
{renderActionButtons(item)}
</Box>
);
}}
</List>
)}
</AutoSizer>
</Resizable>
{currentSourceItem && (
<Box
p={15}
pt={0}
height={"100%"}
flex={1}
overflowY={"auto"}
minW={"300px"}
>
<Box
display={"flex"}
alignItems={"center"}
justifyContent={"flex-end"}
h={40}
>
<Button
size={"small"}
icon={<CloseOutlined />}
type={"link"}
onClick={() => {
setCurrentSourceItem(null);
}}
/>
</Box>
<ProForm
form={detailForm}
layout={"horizontal"}
submitter={{
searchConfig: {
resetText: "重置",
submitText: "下载",
},
resetButtonProps: {
style: {
// 隐藏重置按钮
display: "none",
},
},
onSubmit: async () => {
const item = detailForm.getFieldsValue();
await downloadFile(item);
},
}}
onValuesChange={(changedFields) => {
if (changedFields.hasOwnProperty("exeFile")) {
setMoreOptions(changedFields.exeFile !== "mediago");
}
}}
size={"small"}
>
<ProFormText
name={"title"}
label="视频名称"
placeholder="请输入视频名称"
/>
<ProFormSelect
name={"exeFile"}
options={downloaderOptions}
label={"下载程序"}
placeholder={"请选择下载程序"}
/>
<ProFormText
name={"url"}
label="请求地址"
placeholder="请输入请求地址"
/>
<HeaderEdit label={"请求标头"} name={"headers"} />
{moreOptions && (
<>
<Divider plain style={{ margin: "-10px 0 5px 0" }}>
<Box
d={"flex"}
alignItems={"center"}
justifyContent={"center"}
cursor={"pointer"}
color={"#409EFF"}
onClick={() => {
setExpanded((state) => !state);
}}
>
{expanded ? (
<>
<DownOutlined />
<Box ml={5}></Box>
</>
) : (
<>
<UpOutlined />
<Box ml={5}></Box>
</>
)}
</Box>
</Divider>
{!expanded && (
<>
<Form.Item
name={"checkbox"}
initialValue={["enableDelAfterDone"]}
>
<Checkbox.Group style={{ width: "100%" }}>
<Row>
<Col span={12} style={{ marginBottom: "8px" }}>
<Checkbox value="enableDelAfterDone">
</Checkbox>
</Col>
<Col span={12} style={{ marginBottom: "8px" }}>
<Checkbox value="disableDateInfo">
</Checkbox>
</Col>
<Col span={12} style={{ marginBottom: "8px" }}>
<Checkbox value="noProxy">
使
</Checkbox>
</Col>
<Col span={12} style={{ marginBottom: "8px" }}>
<Checkbox value="enableParseOnly">
m3u8
</Checkbox>
</Col>
<Col span={12} style={{ marginBottom: "8px" }}>
<Checkbox value="enableMuxFastStart">
MP4
</Checkbox>
</Col>
<Col span={12} style={{ marginBottom: "8px" }}>
<Checkbox value="noMerge">
</Checkbox>
</Col>
<Col span={12}>
<Checkbox value="enableBinaryMerge">
使
</Checkbox>
</Col>
<Col span={12}>
<Checkbox value="enableAudioOnly">
</Checkbox>
</Col>
<Col span={12}>
<Checkbox value="disableIntegrityCheck">
</Checkbox>
</Col>
</Row>
</Checkbox.Group>
</Form.Item>
<Row>
<Col span={12}>
<Form.Item
name={"maxThreads"}
label={"最大线程"}
labelCol={{ style: { width: "86px" } }}
labelAlign={"left"}
initialValue={32}
>
<InputNumber placeholder="placeholder" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name={"minThreads"}
label={"最小线程"}
labelCol={{ style: { width: "86px" } }}
labelAlign={"left"}
initialValue={16}
>
<InputNumber placeholder="placeholder" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name={"retryCount"}
label={"重试次数"}
labelCol={{ style: { width: "86px" } }}
labelAlign={"left"}
initialValue={15}
>
<InputNumber placeholder="placeholder" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name={"timeOut"}
label={"超时时长(s)"}
labelCol={{ style: { width: "86px" } }}
labelAlign={"left"}
initialValue={10}
>
<InputNumber placeholder="placeholder" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name={"stopSpeed"}
label={"停速(KB/s)"}
labelCol={{ style: { width: "86px" } }}
labelAlign={"left"}
initialValue={0}
>
<InputNumber placeholder="placeholder" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name={"maxSpeed"}
label={"限速(KB/s)"}
labelCol={{ style: { width: "86px" } }}
labelAlign={"left"}
initialValue={0}
>
<InputNumber placeholder="placeholder" />
</Form.Item>
</Col>
</Row>
</>
)}
</>
)}
</ProForm>
</Box>
)}
</Box>
) : (
<Box
flex={1}
display={"flex"}
overflow={"hidden"}
flexDirection={"row"}
alignItems={"center"}
justifyContent={"center"}
>
<Empty
image="https://gw.alipayobjects.com/zos/antfincdn/ZHrcdLPrvN/empty.svg"
imageStyle={{
height: 120,
}}
description={
<span>
<Button type={"link"} onClick={newDownload}>
</Button>
<br />
<Button type={"link"} onClick={openBrowser}>
</Button>
</span>
}
/>
</Box>
)}
{/*新建下载窗口*/}
<Modal
title="新建下载"
visible={isModalVisible}
onCancel={handleCancel}
footer={[
<Button key="back" onClick={handleDownload}>
</Button>,
<Button key="submit" onClick={handleCancel}>
</Button>,
<Button key="link" type="primary" onClick={handleOk}>
</Button>,
]}
>
<Form
labelCol={{ span: 4 }}
form={formRef}
initialValues={{ delete: true }}
>
<Form.Item
label="m3u8"
name="url"
rules={[{ required: true, message: "请填写 m3u8 链接" }]}
>
<Input placeholder="[必填] 输入 m3u8 地址" allowClear />
</Form.Item>
<Form.Item
label="视频名称"
name="title"
rules={[{ required: true, message: "请填写视频名称" }]}
>
<Input placeholder="[可空] 默认当前时间戳" allowClear />
</Form.Item>
<HeaderEdit label={"请求标头"} name={"headers"} />
<Form.Item
label="下载完成是否删除"
name="delete"
labelCol={{ span: 8 }}
valuePropName="checked"
hidden={exeFile === "mediago"}
>
<Switch />
</Form.Item>
</Form>
</Modal>
</Box>
</FileDrop>
);
};
export default DownloadList;

@ -1,193 +0,0 @@
import React, { FC, useRef } from "react";
import { Button, FormInstance, Space, Switch, Tooltip, Form } from "antd";
import "./index.scss";
import ProForm, {
ProFormGroup,
ProFormSelect,
ProFormSwitch,
ProFormText,
} from "@ant-design/pro-form";
import { FolderOpenOutlined, QuestionCircleOutlined } from "@ant-design/icons";
import { AppState } from "../../../../store/reducers";
import {
Settings,
updateSettings,
} from "../../../../store/actions/settings.actions";
import { useDispatch, useSelector } from "react-redux";
import { Box } from "@chakra-ui/react";
import { version } from "../../../../../package.json";
import { downloaderOptions } from "../../../../utils/variables";
import useElectron from "../../../../hooks/electron";
const statisticsTooltip = `
1.
2.
3. 便~
`;
// 设置页面
const Setting: FC = () => {
const settings = useSelector<AppState, Settings>((state) => state.settings);
const dispatch = useDispatch();
const formRef = useRef<FormInstance<Settings>>();
const {
store,
getPath,
showOpenDialog,
openConfigDir: openConfigDirElectron,
openBinDir: openBinDirElectron,
openPath,
} = useElectron();
// 选择下载地址
const handleSelectDir = async (): Promise<void> => {
const defaultPath = await getPath("documents");
const { filePaths } = await showOpenDialog({
defaultPath,
properties: ["openDirectory"],
});
// 没有返回值
if (!filePaths) return;
// 返回值为空
if (Array.isArray(filePaths) && filePaths.length <= 0) return;
const workspaceValue = filePaths[0];
await store.set("workspace", workspaceValue);
formRef.current?.setFieldsValue({
workspace: workspaceValue || "",
});
dispatch(updateSettings({ workspace: workspaceValue }));
};
// 打开配置文件文件夹
const openConfigDir = async (): Promise<void> => {
openConfigDirElectron();
};
// 打开可执行程序文件夹
const openBinDir = () => {
openBinDirElectron();
};
// 本地存储文件夹
const localDir = async (): Promise<void> => {
const { workspace } = settings;
await openPath(workspace);
};
const { useProxy } = settings;
return (
<Box className="setting-form">
<ProForm<Settings>
formRef={formRef}
layout="horizontal"
submitter={false}
labelCol={{ style: { width: "130px" } }}
labelAlign={"left"}
size={"small"}
colon={false}
initialValues={settings}
onValuesChange={async (changedValue) => {
for (const key in changedValue) {
if (changedValue.hasOwnProperty(key)) {
const value = changedValue[key];
await store.set(key, value);
// 如果修改代理地址,关闭代理,可以手动打开
if (key === "proxy" && useProxy) {
await store.set("useProxy", false);
const form = formRef.current;
if (form) {
form.setFieldsValue({
...settings,
useProxy: false,
proxy: value,
});
}
}
}
}
dispatch(updateSettings({ ...settings, ...changedValue }));
}}
>
<ProFormGroup label="基础设置" direction={"vertical"}>
<ProFormText
width="xl"
disabled
name="workspace"
placeholder="请选择视频下载目录"
label={
<Button onClick={handleSelectDir} icon={<FolderOpenOutlined />}>
</Button>
}
/>
<ProFormSwitch label="下载完成提示" name="tip" />
<ProFormText
width="xl"
name="proxy"
placeholder="请填写代理地址"
label="代理设置"
/>
<ProFormSwitch
name={"useProxy"}
label={
<Box d={"flex"} flexDirection={"row"} alignItems={"center"}>
<Box mr={5}></Box>
<Tooltip
title={"该代理会对软件自带浏览器以及下载时生效"}
placement={"right"}
>
<QuestionCircleOutlined />
</Tooltip>
</Box>
}
>
<Switch />
</ProFormSwitch>
<ProFormSwitch
label={
<Box d={"flex"} flexDirection={"row"} alignItems={"center"}>
<Box mr={5}></Box>
<Tooltip title={statisticsTooltip} placement={"right"}>
<QuestionCircleOutlined />
</Tooltip>
</Box>
}
name="statistics"
/>
</ProFormGroup>
<ProFormGroup label="下载设置" direction={"vertical"}>
<ProFormSelect
allowClear={false}
width="xl"
name="exeFile"
label="默认下载器"
placeholder="请选择执行程序"
options={downloaderOptions}
/>
<ProForm.Item label={"更多操作"}>
<Space>
<Button onClick={openConfigDir} icon={<FolderOpenOutlined />}>
</Button>
<Button onClick={openBinDir} icon={<FolderOpenOutlined />}>
</Button>
<Button onClick={localDir} icon={<FolderOpenOutlined />}>
</Button>
</Space>
</ProForm.Item>
<ProForm.Item label={"当前版本"}>
<div>{version}</div>
</ProForm.Item>
</ProFormGroup>
</ProForm>
</Box>
);
};
export default Setting;

@ -1,83 +0,0 @@
@import "../../main";
@include window-common;
.main-window {
height: 100vh;
width: 100%;
overflow: auto;
display: flex;
flex-direction: column;
position: relative;
.main-window-tabs {
flex: 1;
.ant-tabs-nav {
margin: 0;
height: 40px;
background: #4090f7;
-webkit-app-region: drag;
.ant-tabs-nav-wrap {
padding: 0 20px;
}
.ant-tabs-tab-btn {
-webkit-app-region: no-drag;
color: #fff;
font-size: 16px;
user-select: none;
.download-item {
font-size: 16px;
color: #fff;
}
}
.ant-tabs-tab-active {
.ant-tabs-tab-btn {
color: #fff;
}
}
.ant-tabs-ink-bar {
height: 2px;
background: #fff;
transform: translateY(-2px);
}
}
.ant-tabs-content {
padding: 0;
height: 100%;
overflow: auto;
@include scrollbar;
}
}
.float-icon {
position: absolute;
bottom: 40px;
left: 10px;
height: 50px;
width: 60px;
cursor: pointer;
}
.toolbar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
border-top: 1px solid #dcdfe6;
padding: 0 12px;
font-size: 20px;
}
}
.comment-drawer {
.ant-drawer-body {
@include scrollbar;
}
}

@ -1,195 +0,0 @@
import React, { FC, useEffect, useRef, useState } from "react";
import "./index.scss";
import { Badge, Button, message, Tabs } from "antd";
import WindowToolBar from "../../components/WindowToolBar";
import Setting from "../../nodes/main/elements/Setting";
import { SourceStatus, SourceType } from "../../types";
import {
getVideos,
insertVideo,
updateVideoStatus,
} from "../../utils/localforge";
import audioSrc from "../../assets/tip.mp3";
import onEvent from "../../utils/td-utils";
import { useDispatch, useSelector } from "react-redux";
import { Settings, updateSettings } from "../../store/actions/settings.actions";
import { AppState } from "../../store/reducers";
import useElectron from "../../hooks/electron";
import NewDownloadList from "../../nodes/main/elements/DownloadList";
import { MainState, updateNotifyCount } from "../../store/actions/main.actions";
import { QuestionCircleOutlined } from "@ant-design/icons";
import { helpUrl } from "../../utils/variables";
import { IpcRendererEvent } from "electron";
const audio = new Audio(audioSrc);
enum TabKey {
HomeTab = "1",
SettingTab = "3",
}
const { TabPane } = Tabs;
const MainPage: FC = () => {
const [tableData, setTableData] = useState<SourceItem[]>([]);
const [activeKey, setActiveKey] = useState<TabKey>(TabKey.HomeTab);
const dispatch = useDispatch();
const settings = useSelector<AppState, Settings>((state) => state.settings);
const countRef = useRef(0);
const { notifyCount } = useSelector<AppState, MainState>(
(state) => state.main
);
countRef.current = notifyCount;
const { workspace, exeFile } = settings;
const {
addEventListener,
removeEventListener,
closeMainWindow,
store,
minimize,
} = useElectron();
useEffect(() => {
initData();
addEventListener("m3u8-notifier", handleWebViewMessage);
return () => {
removeEventListener("m3u8-notifier", handleWebViewMessage);
};
}, []);
const initData = async () => {
// 开始初始化表格数据
const tableData = await getVideos();
const initialSettings = await store.get();
dispatch(updateSettings(initialSettings));
setTableData(tableData);
setActiveKey(
initialSettings.workspace ? TabKey.HomeTab : TabKey.SettingTab
);
};
const handleWebViewMessage = async (
e: IpcRendererEvent,
source: SourceUrl
): Promise<void> => {
const item: SourceItem = {
...source,
exeFile,
status: SourceStatus.Ready,
type: SourceType.M3u8,
directory: settings.workspace,
createdAt: Date.now(),
deleteSegments: true,
};
const sourceItem = await insertVideo(item);
if (!sourceItem) return;
const tableData = await getVideos();
setTableData(tableData);
dispatch(updateNotifyCount(countRef.current + 1));
};
// 首页面板切换事件
const onTabChange = (activeKey: TabKey): void => {
if (activeKey === TabKey.HomeTab) {
dispatch(updateNotifyCount(0));
}
};
// 打开使用帮助
const openHelp = () => {
onEvent.mainPageHelp();
window.electron.openExternal(helpUrl);
};
// 首页面板点击事件
const onTabClick = async (activeKey: TabKey): Promise<void> => {
if (!settings.workspace) {
message.error("请选择本地路径");
return;
}
if (activeKey === TabKey.SettingTab) {
onEvent.toSettingPage();
} else if (activeKey === TabKey.HomeTab) {
onEvent.toMainPage();
}
setActiveKey(activeKey);
};
// 切换视频源的 status
const changeSourceStatus = async (
source: SourceItem,
status: SourceStatus
): Promise<void> => {
if (status === SourceStatus.Success && settings.tip) {
await audio.play();
}
await updateVideoStatus(source, status);
const tableData = await getVideos();
setTableData(tableData);
};
// 更新表格的数据
const updateTableData = async (): Promise<void> => {
const videos = await getVideos();
setTableData(videos);
};
return (
<div className="main-window">
<WindowToolBar
color="#4090F7"
onClose={() => {
closeMainWindow();
}}
onMinimize={() => {
minimize("main");
}}
/>
<div className="main-window">
<Tabs
activeKey={activeKey}
tabPosition="top"
className="main-window-tabs"
onChange={(value) => onTabChange(value as TabKey)}
onTabClick={(key) => onTabClick(key as TabKey)}
>
<TabPane
tab={
<Badge className="download-item" count={notifyCount}>
</Badge>
}
key={TabKey.HomeTab}
>
<NewDownloadList
workspace={workspace}
tableData={tableData}
changeSourceStatus={changeSourceStatus}
updateTableData={updateTableData}
/>
</TabPane>
<TabPane tab="设置" key={TabKey.SettingTab}>
<Setting />
</TabPane>
</Tabs>
</div>
<div className="toolbar">
<Button
type={"link"}
onClick={openHelp}
icon={<QuestionCircleOutlined />}
>
使
</Button>
</div>
</div>
);
};
export default MainPage;

@ -1,17 +0,0 @@
export interface MainState {
notifyCount: number;
}
export const UPDATE_NOTIFY_COUNT = "UPDATE_NOTIFY_COUNT";
export interface UpdateNotifyCount {
type: typeof UPDATE_NOTIFY_COUNT;
payload: number;
}
export const updateNotifyCount = (count: number): UpdateNotifyCount => ({
type: UPDATE_NOTIFY_COUNT,
payload: count,
});
export type MainUnionType = UpdateNotifyCount;

@ -1,22 +0,0 @@
export interface Settings {
workspace: string;
tip: boolean;
proxy: string;
useProxy: boolean;
exeFile: string;
statistics: boolean; // 是否允许打点统计
}
export const UPDATE_SETTINGS = "UPDATE_SETTINGS";
export interface UpdateSettings {
type: typeof UPDATE_SETTINGS;
payload: Partial<Settings>;
}
export const updateSettings = (payload: Partial<Settings>): UpdateSettings => ({
type: UPDATE_SETTINGS,
payload,
});
export type SettingsUnionType = UpdateSettings;

@ -1,14 +0,0 @@
import { applyMiddleware, createStore } from "redux";
import { routerMiddleware } from "connected-react-router";
import { composeWithDevTools } from "redux-devtools-extension";
import { createHashHistory } from "history";
import createRootReducer from "./reducers";
export const history = createHashHistory();
const store = createStore(
createRootReducer(history),
composeWithDevTools(applyMiddleware(routerMiddleware(history)))
);
export default store;

@ -1,25 +0,0 @@
import { CombinedState, combineReducers, Reducer } from "redux";
import { connectRouter, RouterState } from "connected-react-router";
import { History } from "history";
import settings from "./settings";
import main from "./main";
import { Settings } from "../actions/settings.actions";
import { MainState } from "../actions/main.actions";
export interface AppState {
router: RouterState;
settings: Settings;
main: MainState;
}
const createRootReducer = (
history: History
): Reducer<CombinedState<AppState>> => {
return combineReducers({
router: connectRouter(history),
settings,
main,
});
};
export default createRootReducer;

@ -1,24 +0,0 @@
import {
MainState,
MainUnionType,
UPDATE_NOTIFY_COUNT,
} from "../actions/main.actions";
const initialState: MainState = {
notifyCount: 0,
};
export default function main(
state = initialState,
action: MainUnionType
): MainState {
switch (action.type) {
case UPDATE_NOTIFY_COUNT:
return {
...state,
notifyCount: action.payload,
};
default:
return state;
}
}

@ -1,29 +0,0 @@
import {
Settings,
SettingsUnionType,
UPDATE_SETTINGS,
} from "../actions/settings.actions";
const initialState: Settings = {
workspace: "",
exeFile: "",
tip: true,
proxy: "",
useProxy: false,
statistics: true,
};
export default function settings(
state = initialState,
action: SettingsUnionType
): Settings {
switch (action.type) {
case UPDATE_SETTINGS:
return {
...state,
...action.payload,
};
default:
return state;
}
}

@ -1,11 +0,0 @@
export enum SourceStatus {
Ready = "ready",
Downloading = "downloading",
Failed = "failed",
Success = "success",
}
export enum SourceType {
M3u8 = "m3u8",
M4s = "m4s",
}

@ -1,2 +0,0 @@
const urlReg = /^(https?:\/\/(([a-zA-Z0-9]+-?)+[a-zA-Z0-9]+\.)+[a-zA-Z]+)(:\d+)?(\/.*)?(\?.*)?(#.*)?$/;
export const isUrl = (url: string): boolean => urlReg.test(url);

@ -1,116 +0,0 @@
import * as localforage from "localforage";
import { SourceStatus } from "../types";
const keys = { videos: "videos", fav: "fav" };
const insertVideo = async (
item: SourceItem
): Promise<SourceItem | undefined> => {
let videos = await localforage.getItem<SourceItem[]>(keys.videos);
// 首先查看数据库中是否存在
if (!Array.isArray(videos)) videos = [];
const isFav = videos.findIndex((video) => video.url === item.url) >= 0;
if (isFav) return undefined;
videos.unshift(item);
await localforage.setItem(keys.videos, videos);
return item;
};
const getVideos = async (): Promise<SourceItem[]> => {
let videos = await localforage.getItem<SourceItem[]>(keys.videos);
if (!Array.isArray(videos)) videos = [];
return videos;
};
const updateVideoStatus = async (
source: SourceItem,
status: SourceStatus
): Promise<void> => {
// fixme: 当数据量比较大的时候
let videos = await localforage.getItem<SourceItem[]>(keys.videos);
if (!Array.isArray(videos)) videos = [];
const findIndex = videos.findIndex((video) => source.id === video.id);
if (findIndex >= 0) {
videos.splice(findIndex, 1, { ...source, status });
await localforage.setItem(keys.videos, videos);
}
};
const updateVideoTitle = async (source: SourceItem, title: string) => {
let videos = await localforage.getItem<SourceItem[]>(keys.videos);
if (!Array.isArray(videos)) videos = [];
const findIndex = videos.findIndex((video) => source.id === video.id);
if (findIndex >= 0) {
videos.splice(findIndex, 1, { ...source, title });
await localforage.setItem(keys.videos, videos);
}
};
const updateVideoUrl = async (source: SourceItem, url: string) => {
let videos = await localforage.getItem<SourceItem[]>(keys.videos);
if (!Array.isArray(videos)) videos = [];
const findIndex = videos.findIndex((video) => source.id === video.id);
if (findIndex >= 0) {
videos.splice(findIndex, 1, { ...source, url });
await localforage.setItem(keys.videos, videos);
}
};
export const removeVideo = async (id: string) => {
let videos = await localforage.getItem<SourceItem[]>(keys.videos);
if (!Array.isArray(videos)) videos = [];
const favIndex = videos.findIndex((item) => item.id === id);
if (favIndex >= 0) videos.splice(favIndex, 1);
await localforage.setItem(keys.videos, videos);
return videos;
};
const removeVideos = async (ids: string[]): Promise<void> => {
for (const id of ids) {
await removeVideo(String(id));
}
};
const insertFav = async (fav: Fav): Promise<Fav[]> => {
let favs = await localforage.getItem<Fav[]>(keys.fav);
if (!Array.isArray(favs)) favs = [];
const isFav = favs.findIndex((item) => item.url === fav.url) >= 0;
if (isFav) return favs;
favs.unshift(fav);
await localforage.setItem(keys.fav, favs);
return favs;
};
const removeFav = async (fav: Fav): Promise<Fav[]> => {
let favs = await localforage.getItem<Fav[]>(keys.fav);
if (!Array.isArray(favs)) favs = [];
const favIndex = favs.findIndex((item) => item.url === fav.url);
if (favIndex >= 0) favs.splice(favIndex, 1);
await localforage.setItem(keys.fav, favs);
return favs;
};
const isFavFunc = async (url: string): Promise<boolean> => {
let favs = await localforage.getItem<Fav[]>(keys.fav);
if (!Array.isArray(favs)) favs = [];
return favs.findIndex((item) => item.url === url) >= 0;
};
const getFavs = async (): Promise<Fav[]> => {
const favs = await localforage.getItem<Fav[]>(keys.fav);
if (!Array.isArray(favs)) return [];
return favs;
};
export {
insertVideo,
getVideos,
updateVideoStatus,
updateVideoTitle,
updateVideoUrl,
removeVideos,
insertFav,
isFavFunc,
removeFav,
getFavs,
};

@ -1,24 +0,0 @@
import tdApp from "../utils/td";
const onEvent = {
browserPageGoBack: (): void => tdApp.onEvent("浏览器页面-点击返回按钮"),
browserPageReload: (): void => tdApp.onEvent("浏览器页面-点击刷新按钮"),
mainPageDownloadFail: (kv?: unknown): void =>
tdApp.onEvent("下载页面-下载视频失败", kv),
mainPageDownloadSuccess: (kv?: unknown): void =>
tdApp.onEvent("下载页面-下载视频成功", kv),
mainPageOpenBrowserPage: (): void => tdApp.onEvent("下载页面-打开浏览器页面"),
mainPageNewSource: (): void => tdApp.onEvent("下载页面-新建下载"),
mainPageHelp: (): void => tdApp.onEvent("下载页面-打开使用帮助"),
tableStartDownload: (): void => tdApp.onEvent("资源表格-下载按钮"),
tableReNewStatus: (): void => tdApp.onEvent("资源表格-重置状态"),
toSettingPage: (): void => tdApp.onEvent("下载页面-点击切换设置页面"),
toMainPage: (): void => tdApp.onEvent("下载页面-点击切换主页面"),
favPageAddFav: (): void => tdApp.onEvent("收藏页面-添加收藏"),
favPageOpenLink: (): void => tdApp.onEvent("收藏页面-打开链接"),
favPageDeleteLink: (): void => tdApp.onEvent("收藏页面-删除链接"),
addSourceDownload: (): void => tdApp.onEvent("新建下载-立即下载"),
addSourceAddSource: (): void => tdApp.onEvent("新建下载-添加资源"),
};
export default onEvent;

@ -1,32 +0,0 @@
import { version } from "../../package.json";
import store from "../store";
const isProd = process.env.NODE_ENV === "production";
class TDEvent {
appId?: string | boolean;
vn: string;
vc: string;
constructor() {
this.appId = import.meta.env.VITE_APP_TDID;
this.vn = isProd ? `${version}生产版` : `${version}开发版`;
this.vc = `${version}`;
}
init() {
const script = document.createElement("script");
script.src = `https://jic.talkingdata.com/app/h5/v1?appid=${this.appId}&vn=${this.vn}&vc=${this.vc}`;
const headElement = document.getElementsByTagName("head")[0];
headElement.appendChild(script);
}
onEvent(eventId: string, mapKv: any = {}) {
const { settings } = store.getState();
if (settings.statistics) {
window.TDAPP.onEvent(eventId, "", mapKv);
}
}
}
export default new TDEvent();

@ -1,9 +0,0 @@
const processHeaders = (headersStr: string): Record<string, string> =>
headersStr.split("\n").reduce((prev: Record<string, string>, cur) => {
const colonIndex = cur.indexOf(":");
const key = cur.slice(0, colonIndex).trim();
prev[key] = cur.slice(colonIndex + 1).trim();
return prev;
}, {});
export { processHeaders };

@ -1,22 +0,0 @@
const helpUrl =
"https://blog.ziying.site/post/media-downloader-how-to-use/?form=client";
interface Option {
value: string;
label: string;
}
const downloaderOptions: Option[] = [];
if (window.electron.isWindows) {
downloaderOptions.push({
value: "N_m3u8DL-CLI",
label: "N_m3u8DL-CLI推荐",
});
}
downloaderOptions.push({
value: "mediago",
label: "mediago",
});
export { downloaderOptions, helpUrl };

@ -1,39 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"types": [
"vite/client"
],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"skipDefaultLibCheck": true,
"noEmit": true,
"typeRoots": [
"node_modules/@types"
],
"jsx": "react",
"baseUrl": "./src",
"paths": {
}
},
"include": [
"./src/**/*"
],
"files": [
"./renderer.d.ts",
"../global.d.ts"
]
}

@ -1,26 +0,0 @@
import {defineConfig} from "vite";
import {resolve} from "path";
import reactRefresh from "@vitejs/plugin-react-refresh";
export default defineConfig({
root: __dirname,
server: {
port: 7789,
strictPort: true,
},
resolve: {
alias: [
{find: /^types/, replacement: resolve(__dirname, "../src/types")},
{find: /^~/, replacement: ""},
],
},
envDir: "../../",
plugins: [reactRefresh()],
css: {
preprocessorOptions: {
less: {
javascriptEnabled: true,
},
},
},
})

@ -1,6 +0,0 @@
.eslintrc.js
esbuild.config.js
babel.config.js
webpack.config.js
script/
vite.config.ts

@ -1,19 +0,0 @@
module.exports = {
root: true,
env: {
browser: true,
es6: true,
node: true,
},
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "react"],
extends: [
"plugin:@typescript-eslint/recommended",
"react-app",
"plugin:prettier/recommended",
],
rules: {
"@typescript-eslint/ban-ts-comment": "warn",
"prettier/prettier": "error",
},
};

@ -1,5 +0,0 @@
node_modules
pack
build
.bin
dist

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

@ -1,22 +0,0 @@
import { series, parallel, src, dest } from "gulp";
import del from "del";
async function clean() {
await del(["dist", "build", ".bin"]);
}
function copyMain() {
return src("../app-main/dist/**/*").pipe(dest("./dist/main"));
}
function copyRenderer() {
return src("../app-renderer/dist/**/*").pipe(dest("./dist/renderer"));
}
function copyBin() {
return src("../app-main/.bin/**/*").pipe(dest(".bin"));
}
const source = series(clean, parallel(copyMain, copyRenderer, copyBin));
export { source };

@ -1,80 +0,0 @@
{
"name": "app",
"version": "1.0.0",
"description": "hello world ~",
"main": "main/index.js",
"scripts": {
"pack": "gulp source && electron-builder --dir",
"build": "gulp source && electron-builder"
},
"keywords": [],
"author": "",
"license": "ISC",
"build": {
"productName": "在线视频下载",
"appId": "mediago.ziying.site",
"copyright": "caorushizi",
"artifactName": "media-downloader-setup-${version}.${ext}",
"directories": {
"output": "./build"
},
"files": [
{
"from": "dist",
"to": "./",
"filter": [
"**/*"
]
},
"./package.json"
],
"extraResources": [
".bin/**"
],
"win": {
"icon": "assets/icon.ico",
"requestedExecutionLevel": "requireAdministrator",
"target": [
{
"target": "nsis"
}
]
},
"dmg": {
"contents": []
},
"mac": {
"icon": "build/icons/icon.icns"
},
"linux": {
"icon": "build/icons"
},
"nsis": {
"oneClick": false,
"allowElevation": true,
"allowToChangeInstallationDirectory": true,
"installerIcon": "",
"uninstallerIcon": "",
"installerHeaderIcon": "",
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "",
"include": "",
"script": ""
}
},
"devDependencies": {
"@types/gulp": "^4.0.9",
"@types/node": "^14.14.35",
"@typescript-eslint/eslint-plugin": "^4.0.0",
"@typescript-eslint/parser": "^4.0.0",
"asar": "^3.1.0",
"del": "^6.0.0",
"electron": "16.0.4",
"electron-builder": "^22.14.2",
"gulp": "^4.0.2",
"gulp-copy": "^4.0.1",
"prettier": ">=1.13.0",
"ts-node": "^10.7.0"
}
}

@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"baseUrl": "./src",
"resolveJsonModule": true,
"typeRoots": [
"node_modules/@types",
]
}
}

137
packages/global.d.ts vendored

@ -1,137 +0,0 @@
declare interface SourceUrl {
id: string;
title: string;
duration: number;
url: string;
headers?: Record<string, string>;
}
declare type SourceStatus = "ready" | "downloading" | "failed" | "success";
declare type SourceType = "m3u8" | "m4s";
declare type SourceItem = SourceUrl & {
status: SourceStatus;
type: SourceType;
deleteSegments: boolean;
directory: string;
createdAt: number;
exeFile: string;
// 额外字段
checkbox?: string[];
maxThreads?: number;
minThreads?: number;
retryCount?: number;
timeOut?: number;
stopSpeed?: number;
maxSpeed?: number;
};
declare interface Fav {
url: string;
title: string;
}
declare interface SourceItemForm {
title: string;
url: string;
headers?: string;
delete: boolean;
}
// M3u8DL 全部参数
declare interface M3u8DLArgs {
url: string; // 视频地址
workDir: string; // 设定程序工作目录
saveName: string; // 设定存储文件名(不包括后缀)
baseUrl?: string; // 设定Baseurl
headers?: string; // 设定请求头,格式 key:value 使用|分割不同的key&value
maxThreads?: number; // 设定程序的最大线程数(默认为32)
minThreads?: number; // 设定程序的最小线程数(默认为16)
retryCount?: number; // 设定程序的重试次数(默认为15)
timeOut?: number; // 设定程序网络请求的超时时间(单位为秒默认为10秒)
muxSetJson?: string; // 使用外部json文件定义混流选项
useKeyFile?: string; // 使用外部16字节文件定义AES-128解密KEY
useKeyBase64?: string; // 使用Base64字符串定义AES-128解密KEY
useKeyIV?: string; // 使用HEX字符串定义AES-128解密IV
downloadRange?: string; // 仅下载视频的一部分分片或长度
liveRecDur?: string; // 直播录制时,达到此长度自动退出软件
stopSpeed?: number; // 当速度低于此值时,重试(单位为KB/s)
maxSpeed?: number; // 设置下载速度上限(单位为KB/s)
proxyAddress?: string; // 设置HTTP代理, 如 http://127.0.0.1:8080
enableDelAfterDone?: boolean; // 开启下载后删除临时文件夹的功能
enableMuxFastStart?: boolean; // 开启混流mp4的FastStart特性
enableBinaryMerge?: boolean; // 开启二进制合并分片
enableParseOnly?: boolean; // 开启仅解析模式(程序只进行到meta.json)
enableAudioOnly?: boolean; // 合并时仅封装音频轨道
disableDateInfo?: boolean; // 关闭混流中的日期写入
noMerge?: boolean; // 禁用自动合并
noProxy?: boolean; // 不自动使用系统代理
}
// mediago 全部参数
declare interface MediaGoArgs {
path: string;
name: string;
url: string;
headers?: string;
}
declare interface VideoDetail {
segmentsLen: number;
duration: number;
}
declare interface IpcResponse {
code: number;
msg: string;
data: any;
}
interface ElectronApi {
store: {
get: (key?: string) => Promise<any>;
set: (key: string, value: any) => Promise<void>;
};
isWindows: boolean;
isMacos: boolean;
ipcExec: (
exeFile: string,
args: M3u8DLArgs | MediaGoArgs
) => Promise<IpcRendererResp>;
openBinDir: () => void;
openConfigDir: () => void;
openPath: (workspace: string) => Promise<string>;
openExternal: (
url: string,
options?: Electron.OpenExternalOptions
) => Promise<void>;
openBrowserWindow: (url?: string) => void;
getPath: (name: string) => Promise<string>;
showOpenDialog: (options: Electron.OpenDialogOptions) => Promise<any>;
closeBrowserWindow: () => void;
getBrowserView: () => Promise<Electron.BrowserView | null>;
addEventListener: (
channel: string,
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
) => void;
removeEventListener: (
channel: string,
listener: (...args: any[]) => void
) => void;
setBrowserViewRect: (rect: BrowserViewRect) => void;
closeMainWindow: () => void;
browserViewGoBack: () => void;
browserViewReload: () => void;
browserViewLoadURL: (url?: string) => void;
itemContextMenu: (item: SourceItem) => void;
minimize: (name: string) => void;
}
declare interface AppStore {
workspace: string;
tip: boolean;
proxy: string;
useProxy: boolean;
exeFile: string;
statistics: boolean; // 是否允许打点统计
}

@ -1,7 +0,0 @@
.eslintrc.js
esbuild.config.js
babel.config.js
webpack.config.js
script/
vite.config.ts
build

@ -1,19 +0,0 @@
module.exports = {
root: true,
env: {
browser: true,
es6: true,
node: true,
},
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "react"],
extends: [
"plugin:@typescript-eslint/recommended",
"react-app",
"plugin:prettier/recommended",
],
rules: {
"@typescript-eslint/ban-ts-comment": "warn",
"prettier/prettier": "error",
},
};

@ -1,776 +0,0 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/t5eo7UJc.ts
#EXTINF:2,
/20220403/vYigLW9d/2000kb/hls/706YAUIb.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/cIakdl28.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/z86xtPYy.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/88YoVaqA.ts
#EXTINF:1.333,
/20220403/vYigLW9d/2000kb/hls/83RyPrv4.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/1A1kqBnX.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/1I8etuwA.ts
#EXTINF:3.533,
/20220403/vYigLW9d/2000kb/hls/9SNKZYbG.ts
#EXTINF:2.2,
/20220403/vYigLW9d/2000kb/hls/PcZY4skP.ts
#EXTINF:3.3,
/20220403/vYigLW9d/2000kb/hls/GMpnFWSe.ts
#EXTINF:2.033,
/20220403/vYigLW9d/2000kb/hls/MarDDLjN.ts
#EXTINF:4.267,
/20220403/vYigLW9d/2000kb/hls/uIFKckMw.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/TNJcOS5v.ts
#EXTINF:3.333,
/20220403/vYigLW9d/2000kb/hls/MsQHIybb.ts
#EXTINF:2,
/20220403/vYigLW9d/2000kb/hls/9W72xh9e.ts
#EXTINF:2.267,
/20220403/vYigLW9d/2000kb/hls/z1AdcblH.ts
#EXTINF:4.867,
/20220403/vYigLW9d/2000kb/hls/c5gdXzYK.ts
#EXTINF:1.5,
/20220403/vYigLW9d/2000kb/hls/mcs6Ba22.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/Dwig7hZE.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/eDPIZBKy.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/MQ6iecaN.ts
#EXTINF:2.233,
/20220403/vYigLW9d/2000kb/hls/f4KKdTdo.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/3zE3ia0D.ts
#EXTINF:4.833,
/20220403/vYigLW9d/2000kb/hls/rvhv6jsi.ts
#EXTINF:1.533,
/20220403/vYigLW9d/2000kb/hls/sWw98TuL.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/dhasXTI9.ts
#EXTINF:2.767,
/20220403/vYigLW9d/2000kb/hls/48Wblrva.ts
#EXTINF:4.167,
/20220403/vYigLW9d/2000kb/hls/UjIYPgYi.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/Elz31cnU.ts
#EXTINF:4.433,
/20220403/vYigLW9d/2000kb/hls/Trkg0HM1.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/oBoElrl4.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/2l1dt2Gq.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/e9KaBuCZ.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/XP1OrEtk.ts
#EXTINF:3.9,
/20220403/vYigLW9d/2000kb/hls/LaPq6unu.ts
#EXTINF:2.767,
/20220403/vYigLW9d/2000kb/hls/EkOJwON5.ts
#EXTINF:4.1,
/20220403/vYigLW9d/2000kb/hls/sJXDpsjn.ts
#EXTINF:1.4,
/20220403/vYigLW9d/2000kb/hls/zNss1HVO.ts
#EXTINF:2.733,
/20220403/vYigLW9d/2000kb/hls/Kl383ASH.ts
#EXTINF:3.633,
/20220403/vYigLW9d/2000kb/hls/LOSmsdUv.ts
#EXTINF:4.4,
/20220403/vYigLW9d/2000kb/hls/WVJBjNYR.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/VatTTh74.ts
#EXTINF:2.3,
/20220403/vYigLW9d/2000kb/hls/wK3wT1Ad.ts
#EXTINF:2.267,
/20220403/vYigLW9d/2000kb/hls/WyxC5cRs.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/vXw3Yvlw.ts
#EXTINF:2.033,
/20220403/vYigLW9d/2000kb/hls/j848QgUU.ts
#EXTINF:3.267,
/20220403/vYigLW9d/2000kb/hls/vZuFRSC2.ts
#EXTINF:2.267,
/20220403/vYigLW9d/2000kb/hls/khLlqDhl.ts
#EXTINF:4.367,
/20220403/vYigLW9d/2000kb/hls/yI6k6tqd.ts
#EXTINF:2.467,
/20220403/vYigLW9d/2000kb/hls/vLTUDGHT.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/1Qnlv9gs.ts
#EXTINF:2.3,
/20220403/vYigLW9d/2000kb/hls/9QftUk0b.ts
#EXTINF:4.967,
/20220403/vYigLW9d/2000kb/hls/nzVsWQyV.ts
#EXTINF:1.6,
/20220403/vYigLW9d/2000kb/hls/sGIf1Y1I.ts
#EXTINF:3.9,
/20220403/vYigLW9d/2000kb/hls/XdFIZr26.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/adJWu1dT.ts
#EXTINF:1.933,
/20220403/vYigLW9d/2000kb/hls/lv5VAZaV.ts
#EXTINF:4.867,
/20220403/vYigLW9d/2000kb/hls/JRD6MhEx.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/La2zYgcl.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/vwONQNw1.ts
#EXTINF:3.4,
/20220403/vYigLW9d/2000kb/hls/jOI1c4eE.ts
#EXTINF:1.867,
/20220403/vYigLW9d/2000kb/hls/2UVVNmdL.ts
#EXTINF:4.733,
/20220403/vYigLW9d/2000kb/hls/DXkr0vHc.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/p8HsK5r1.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/6psAHcxv.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/3WanAAgn.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/80kRDJm7.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/IsVVrQ2c.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/usHCnusH.ts
#EXTINF:2.3,
/20220403/vYigLW9d/2000kb/hls/8GE5mUvQ.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/e7rpEev2.ts
#EXTINF:4.467,
/20220403/vYigLW9d/2000kb/hls/np8AfYjt.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/s58ONNZ7.ts
#EXTINF:1.967,
/20220403/vYigLW9d/2000kb/hls/E1LzBUcc.ts
#EXTINF:3.733,
/20220403/vYigLW9d/2000kb/hls/7JbrmjAC.ts
#EXTINF:2,
/20220403/vYigLW9d/2000kb/hls/PUjhsE57.ts
#EXTINF:3.7,
/20220403/vYigLW9d/2000kb/hls/5GEZl5TM.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/NVkKXuHv.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/YanyufXw.ts
#EXTINF:2.367,
/20220403/vYigLW9d/2000kb/hls/lqyMzd0i.ts
#EXTINF:3.067,
/20220403/vYigLW9d/2000kb/hls/cEb6bEaj.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/z2otxYkV.ts
#EXTINF:2.067,
/20220403/vYigLW9d/2000kb/hls/k9usPpiP.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/phuwiZUb.ts
#EXTINF:1.633,
/20220403/vYigLW9d/2000kb/hls/uGlCPgmg.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/xOXtNxBM.ts
#EXTINF:4.667,
/20220403/vYigLW9d/2000kb/hls/zxJPoxiH.ts
#EXTINF:1.567,
/20220403/vYigLW9d/2000kb/hls/UFTUL5bw.ts
#EXTINF:4.067,
/20220403/vYigLW9d/2000kb/hls/GP9fhyIe.ts
#EXTINF:2.467,
/20220403/vYigLW9d/2000kb/hls/IIk6Zjf6.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/s7KG1ykn.ts
#EXTINF:3.567,
/20220403/vYigLW9d/2000kb/hls/QQ0RAnzu.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/d5wMy6wS.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/uVrKf5EN.ts
#EXTINF:4.267,
/20220403/vYigLW9d/2000kb/hls/kM7BtbLW.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/foO4jPuI.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/dAwe4jeq.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/ZgJd7IPo.ts
#EXTINF:2.067,
/20220403/vYigLW9d/2000kb/hls/yXar6npQ.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/HKwZYm0Z.ts
#EXTINF:3.867,
/20220403/vYigLW9d/2000kb/hls/6jo758Em.ts
#EXTINF:1.533,
/20220403/vYigLW9d/2000kb/hls/p2ynBVxJ.ts
#EXTINF:4.4,
/20220403/vYigLW9d/2000kb/hls/pDDl66UB.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/JoOdQS33.ts
#EXTINF:3.2,
/20220403/vYigLW9d/2000kb/hls/hNoIosNP.ts
#EXTINF:2.733,
/20220403/vYigLW9d/2000kb/hls/kVRiTkMM.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/mGjdMFeR.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/WxRyxs9c.ts
#EXTINF:3.033,
/20220403/vYigLW9d/2000kb/hls/cAxO7by8.ts
#EXTINF:3.767,
/20220403/vYigLW9d/2000kb/hls/BCCGIMrj.ts
#EXTINF:3.733,
/20220403/vYigLW9d/2000kb/hls/qCGefmpt.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/wNGRPtQk.ts
#EXTINF:2.467,
/20220403/vYigLW9d/2000kb/hls/0DLbmv8H.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/mnDjL8FL.ts
#EXTINF:4.633,
/20220403/vYigLW9d/2000kb/hls/q1DROtgj.ts
#EXTINF:2.467,
/20220403/vYigLW9d/2000kb/hls/eRdZZB3Q.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/206bn5gF.ts
#EXTINF:4.3,
/20220403/vYigLW9d/2000kb/hls/vJ4j9NlY.ts
#EXTINF:1.567,
/20220403/vYigLW9d/2000kb/hls/IrST8aJa.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/TB7EVpH4.ts
#EXTINF:3,
/20220403/vYigLW9d/2000kb/hls/pgSbTkRa.ts
#EXTINF:3,
/20220403/vYigLW9d/2000kb/hls/VUB5fnFj.ts
#EXTINF:2.8,
/20220403/vYigLW9d/2000kb/hls/6AUr1aug.ts
#EXTINF:3.167,
/20220403/vYigLW9d/2000kb/hls/ITDpDQCm.ts
#EXTINF:3.7,
/20220403/vYigLW9d/2000kb/hls/5vAVqiK6.ts
#EXTINF:2.467,
/20220403/vYigLW9d/2000kb/hls/hCeCEifT.ts
#EXTINF:4.533,
/20220403/vYigLW9d/2000kb/hls/KbXjtT7B.ts
#EXTINF:1.167,
/20220403/vYigLW9d/2000kb/hls/m6eR5lKm.ts
#EXTINF:4.367,
/20220403/vYigLW9d/2000kb/hls/nRXGIeQf.ts
#EXTINF:2.267,
/20220403/vYigLW9d/2000kb/hls/RWIDKv9z.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/2uXbwOhm.ts
#EXTINF:3.833,
/20220403/vYigLW9d/2000kb/hls/WEtIDeJC.ts
#EXTINF:2.4,
/20220403/vYigLW9d/2000kb/hls/RjnjOOJ6.ts
#EXTINF:3.667,
/20220403/vYigLW9d/2000kb/hls/2rfYOB9U.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/08WxNMa8.ts
#EXTINF:3.267,
/20220403/vYigLW9d/2000kb/hls/N7kYyBdX.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/ZWgswIQk.ts
#EXTINF:4.1,
/20220403/vYigLW9d/2000kb/hls/gCYj6e52.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/qSmuM4lg.ts
#EXTINF:3.633,
/20220403/vYigLW9d/2000kb/hls/yhvvE5Bk.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/0QHYV2zk.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/n9y9RN31.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/4mz31ZeQ.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/GVZCXcqz.ts
#EXTINF:1.233,
/20220403/vYigLW9d/2000kb/hls/YoLD3h36.ts
#EXTINF:3,
/20220403/vYigLW9d/2000kb/hls/jNFKaJfU.ts
#EXTINF:3.467,
/20220403/vYigLW9d/2000kb/hls/EiGpSpH1.ts
#EXTINF:3.2,
/20220403/vYigLW9d/2000kb/hls/C6NPhAr7.ts
#EXTINF:2.633,
/20220403/vYigLW9d/2000kb/hls/JcbhSqUC.ts
#EXTINF:3.967,
/20220403/vYigLW9d/2000kb/hls/SfB27QCE.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/lmSGC6g4.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/TBKnedXe.ts
#EXTINF:2.733,
/20220403/vYigLW9d/2000kb/hls/jQoEo9YD.ts
#EXTINF:3.4,
/20220403/vYigLW9d/2000kb/hls/78bZkJHa.ts
#EXTINF:3.733,
/20220403/vYigLW9d/2000kb/hls/Qfl8IKxW.ts
#EXTINF:2.333,
/20220403/vYigLW9d/2000kb/hls/uiwmoB4k.ts
#EXTINF:4.2,
/20220403/vYigLW9d/2000kb/hls/pI3HdzcA.ts
#EXTINF:3.4,
/20220403/vYigLW9d/2000kb/hls/QZE0nrzv.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/rucsUPbV.ts
#EXTINF:2.167,
/20220403/vYigLW9d/2000kb/hls/fQ9zPDFJ.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/I49ZdCE2.ts
#EXTINF:4.067,
/20220403/vYigLW9d/2000kb/hls/w88o7C7O.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/tPhaP4MD.ts
#EXTINF:3.833,
/20220403/vYigLW9d/2000kb/hls/KcL9c6AS.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/COYo22d3.ts
#EXTINF:3,
/20220403/vYigLW9d/2000kb/hls/De4iegyu.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/MjSMdjEL.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/HL7pfalc.ts
#EXTINF:3.633,
/20220403/vYigLW9d/2000kb/hls/MjlHgW31.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/jCukIVpP.ts
#EXTINF:3.233,
/20220403/vYigLW9d/2000kb/hls/8fYCqQvk.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/FRXNetpO.ts
#EXTINF:2.933,
/20220403/vYigLW9d/2000kb/hls/poU9MWVi.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/bUVTl38I.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/pVvI9MlP.ts
#EXTINF:1.7,
/20220403/vYigLW9d/2000kb/hls/POV4ldN7.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/LdTXKXnt.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/oigE7FnA.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/UUwFxEaL.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/plkrZi5A.ts
#EXTINF:2.467,
/20220403/vYigLW9d/2000kb/hls/dEi83VA8.ts
#EXTINF:4.167,
/20220403/vYigLW9d/2000kb/hls/2ZtBZ1OT.ts
#EXTINF:1.8,
/20220403/vYigLW9d/2000kb/hls/3a1kQv30.ts
#EXTINF:3.3,
/20220403/vYigLW9d/2000kb/hls/EAjkkKMi.ts
#EXTINF:3.367,
/20220403/vYigLW9d/2000kb/hls/4ix5WdcO.ts
#EXTINF:2.167,
/20220403/vYigLW9d/2000kb/hls/E6hteO4X.ts
#EXTINF:3.767,
/20220403/vYigLW9d/2000kb/hls/T98eZg0Y.ts
#EXTINF:3.733,
/20220403/vYigLW9d/2000kb/hls/dVrGaseO.ts
#EXTINF:1.8,
/20220403/vYigLW9d/2000kb/hls/I4ro2Y7q.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/Bkb3XPcT.ts
#EXTINF:4.4,
/20220403/vYigLW9d/2000kb/hls/Lc9Xvz5E.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/Rs0PjtYa.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/4Z6KRbur.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/IzDwiQ60.ts
#EXTINF:2.4,
/20220403/vYigLW9d/2000kb/hls/T40wwc6R.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/hNRowS5S.ts
#EXTINF:1.8,
/20220403/vYigLW9d/2000kb/hls/zs0ydhMB.ts
#EXTINF:4.333,
/20220403/vYigLW9d/2000kb/hls/FspYUTnx.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/YVD6Rcxg.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/xQsjCHsd.ts
#EXTINF:4.867,
/20220403/vYigLW9d/2000kb/hls/qqbWySV6.ts
#EXTINF:1.6,
/20220403/vYigLW9d/2000kb/hls/V8EUVy0G.ts
#EXTINF:2.1,
/20220403/vYigLW9d/2000kb/hls/Wb9PjOnv.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/CoRxwdwa.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/EJFlAgRs.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/bHHktyEW.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/p8utXick.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/JUWfx8tt.ts
#EXTINF:4.933,
/20220403/vYigLW9d/2000kb/hls/6sDSWC0Z.ts
#EXTINF:1.933,
/20220403/vYigLW9d/2000kb/hls/TG5YLkkO.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/D6Clcho3.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/Y7hvdg8W.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/LN4zhFhI.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/MG0PwXNW.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/DyPIHdYq.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/lHYhmKmB.ts
#EXTINF:4.867,
/20220403/vYigLW9d/2000kb/hls/ngtEs8B2.ts
#EXTINF:1.733,
/20220403/vYigLW9d/2000kb/hls/ZzGpxumG.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/849F3bgP.ts
#EXTINF:4,
/20220403/vYigLW9d/2000kb/hls/c9hYG9gp.ts
#EXTINF:2.733,
/20220403/vYigLW9d/2000kb/hls/QJyyMuLB.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/8v01BmlR.ts
#EXTINF:4.167,
/20220403/vYigLW9d/2000kb/hls/hTYb4V2O.ts
#EXTINF:1.333,
/20220403/vYigLW9d/2000kb/hls/wpPS4hyQ.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/AnHB37yl.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/HooV3tle.ts
#EXTINF:3.733,
/20220403/vYigLW9d/2000kb/hls/c1ZabaQ4.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/IAEbI36r.ts
#EXTINF:2.333,
/20220403/vYigLW9d/2000kb/hls/8DCVlJ09.ts
#EXTINF:1.8,
/20220403/vYigLW9d/2000kb/hls/N8HMMeea.ts
#EXTINF:3.967,
/20220403/vYigLW9d/2000kb/hls/ujR8ZE0c.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/nlr8Z3ej.ts
#EXTINF:4.333,
/20220403/vYigLW9d/2000kb/hls/cDOZl4An.ts
#EXTINF:2.3,
/20220403/vYigLW9d/2000kb/hls/AT2UaUb0.ts
#EXTINF:2.1,
/20220403/vYigLW9d/2000kb/hls/N2QXjQKF.ts
#EXTINF:4.367,
/20220403/vYigLW9d/2000kb/hls/Y4XQ0wlW.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/5FKB7VvR.ts
#EXTINF:3.333,
/20220403/vYigLW9d/2000kb/hls/Cp7G4tnT.ts
#EXTINF:3.067,
/20220403/vYigLW9d/2000kb/hls/Xpjt0KT7.ts
#EXTINF:2.833,
/20220403/vYigLW9d/2000kb/hls/aDrTftmH.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/H9SwidJJ.ts
#EXTINF:4.1,
/20220403/vYigLW9d/2000kb/hls/EpFEVBxf.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/jofCvprP.ts
#EXTINF:1.7,
/20220403/vYigLW9d/2000kb/hls/USGDuXs0.ts
#EXTINF:4.4,
/20220403/vYigLW9d/2000kb/hls/GS5Z5Dn4.ts
#EXTINF:1.633,
/20220403/vYigLW9d/2000kb/hls/pWgRxqg7.ts
#EXTINF:4.733,
/20220403/vYigLW9d/2000kb/hls/G7kPs4q2.ts
#EXTINF:1.6,
/20220403/vYigLW9d/2000kb/hls/QcbRQdRa.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/8C6ICMZa.ts
#EXTINF:4.4,
/20220403/vYigLW9d/2000kb/hls/ntDmICwe.ts
#EXTINF:2.167,
/20220403/vYigLW9d/2000kb/hls/8uyUmTSa.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/17WuZT5j.ts
#EXTINF:5.167,
/20220403/vYigLW9d/2000kb/hls/azio7ymn.ts
#EXTINF:2.167,
/20220403/vYigLW9d/2000kb/hls/HmKIMZ38.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/qYmRSheZ.ts
#EXTINF:3.067,
/20220403/vYigLW9d/2000kb/hls/JfSqS2p1.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/dO3m0Gcb.ts
#EXTINF:4.833,
/20220403/vYigLW9d/2000kb/hls/aLqyInZX.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/88WlZZta.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/x12pOqep.ts
#EXTINF:2.867,
/20220403/vYigLW9d/2000kb/hls/sTTXk7wt.ts
#EXTINF:2.867,
/20220403/vYigLW9d/2000kb/hls/gWfuXgeh.ts
#EXTINF:2.067,
/20220403/vYigLW9d/2000kb/hls/zXhcG1rm.ts
#EXTINF:3.7,
/20220403/vYigLW9d/2000kb/hls/a9qx5uqU.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/KDVBrUOU.ts
#EXTINF:2.933,
/20220403/vYigLW9d/2000kb/hls/sFu615hg.ts
#EXTINF:4.333,
/20220403/vYigLW9d/2000kb/hls/PwqBhfan.ts
#EXTINF:2.2,
/20220403/vYigLW9d/2000kb/hls/URqyyHoO.ts
#EXTINF:2.467,
/20220403/vYigLW9d/2000kb/hls/qRJXlroA.ts
#EXTINF:4.7,
/20220403/vYigLW9d/2000kb/hls/SbSlhZs7.ts
#EXTINF:1.167,
/20220403/vYigLW9d/2000kb/hls/sBp1Bn9Y.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/YoH7n2PH.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/UnCkyZPa.ts
#EXTINF:2.567,
/20220403/vYigLW9d/2000kb/hls/4cEwTOZA.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/ZU6NJ4P5.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/NwusKC8v.ts
#EXTINF:3.8,
/20220403/vYigLW9d/2000kb/hls/Ql1GWZbd.ts
#EXTINF:4.033,
/20220403/vYigLW9d/2000kb/hls/5LNsvGuV.ts
#EXTINF:1.4,
/20220403/vYigLW9d/2000kb/hls/uol5nkqV.ts
#EXTINF:4.4,
/20220403/vYigLW9d/2000kb/hls/toUQdDo3.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/Qhq6CDit.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/Qu2mp7Zt.ts
#EXTINF:2.967,
/20220403/vYigLW9d/2000kb/hls/TxvvehEs.ts
#EXTINF:3.1,
/20220403/vYigLW9d/2000kb/hls/jMTmR52L.ts
#EXTINF:3.867,
/20220403/vYigLW9d/2000kb/hls/6gRVrgWj.ts
#EXTINF:3.633,
/20220403/vYigLW9d/2000kb/hls/6Mx1AU1v.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/q0jFU4q7.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/8z7NXZPM.ts
#EXTINF:2.033,
/20220403/vYigLW9d/2000kb/hls/F8kPMK7T.ts
#EXTINF:3.667,
/20220403/vYigLW9d/2000kb/hls/YvltMOS5.ts
#EXTINF:3.6,
/20220403/vYigLW9d/2000kb/hls/Pf8PEtwV.ts
#EXTINF:2.467,
/20220403/vYigLW9d/2000kb/hls/9AZDXiqJ.ts
#EXTINF:2.167,
/20220403/vYigLW9d/2000kb/hls/vVOBge9M.ts
#EXTINF:2.833,
/20220403/vYigLW9d/2000kb/hls/CDNXcqYX.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/9teEclZh.ts
#EXTINF:3.233,
/20220403/vYigLW9d/2000kb/hls/6wu1gO1g.ts
#EXTINF:0.767,
/20220403/vYigLW9d/2000kb/hls/DV7FuJUl.ts
#EXTINF:4.167,
/20220403/vYigLW9d/2000kb/hls/QWAcbFDZ.ts
#EXTINF:2.033,
/20220403/vYigLW9d/2000kb/hls/xCpatSok.ts
#EXTINF:4.267,
/20220403/vYigLW9d/2000kb/hls/Cjijx2K1.ts
#EXTINF:2.733,
/20220403/vYigLW9d/2000kb/hls/xefX89bl.ts
#EXTINF:1.933,
/20220403/vYigLW9d/2000kb/hls/qyOB6n9j.ts
#EXTINF:4.633,
/20220403/vYigLW9d/2000kb/hls/elGUbEV9.ts
#EXTINF:2.067,
/20220403/vYigLW9d/2000kb/hls/1IctsAZd.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/4eKwnRrh.ts
#EXTINF:2.7,
/20220403/vYigLW9d/2000kb/hls/DO8V12fQ.ts
#EXTINF:3.667,
/20220403/vYigLW9d/2000kb/hls/2bD4lO1T.ts
#EXTINF:2.4,
/20220403/vYigLW9d/2000kb/hls/KUIUSkHS.ts
#EXTINF:2.7,
/20220403/vYigLW9d/2000kb/hls/D2hSmagi.ts
#EXTINF:3,
/20220403/vYigLW9d/2000kb/hls/zvMrAEWw.ts
#EXTINF:3.2,
/20220403/vYigLW9d/2000kb/hls/L6ezNikF.ts
#EXTINF:3.967,
/20220403/vYigLW9d/2000kb/hls/h4oqgSrs.ts
#EXTINF:2.467,
/20220403/vYigLW9d/2000kb/hls/usiLXpUz.ts
#EXTINF:2.933,
/20220403/vYigLW9d/2000kb/hls/VkNDxXfT.ts
#EXTINF:3.067,
/20220403/vYigLW9d/2000kb/hls/dAimbXwS.ts
#EXTINF:2.4,
/20220403/vYigLW9d/2000kb/hls/mLzNkCZH.ts
#EXTINF:5.133,
/20220403/vYigLW9d/2000kb/hls/USW5GRpG.ts
#EXTINF:2.267,
/20220403/vYigLW9d/2000kb/hls/vT5RdHa5.ts
#EXTINF:1.6,
/20220403/vYigLW9d/2000kb/hls/lxa7oiyZ.ts
#EXTINF:3.033,
/20220403/vYigLW9d/2000kb/hls/s46GuRws.ts
#EXTINF:3.5,
/20220403/vYigLW9d/2000kb/hls/0t0ILxZ7.ts
#EXTINF:3.067,
/20220403/vYigLW9d/2000kb/hls/ES50FHWS.ts
#EXTINF:2.667,
/20220403/vYigLW9d/2000kb/hls/qDBd5sVG.ts
#EXTINF:3.467,
/20220403/vYigLW9d/2000kb/hls/NmB1NVWk.ts
#EXTINF:4.367,
/20220403/vYigLW9d/2000kb/hls/pXR21QtL.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/Sp8MSXV9.ts
#EXTINF:2.267,
/20220403/vYigLW9d/2000kb/hls/ExkUp7NY.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/VNngmCbL.ts
#EXTINF:2.6,
/20220403/vYigLW9d/2000kb/hls/89vtqMT9.ts
#EXTINF:4.167,
/20220403/vYigLW9d/2000kb/hls/LNpaEr0d.ts
#EXTINF:3.1,
/20220403/vYigLW9d/2000kb/hls/0AgtCtTx.ts
#EXTINF:3.567,
/20220403/vYigLW9d/2000kb/hls/pmAPFU8t.ts
#EXTINF:2.467,
/20220403/vYigLW9d/2000kb/hls/FtrAo7ah.ts
#EXTINF:2,
/20220403/vYigLW9d/2000kb/hls/1RSbJZ9X.ts
#EXTINF:4.7,
/20220403/vYigLW9d/2000kb/hls/VnPeyyBm.ts
#EXTINF:1.567,
/20220403/vYigLW9d/2000kb/hls/NGflc9RC.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/wEcox3yS.ts
#EXTINF:3.667,
/20220403/vYigLW9d/2000kb/hls/UdgU8sQf.ts
#EXTINF:3.833,
/20220403/vYigLW9d/2000kb/hls/9RNPIQ2P.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/5CksFmhj.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/AARAigWU.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/AIpauOyQ.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/9pCUQQpw.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/11zX4AfT.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/RMtWPll1.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/gWeYPHA9.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/Pt9ym9HZ.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/X7Zgem11.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/yi8Yv40G.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/PnzpT119.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/yGjO9Ukb.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/fYExhh2G.ts
#EXTINF:4.333,
/20220403/vYigLW9d/2000kb/hls/0eUZ7jcR.ts
#EXTINF:1.867,
/20220403/vYigLW9d/2000kb/hls/mZxCVRiB.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/2DXCtLmF.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/n8bKlOws.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/8cQhQKDi.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/LlUoqGZ8.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/p2LXnrBu.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/xNpRMaVU.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/n3QTIBfH.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/uRoQXgmn.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/ryEykUOu.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/CdVFcu3e.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/dP2sAgZM.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/C6t9Ig7a.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/BhVQpNJY.ts
#EXTINF:2.4,
/20220403/vYigLW9d/2000kb/hls/W6wTfyec.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/8QQmYQ1r.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/7hIpyLtI.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/LoCyT5An.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/ZV3PuHLC.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/b99PvX7a.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/MfjLbkNd.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/ZLwBINbj.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/af1E5p03.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/CxcQQyxo.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/ys6Xl87w.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/PhJ4KsxI.ts
#EXTINF:5,
/20220403/vYigLW9d/2000kb/hls/HV5JzWv9.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/3yHs5ME9.ts
#EXTINF:1.9,
/20220403/vYigLW9d/2000kb/hls/CrNtkjJf.ts
#EXTINF:2.5,
/20220403/vYigLW9d/2000kb/hls/kEO3FqH7.ts
#EXTINF:4.133,
/20220403/vYigLW9d/2000kb/hls/RnBL53av.ts
#EXT-X-ENDLIST

@ -1,71 +0,0 @@
declare module "m3u8-parser";
declare interface Manifest {
allowCache: boolean;
endList: boolean;
mediaSequence: number;
discontinuitySequence: number;
playlistType: string;
custom: unknown;
playlists?: Playlist[];
mediaGroups: {
AUDIO: {
"GROUP-ID": {
NAME: {
default: boolean;
autoselect: boolean;
language: string;
uri: string;
instreamId: string;
characteristics: string;
forced: boolean;
};
};
};
VIDEO: unknown;
"CLOSED-CAPTIONS": unknown;
SUBTITLES: unknown;
};
dateTimeString: string;
dateTimeObject: Date;
targetDuration: number;
totalDuration: number;
discontinuityStarts: [number];
segments?: Segment[];
}
declare interface Playlist {
attributes: unknown;
uri: string;
timeline: number;
}
declare interface Segment {
byterange: {
length: number;
offset: number;
};
duration: number;
attributes: unknown;
discontinuity: number;
uri: string;
timeline: number;
key: {
method: string;
uri: string;
iv: string;
};
map: {
uri: string;
byterange: {
length: number;
offset: number;
};
};
"cue-out": string;
"cue-out-cont": string;
"cue-in": string;
custom: unknown;
}
declare module "spawn-args";

@ -1,5 +0,0 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
};

@ -1,39 +0,0 @@
{
"name": "mediago-node",
"version": "1.0.0",
"description": "",
"main": "build/index.js",
"scripts": {
"server": "node script/server.js",
"start": "tsc && node build/index.js",
"build": "tsc",
"test": "jest"
},
"keywords": [],
"author": "",
"license": "MIT",
"trasform": {},
"devDependencies": {
"@types/fs-extra": "^9.0.13",
"@types/jest": "^27.4.1",
"@types/lru-cache": "^7.6.1",
"@types/node": "^14.14.35",
"@typescript-eslint/eslint-plugin": "^4.0.0",
"@typescript-eslint/parser": "^4.0.0",
"eslint": "^7.5.0",
"jest": "^27.5.1",
"koa": "^2.13.4",
"koa-static": "^5.0.0",
"prettier": "2.2.1",
"ts-jest": "^27.1.4"
},
"dependencies": {
"axios": "^0.26.1",
"fs-extra": "^10.0.1",
"lodash": "^4.17.21",
"lru-cache": "^7.8.1",
"m3u8-parser": "^4.7.0",
"nanoid": "^3.1.30",
"spawn-args": "^0.2.0"
}
}

@ -1,9 +0,0 @@
const serve = require('koa-static');
const Koa = require('koa');
const app = new Koa();
const path = require('path')
app.use(serve(path.resolve(__dirname, '../example')));
app.listen(3000);
console.log('listening on port 3000');

@ -1,17 +0,0 @@
import LRU from "lru-cache";
const options = {
max: 500,
maxSize: 5000,
sizeCalculation: () => {
return 1;
},
ttl: 1000 * 60 * 5,
allowStale: false,
updateAgeOnGet: false,
updateAgeOnHas: false,
};
const cache = new LRU(options);
export default cache;

@ -1,72 +0,0 @@
import axios, { AxiosProxyConfig } from "axios";
import { Agent } from "https";
import { move, pathExists, createWriteStream } from "fs-extra";
const httpReg = /^https?:\/\//;
const isAbsReg = /^\//;
export default class Downloader {
constructor(private proxy?: AxiosProxyConfig) {}
static buildUrl(uri: string, baseUrl: string): string {
let url: string;
if (httpReg.test(uri)) {
url = uri;
} else {
const m3u8 = new URL(baseUrl);
if (isAbsReg.test(uri)) {
m3u8.pathname = uri;
} else {
const pathArr = m3u8.pathname.split("/");
pathArr.pop();
pathArr.push(uri);
m3u8.pathname = pathArr.join("/");
}
url = m3u8.toString();
}
return url;
}
async fetch(url: string): Promise<Buffer> {
const resp = await axios.get(url, {
httpsAgent: new Agent({
rejectUnauthorized: false,
}),
proxy: this.proxy,
responseType: "arraybuffer",
});
return resp.data;
}
async do(url: string, output: string, transforms: any[]): Promise<void> {
const exist = await pathExists(output);
if (exist) return;
const tmpFile = `${output}.tmp`;
const writer = createWriteStream(tmpFile);
const resp = await axios.get(url, {
httpsAgent: new Agent({
rejectUnauthorized: false,
}),
responseType: "stream",
proxy: this.proxy,
});
let pipeline = resp.data;
transforms.forEach((t) => {
pipeline = pipeline.pipe(t);
});
pipeline.pipe(writer);
return new Promise((resolve, reject) => {
writer.on("finish", async () => {
await move(tmpFile, output);
resolve();
});
writer.on("error", reject);
});
}
}

@ -1,16 +0,0 @@
import { nanoid } from "nanoid";
export class Task {
id: string;
timestamp: number;
status: "pending" | "retry" = "pending";
retryCount = 0;
lastFailedTime?: number;
runner: () => Promise<void>;
constructor(runner: () => Promise<void>) {
this.id = nanoid();
this.timestamp = Date.now();
this.runner = runner;
}
}

@ -1,145 +0,0 @@
import EventEmitter from "events";
import { Task } from "./Task";
import { throttle } from "lodash";
interface TaskOptions {
limit?: number;
debug?: boolean;
}
type RunnerStatus = "initial" | "running" | "suspended" | "terminated";
export class TaskRunner extends EventEmitter {
// 下载状态
private status: RunnerStatus = "initial";
// 暂停时的队列
private staging: Task[] = [];
// 全部的任务列表
private queue: Task[] = [];
// 当前正在处理的任务
private active: Task[] = [];
// 最大处理的任务数
private readonly limit: number;
private readonly debug: boolean;
private runTaskThrottle;
constructor(options?: TaskOptions) {
super();
const { limit = 5, debug = false } = options || {};
this.limit = limit;
this.debug = debug;
this.runTaskThrottle = throttle(this.runTask, 200);
}
pauseTask(): void {
if (this.status === "running") {
this.staging = this.queue.slice();
this.queue = [];
this.status = "suspended";
}
}
resumeTask(): void {
if (this.status === "suspended") {
this.queue = this.staging;
this.staging = [];
this.runTaskThrottle();
}
}
stopTask(): void {
if (this.status === "running") {
this.queue = [];
this.status = "terminated";
}
}
public addTask(task: Task, immediate?: boolean): void {
this.queue.push(task);
if (immediate) {
this.status = "running";
this.runTaskThrottle();
}
}
public run(): void {
this.status = "running";
this.runTaskThrottle();
}
private async execute(task: Task) {
try {
await task.runner();
// 任务执行成功
this.log(
`执行 ${task.id} 任务成功,目前队列中有 ${
this.queue.length + this.active.length - 1
} `
);
} catch (err) {
// 任务执行失败
task.status = "retry";
task.retryCount += 1;
task.lastFailedTime = Date.now();
if (this.status === "running") {
this.queue.push(task);
} else if (this.status === "suspended") {
this.staging.push(task);
}
this.log(`开始执行 ${task.id} 执行失败,失败 ${task.retryCount} 次。`);
this.log("错误信息是:", (err as any).message);
} finally {
// 处理当前正在活动的任务
const doneId = this.active.findIndex((i) => i.id === task.id);
this.active.splice(doneId, 1);
// 处理完成的任务
this.runTaskThrottle();
// 传输完成
if (this.queue.length === 0 && this.active.length === 0) {
this.emit("done");
}
}
}
private runTask() {
if (this.status === "suspended" || this.status === "terminated") {
return;
}
while (this.active.length < this.limit && this.queue.length > 0) {
const task = this.queue.shift();
// 如果任务队列中没有任务,进行下一次循环
if (!task) continue;
if (task.status === "pending") {
// 如果任务是 pending 状态直接执行任务
this.active.push(task);
this.execute(task);
} else if (task.status === "retry" && task.lastFailedTime) {
// 如果当前的任务是已经失败过
// 1. 判断重试的次数是不是大于15次
// 2. 判断当前执行的时间是否超过5s
if (task.lastFailedTime - Date.now() / 1000 <= 5) {
// fixme: 失败后重试
this.queue.push(task);
} else if (task.retryCount < 15) {
this.active.push(task);
this.execute(task);
}
}
}
}
private log(...args: unknown[]) {
if (this.debug) {
console.log(...args);
}
}
}

@ -1,123 +0,0 @@
import path from "path";
import { ensureDir, pathExists, writeFile } from "fs-extra";
import { concatVideo, parseManifest } from "../utils";
import { Task } from "./Task";
import Downloader from "./Downloader";
import { AxiosProxyConfig, AxiosRequestHeaders } from "axios";
import { TaskRunner } from "./TaskRunner";
import { CipherGCMTypes, createDecipheriv } from "crypto";
import cache from "./Cache";
export default class Workspace {
m3u8Path: string;
fileList: string;
cacheDir: string;
videoPath: string;
manifest?: Manifest;
segments?: Segment[];
runner: TaskRunner;
downloader: Downloader;
constructor(
private m3u8Url: string,
private baseDir: string,
private videoName: string,
proxy?: AxiosProxyConfig,
headers?: AxiosRequestHeaders
) {
const cacheDir = path.resolve(baseDir, videoName);
this.m3u8Path = path.resolve(cacheDir, "raw.m3u8");
this.fileList = path.resolve(cacheDir, "fileList.txt");
this.cacheDir = cacheDir;
this.videoPath = path.resolve(baseDir, `${videoName}.mp4`);
this.runner = new TaskRunner({
limit: 15,
debug: true,
});
this.downloader = new Downloader(proxy);
}
async prepare(): Promise<void> {
if (await pathExists(this.videoPath)) {
throw new Error("视频文件已经存在");
}
await ensureDir(this.cacheDir);
await this.prepareSegments();
await this.prepareSegmentTasks();
}
private async prepareSegments() {
const data = await this.downloader.fetch(this.m3u8Url);
this.manifest = await parseManifest(String(data));
const { playlists } = this.manifest || {};
if (playlists && playlists.length > 0) {
// todo: 选择 playlist
const [playlist] = playlists;
const url = Downloader.buildUrl(playlist.uri, this.m3u8Url);
const data = await this.downloader.fetch(url);
this.manifest = await parseManifest(String(data));
}
this.segments = this.manifest.segments;
}
private async prepareSegmentTasks(): Promise<void> {
let fileListContent = "";
if (!this.segments) {
return;
}
for (const [index, item] of Object.entries(this.segments)) {
const dest = path.resolve(this.cacheDir, `${index}.ts`);
fileListContent += `file '${dest}'\n`;
const sign = item?.key?.uri;
if (item.key) {
if (!cache.has(sign)) {
const keyUrl = Downloader.buildUrl(item.key.uri, this.m3u8Url);
const key = await this.downloader.fetch(keyUrl);
cache.set(sign, key);
}
}
const task = new Task(async () => {
const url = Downloader.buildUrl(item.uri, this.m3u8Url);
const transforms = [];
if (cache.has(sign)) {
const method = `${item.key.method}-cbc`.toLowerCase() as CipherGCMTypes;
const iv =
item.key.iv || Buffer.from(`${index}`.padStart(32, "0"), "hex");
const key = cache.get(sign) as string;
const transform = createDecipheriv(method, key, iv);
transforms.push(transform);
}
await this.downloader.do(url, dest, transforms);
});
this.runner.addTask(task);
}
await writeFile(this.fileList, fileListContent);
}
async run(): Promise<void> {
this.runner.run();
return new Promise((resolve) => {
this.runner.on("done", async () => {
await concatVideo(this.fileList, this.videoPath);
resolve();
});
});
}
}

@ -1,29 +0,0 @@
import { isUrl } from "./utils";
import { nanoid } from "nanoid";
import Workspace from "./core/Workspace";
import { AxiosProxyConfig, AxiosRequestHeaders } from "axios";
interface DownloaderOptions {
url: string;
name?: string;
path?: string;
proxy?: AxiosProxyConfig;
headers?: AxiosRequestHeaders;
}
export async function downloader(opts: DownloaderOptions): Promise<void> {
let { name, path: pathStr } = opts;
const { url, proxy, headers } = opts;
if (!name) name = nanoid(5);
if (!pathStr) pathStr = `${__dirname}/videos`;
if (!isUrl(url)) {
console.error("url 不是合法的url");
return;
}
const workspace = new Workspace(url, pathStr, name, proxy, headers);
await workspace.prepare();
await workspace.run();
}

@ -1,57 +0,0 @@
import { spawn } from "child_process";
import argsBuilder from "spawn-args";
import { Parser } from "m3u8-parser";
export async function spawnRunner(
command: string,
args: string,
opts?: unknown
): Promise<void> {
return new Promise((resolve, reject) => {
const spawnCommand = spawn("ffmpeg", argsBuilder(args, opts));
spawnCommand.stdout?.on("data", (data) => {
const value = data.toString().trim();
console.log(`stdout: ${value}`);
});
spawnCommand.stderr?.on("data", (data) => {
const value = data.toString().trim();
console.error(`stderr: ${value}`);
});
spawnCommand.on("close", (code) => {
if (code !== 0) {
reject(new Error("执行失败"));
} else {
resolve();
}
});
});
}
export function isUrl(urlStr: string): boolean {
return /http(s)?:\/\/([\w-]+\.)+[\w-]+(\/[\w- ./?%&=]*)?/.test(urlStr);
}
export async function concatVideo(
filelist: string,
video: string
): Promise<void> {
const args = `-f concat -safe 0 -i "${filelist}" -acodec copy -vcodec copy "${video}"`;
await spawnRunner("ffmpeg", args, { removequotes: "always" });
}
export async function parseManifest(rawM3u8: string): Promise<Manifest> {
const parser = new Parser();
parser.push(rawM3u8);
parser.end();
return parser.manifest as Manifest;
}
export async function sleep(duration = 0): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, duration);
});
}

@ -1,23 +0,0 @@
import { downloader } from "../src";
jest.setTimeout(1000000);
test("downloader", async () => {
const params = {
url: "https://ukzy.ukubf3.com/20220409/WtaJj2Hy/index.m3u8",
path: "C:\\Users\\caorushizi\\Desktop\\test-desktop",
name: "斗罗大陆 1x",
};
await downloader(params);
});
test("downloader 1", async () => {
const params = {
url: "https://iqiyi.sd-play.com/20211017/vQZfIgIp/index.m3u8",
path: "C:\\Users\\caorushizi\\Desktop\\test-desktop",
name: "斗罗大陆 11",
};
await downloader(params);
});

@ -1,26 +0,0 @@
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"baseUrl": "./src",
"resolveJsonModule": true,
"typeRoots": [
"node_modules/@types",
"src/types"
],
"outDir": "./build",
"sourceMap": true,
"declaration": true,
"declarationDir": "build"
},
"include": [
"./src/**/*"
],
"files": [
"./index.d.ts"
]
}

@ -0,0 +1,25 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"plugin:react/recommended",
"standard-with-typescript",
"prettier"
],
"overrides": [
],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"project": [
"./tsconfig.json"
]
},
"plugins": [
"react"
],
"rules": {
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save