Compare commits
59 Commits
v2.0.2
...
v2.0.3-beta.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bc3f83c88 | |||
| 1af2803c22 | |||
| fd6a81abfd | |||
| a383093914 | |||
| 13656429af | |||
| bc34881b38 | |||
| e3b11cb087 | |||
| 8df78ca377 | |||
| f12919c6bc | |||
| 00df324ae7 | |||
| f14f9ffc1b | |||
| a388b725bc | |||
| e64eaeefef | |||
| 6830d69b62 | |||
| 8f609a8e93 | |||
| 99c23f47c5 | |||
| 95f9e20f39 | |||
| df1e664bc9 | |||
| fec30a6453 | |||
| a048211486 | |||
| 465e9b2988 | |||
| af1ff19cb2 | |||
| eae470f2ec | |||
| 1933799e32 | |||
| c7977bf7b4 | |||
| ce8407ce46 | |||
| 5d313df190 | |||
| c560a09904 | |||
| df66a92989 | |||
| 52c9611e49 | |||
| 4018112a1d | |||
| 633a257e65 | |||
| 0666d41226 | |||
| c2cfe55e24 | |||
| cb7fbddd7a | |||
| 135d9fed1b | |||
| 267aedc594 | |||
| 325d0da930 | |||
| 615bc74d15 | |||
| 1ca6935d36 | |||
| 3bfb7139d3 | |||
| 14d44765e5 | |||
| 807dd2d920 | |||
| c9a0a7f7c2 | |||
| a3eea6d501 | |||
| 4ce7dcac1b | |||
| 096762f24c | |||
| baf358d223 | |||
| e72973346b | |||
| 41258ecee8 | |||
| b6a5be152a | |||
| d5d45c4935 | |||
| 81eb4ecf09 | |||
| 5a4cc8c449 | |||
| e7ab664310 | |||
| 3cb2fa27b9 | |||
| e29e41aa4c | |||
| cc62613640 | |||
| 2720bcc685 |
@@ -1,6 +1,6 @@
|
||||
APP_NAME=media-downloader
|
||||
APP_ID=mediago.ziying.site
|
||||
APP_COPYRIGHT=caorushizi
|
||||
APP_VERSION=2.0.2
|
||||
APP_VERSION=2.0.3-beta.2
|
||||
|
||||
APP_SERVER_PORT=8433
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
APP_TD_APPID=
|
||||
APP_CLARITY_APPID=
|
||||
APP_DOWNLOAD_DEBUG=true
|
||||
|
||||
GH_TOKEN=
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
APP_TD_APPID=
|
||||
APP_CLARITY_APPID=
|
||||
APP_DOWNLOAD_DEBUG=
|
||||
|
||||
GH_TOKEN=
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "" # See documentation for possible values
|
||||
- package-ecosystem: "npm" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
@@ -29,7 +29,9 @@ jobs:
|
||||
steps:
|
||||
# step1: check out repository
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
# step2: install node env
|
||||
- name: Install Node.js
|
||||
@@ -37,6 +39,13 @@ jobs:
|
||||
with:
|
||||
node-version: "16.x"
|
||||
|
||||
# step3: install go env
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "1.21.5"
|
||||
|
||||
# step4: install pnpm env
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: latest
|
||||
|
||||
@@ -3,3 +3,4 @@ node_modules
|
||||
*.local
|
||||
.idea
|
||||
.parcel-cache
|
||||
.vitepress
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "internal"]
|
||||
path = internal
|
||||
url = https://github.com/caorushizi/mediago
|
||||
@@ -1,25 +1,59 @@
|
||||
# media-downloader
|
||||
<img src="https://socialify.git.ci/caorushizi/m3u8-downloader/image?font=Inter&forks=1&issues=1&language=1&name=1&owner=1&pattern=Circuit%20Board&pulls=1&stargazers=1&theme=Auto" alt="MediaDownloader"/>
|
||||
|
||||
m3u8 视频在线提取工具 流媒体下载 m3u8 下载 桌面客户端 windows mac。
|
||||
可以直接在线获取 m3u8 链接地址,无需使用使用网络抓包,无需安装浏览器插件,可以直接带出请求标头……详情请参考[使用帮助](https://downloader.caorushizi.cn/guides.html?form=github)
|
||||
<div align="center">
|
||||
<h1>Media downloader</h1>
|
||||
<a href="https://downloader.caorushizi.cn/guides.html?form=github">快速开始</a>
|
||||
<span> • </span>
|
||||
<a href="https://downloader.caorushizi.cn?form=github">官网</a>
|
||||
<span> • </span>
|
||||
<a href="https://downloader.caorushizi.cn/documents.html?form=github">文档</a>
|
||||
<span> • </span>
|
||||
<a href="https://github.com/caorushizi/m3u8-downloader/discussions">Discussions</a>
|
||||
<br>
|
||||
<br>
|
||||
|
||||
## 上手指南
|
||||
<img alt="GitHub Downloads (all assets, all releases)" src="https://img.shields.io/github/downloads/caorushizi/m3u8-downloader/total">
|
||||
<img alt="GitHub Downloads (all assets, latest release)" src="https://img.shields.io/github/downloads/caorushizi/m3u8-downloader/latest/total">
|
||||
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/caorushizi/m3u8-downloader">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/caorushizi/m3u8-downloader">
|
||||
|
||||
以下指南将帮助你在本地机器上安装和运行该项目,进行开发和测试。关于如何将该项目部署到在线环境,请参考部署小节。
|
||||
<hr />
|
||||
</div>
|
||||
|
||||
## 安装要求
|
||||
## Intro
|
||||
|
||||
运行代码需要 node 和 pnpm,node 需要在官网下载安装,pnpm 可以通过`npm i -g pnpm`安装。
|
||||
本项目支持 m3u8 视频在线提取工具 流媒体下载 m3u8 下载。
|
||||
|
||||
- **✅ 无需抓包**: 使用软件自带浏览器可以轻松嗅探网页中的视频资源,通过嗅探到的资源列表选择自己想要下载的资源,简单快速。
|
||||
- **📱 移动播放**: 可以轻松无缝的在PC和移动设备之前切换,下载完成后即可使用手机观看视频。
|
||||
- **⚡️ 批量下载**: 支持同时下载多个视频和直播资源,高速带宽不闲置。
|
||||
|
||||
## Quickstart
|
||||
|
||||
运行代码需要 node 、 pnpm 和 go,node、go 需要在官网下载安装,pnpm 可以通过`npm i -g pnpm`安装。
|
||||
|
||||
## 运行代码
|
||||
|
||||
安装依赖 `pnpm i`
|
||||
```shell
|
||||
# 代码下载
|
||||
git clone --recursive https://github.com/caorushizi/m3u8-downloader.git
|
||||
|
||||
开发环境 `pnpm run dev`
|
||||
# 安装依赖
|
||||
pnpm i
|
||||
|
||||
打包运行 `pnpm run release`
|
||||
# 开发环境
|
||||
pnpm dev
|
||||
|
||||
## 下载链接
|
||||
# 打包运行
|
||||
pnpm release
|
||||
```
|
||||
|
||||
## Releases
|
||||
|
||||
**v2.0.1(2023.7.9 发布)**
|
||||
|
||||
- [windows media-downloader v2.0.2](https://github.com/caorushizi/m3u8-downloader/releases/download/v2.0.2/media-downloader-setup-2.0.2.exe)
|
||||
- [macos media-downloader v2.0.2](https://github.com/caorushizi/m3u8-downloader/releases/download/v2.0.2/media-downloader-setup-2.0.2.dmg)
|
||||
|
||||
**v2.0.1(2023.7.1 发布)**
|
||||
|
||||
@@ -45,11 +79,11 @@ m3u8 视频在线提取工具 流媒体下载 m3u8 下载 桌面客户端 window
|
||||
|
||||
## 软件截图
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
## 技术栈
|
||||
|
||||
|
||||
@@ -20,11 +20,13 @@
|
||||
--vp-button-brand-text: #f2f7fd;
|
||||
--vp-button-brand-bg: #2a82f6;
|
||||
|
||||
--vp-button-brand-active-border: #5e9ef3;
|
||||
--vp-button-brand-active-text: #fff;
|
||||
--vp-button-brand-active-bg: #409eff;
|
||||
|
||||
--vp-button-brand-hover-border: #5e9ef3;
|
||||
--vp-button-brand-hover-text: #fff;
|
||||
--vp-button-brand-hover-bg: #2a82f6;
|
||||
|
||||
--vp-button-brand-active-border: #f6ceec;
|
||||
|
||||
--vp-c-brand: #2a82f6;
|
||||
}
|
||||
|
||||
+1
-1
@@ -26,5 +26,5 @@ features:
|
||||
details: 可以轻松无缝的在PC和移动设备之前切换,下载完成后即可使用手机观看视频。
|
||||
- icon: ⚡️
|
||||
title: 支持批量下载
|
||||
details: 支持同时最多下载10个视频和直播资源,高速带宽不闲置。
|
||||
details: 支持同时下载多个视频和直播资源,高速带宽不闲置。
|
||||
---
|
||||
|
||||
Submodule
+1
Submodule internal added at ca43a37335
+12
-7
@@ -4,28 +4,33 @@
|
||||
"description": "electron-template",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "pnpm run build:mobile && pnpm --parallel -F \"./packages/*\" run dev",
|
||||
"build": "pnpm run build:mobile && pnpm run build:main && pnpm run build:renderer",
|
||||
"dev": "zx scripts/dev.mjs && pnpm --parallel -F \"./packages/*\" run dev",
|
||||
"build": "zx scripts/build.mjs",
|
||||
"build:mobile": "pnpm -F mobile run build",
|
||||
"build:plugin": "pnpm -F plugin run build",
|
||||
"build:main": "pnpm -F main run build",
|
||||
"build:renderer": "pnpm -F renderer run build",
|
||||
"build:server": "zx scripts/build-server.mjs",
|
||||
"pack": "pnpm -F media-downloader run pack",
|
||||
"release": "pnpm run build && pnpm -F main run release",
|
||||
"docs:dev": "vitepress dev docs",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs"
|
||||
"docs:preview": "vitepress preview docs",
|
||||
"types": "pnpm -F main run types"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@cliqz/adblocker@1.26.6": "patches/@cliqz__adblocker@1.26.6.patch"
|
||||
"@cliqz/adblocker@1.26.15": "patches/@cliqz__adblocker@1.26.15.patch"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@waline/client": "^2.15.5",
|
||||
"vitepress": "1.0.0-beta.3",
|
||||
"vue": "^3.3.4"
|
||||
"@waline/client": "3.0.0-alpha.10",
|
||||
"cross-env": "^7.0.3",
|
||||
"vitepress": "1.0.0-rc.39",
|
||||
"vue": "^3.4.15",
|
||||
"zx": "^7.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ module.exports = {
|
||||
"prettier",
|
||||
],
|
||||
overrides: [],
|
||||
ignorePatterns: ["app"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
@@ -17,5 +18,6 @@ module.exports = {
|
||||
plugins: ["@typescript-eslint", "prettier"],
|
||||
rules: {
|
||||
"prettier/prettier": "warn",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,3 +4,4 @@ build
|
||||
dist
|
||||
bin/Logs
|
||||
release
|
||||
types
|
||||
|
||||
@@ -1 +1 @@
|
||||
{}
|
||||
{}
|
||||
|
||||
@@ -2,3 +2,4 @@ node_modules
|
||||
output
|
||||
bin
|
||||
mobile
|
||||
plugin
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "media-downloader",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3-beta.2",
|
||||
"description": "在线视频下载器",
|
||||
"main": "main/index.js",
|
||||
"scripts": {},
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cliqz/adblocker-electron": "^1.26.6",
|
||||
"@cliqz/adblocker-electron-preload": "^1.26.6"
|
||||
"@cliqz/adblocker-electron": "^1.26.15",
|
||||
"@cliqz/adblocker-electron-preload": "^1.26.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^28.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
+44
-52
@@ -4,71 +4,63 @@
|
||||
"description": "在线视频下载器",
|
||||
"main": "main/index.js",
|
||||
"scripts": {
|
||||
"rebuild": "electron-rebuild -f -w better-sqlite3",
|
||||
"dev": "cross-env NODE_ENV=development node script/dev.mjs",
|
||||
"build": "cross-env NODE_ENV=production node script/build.mjs",
|
||||
"pack": "cross-env NODE_ENV=development node script/release.mjs",
|
||||
"release": "cross-env NODE_ENV=production node script/release.mjs"
|
||||
"postinstall": "electron-rebuild -f -w better-sqlite3",
|
||||
"dev": "cross-env NODE_ENV=development VITE_CJS_IGNORE_WARNING=true ts-node scripts/dev.ts",
|
||||
"build": "cross-env NODE_ENV=production ts-node scripts/build.ts",
|
||||
"pack": "cross-env NODE_ENV=development ts-node scripts/release.ts",
|
||||
"types": "tsc --emitDeclarationOnly",
|
||||
"release": "cross-env NODE_ENV=production ts-node scripts/release.ts"
|
||||
},
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^3.2.13",
|
||||
"@rushstack/eslint-patch": "^1.3.2",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/koa": "^2.13.6",
|
||||
"@types/koa-range": "^0.3.2",
|
||||
"@types/koa-send": "^4.1.3",
|
||||
"@types/koa__cors": "^4.0.0",
|
||||
"@types/koa__router": "^12.0.0",
|
||||
"@types/lodash": "^4.14.195",
|
||||
"@types/node": "^20.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "5.60.0",
|
||||
"@typescript-eslint/parser": "5.60.0",
|
||||
"@electron/rebuild": "^3.5.0",
|
||||
"@rushstack/eslint-patch": "^1.7.0",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/node": "^20.11.5",
|
||||
"@typescript-eslint/eslint-plugin": "6.19.0",
|
||||
"@typescript-eslint/parser": "6.19.0",
|
||||
"chokidar": "^3.5.3",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "^16.3.1",
|
||||
"electron": "^25.2.0",
|
||||
"electron-builder": "^24.4.0",
|
||||
"electron-updater": "^6.1.1",
|
||||
"esbuild": "^0.18.6",
|
||||
"eslint": "^8.43.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-config-standard-with-typescript": "^35.0.0",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-n": "15.0.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"dotenv": "^16.3.2",
|
||||
"electron": "^28.1.4",
|
||||
"electron-builder": "^24.9.1",
|
||||
"electron-updater": "^6.1.7",
|
||||
"esbuild": "^0.19.11",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-standard-with-typescript": "^43.0.1",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-n": "16.6.2",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-promise": "6.1.1",
|
||||
"prebuild-install": "^7.1.1",
|
||||
"prettier": "2.8.8",
|
||||
"semver": "^7.5.2",
|
||||
"typescript": "^5.1.3"
|
||||
"prettier": "3.2.4",
|
||||
"semver": "^7.5.4",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cliqz/adblocker-electron": "^1.26.6",
|
||||
"@cliqz/adblocker-electron-preload": "^1.26.6",
|
||||
"@koa/cors": "^4.0.0",
|
||||
"@koa/router": "^12.0.0",
|
||||
"@types/koa-static": "^4.0.2",
|
||||
"axios": "^1.4.0",
|
||||
"better-sqlite3": "^8.4.0",
|
||||
"cross-fetch": "^3.1.6",
|
||||
"dayjs": "^1.11.8",
|
||||
"@cliqz/adblocker-electron": "^1.26.15",
|
||||
"@cliqz/adblocker-electron-preload": "^1.26.15",
|
||||
"@types/semver": "^7.5.6",
|
||||
"axios": "^1.6.5",
|
||||
"better-sqlite3": "^9.3.0",
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"cross-fetch": "^4.0.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-is-dev": "^2.0.0",
|
||||
"electron-log": "^4.4.8",
|
||||
"electron-is-dev": "^3.0.1",
|
||||
"electron-log": "^5.0.3",
|
||||
"electron-store": "^8.1.0",
|
||||
"execa": "^7.1.1",
|
||||
"fs-extra": "^11.1.1",
|
||||
"glob": "^10.3.0",
|
||||
"execa": "^8.0.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"inversify": "^6.0.1",
|
||||
"koa": "^2.14.2",
|
||||
"koa-range": "^0.3.0",
|
||||
"koa-send": "^5.0.1",
|
||||
"koa-static": "^5.0.0",
|
||||
"inversify": "^6.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"typeorm": "^0.3.17"
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typeorm": "^0.3.19"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,30 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default {
|
||||
// other rules...
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "plugin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"antd": "^5.13.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.5",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"electron": "^28.1.4",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.12"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#MediagoRoot {
|
||||
position: absolute;
|
||||
z-index: 99999;
|
||||
.app {
|
||||
z-index: 99999;
|
||||
.app-float-button {
|
||||
left: 30px;
|
||||
bottom: 30px;
|
||||
}
|
||||
.app-logo {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 3px;
|
||||
transform: scale(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { FloatButton, Button, Dropdown, Space } from "antd";
|
||||
import "./App.scss";
|
||||
import logo from "./assets/logo.png";
|
||||
import { WebSource } from "./types";
|
||||
import { ipcRenderer } from "electron/renderer";
|
||||
|
||||
function App() {
|
||||
const [items, setItems] = useState<WebSource[]>([]);
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
const receiveMessage = (_: unknown, data: WebSource) => {
|
||||
setItems((item) => [...item, data]);
|
||||
setCount((c) => c + 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
ipcRenderer.on("webview-link-message", receiveMessage);
|
||||
|
||||
return () => {
|
||||
ipcRenderer.removeListener("webview-link-message", receiveMessage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Dropdown
|
||||
placement="topLeft"
|
||||
menu={{
|
||||
items: items.map((item) => ({
|
||||
key: item.name,
|
||||
label: (
|
||||
<Space
|
||||
onClick={() => {
|
||||
ipcRenderer.invoke("add-download-item", {
|
||||
name: item.name,
|
||||
url: item.url,
|
||||
type: item.type,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div>{item.name}</div>
|
||||
<Button type="link" style={{ padding: 0 }}>
|
||||
添加
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
})),
|
||||
}}
|
||||
trigger={["click"]}
|
||||
>
|
||||
<FloatButton
|
||||
className="app-float-button"
|
||||
icon={<img className="app-logo" src={logo} alt="" />}
|
||||
shape="square"
|
||||
badge={{
|
||||
count,
|
||||
offset: [10, 0],
|
||||
}}
|
||||
onClick={() => {
|
||||
setCount(0);
|
||||
}}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./index.scss";
|
||||
|
||||
export function mount() {
|
||||
const root = document.createElement("div");
|
||||
root.id = "MediagoRoot";
|
||||
document.body.appendChild(root);
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
mount();
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export enum DownloadType {
|
||||
m3u8 = "m3u8",
|
||||
bilibili = "bilibili",
|
||||
}
|
||||
|
||||
export interface WebSource {
|
||||
url: string;
|
||||
type: DownloadType;
|
||||
name: string;
|
||||
headers?: string;
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["electron"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, "./src/main.tsx"),
|
||||
name: "plugin",
|
||||
fileName: () => "index.js",
|
||||
formats: ["cjs"],
|
||||
},
|
||||
outDir: path.resolve(__dirname, "../../main/app/plugin"),
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
external: ["electron/renderer"],
|
||||
},
|
||||
},
|
||||
server: {
|
||||
origin: "http://localhost:5173",
|
||||
},
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
import { spawn } from "child_process";
|
||||
import electron from "electron";
|
||||
import * as esbuild from "esbuild";
|
||||
import chokidar from "chokidar";
|
||||
import { loadDotEnvRuntime, mainResolve, log, copyResource } from "./utils.mjs";
|
||||
|
||||
let electronProcess = null;
|
||||
|
||||
process.env.NODE_ENV = "development";
|
||||
loadDotEnvRuntime();
|
||||
|
||||
async function copySource() {
|
||||
const path = "build/Release/better_sqlite3.node";
|
||||
|
||||
copyResource([
|
||||
{
|
||||
from: mainResolve("node_modules/better-sqlite3", path),
|
||||
to: mainResolve("app", path),
|
||||
},
|
||||
{
|
||||
from: mainResolve("bin"),
|
||||
to: mainResolve("app/bin"),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const ctx = await esbuild.context({
|
||||
entryPoints: [
|
||||
mainResolve("src/index.ts"),
|
||||
mainResolve("src/preload.ts"),
|
||||
mainResolve("src/webview.ts"),
|
||||
],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
sourcemap: true,
|
||||
target: ["node16.13"],
|
||||
external: [
|
||||
"electron",
|
||||
"mock-aws-s3",
|
||||
"aws-sdk",
|
||||
"nock",
|
||||
"@cliqz/adblocker-electron-preload",
|
||||
],
|
||||
define: {
|
||||
// 开发环境中二进制可执行文件的路径
|
||||
__bin__: `"${mainResolve("bin", process.platform).replace(/\\/g, "\\\\")}"`,
|
||||
},
|
||||
plugins: [],
|
||||
outdir: mainResolve("app/build/main"),
|
||||
loader: { ".png": "file" },
|
||||
});
|
||||
|
||||
const watcher = chokidar.watch("./src");
|
||||
|
||||
watcher.on("change", async () => {
|
||||
await ctx.rebuild();
|
||||
log("watch build succeed.");
|
||||
if (electronProcess && electronProcess.kill) {
|
||||
if (process.platform === "darwin") {
|
||||
spawn("kill", ["-9", electronProcess.pid]);
|
||||
} else {
|
||||
process.kill(electronProcess.pid);
|
||||
}
|
||||
electronProcess = null;
|
||||
startElectron();
|
||||
}
|
||||
});
|
||||
|
||||
function startElectron() {
|
||||
const args = ["--inspect=5858", mainResolve("app/build/main/index.js")];
|
||||
|
||||
electronProcess = spawn(String(electron), args);
|
||||
|
||||
electronProcess.stdout.on("data", (data) => {
|
||||
log(String(data));
|
||||
});
|
||||
|
||||
electronProcess.stderr.on("data", (data) => {
|
||||
log(String(data));
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.rebuild();
|
||||
await copySource();
|
||||
await startElectron();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
process.exit();
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
loadDotEnvDefined,
|
||||
copyResource,
|
||||
removeResource,
|
||||
} from "./utils.mjs";
|
||||
} from "./utils";
|
||||
import { external } from "./config";
|
||||
|
||||
const mainDefined = loadDotEnvDefined();
|
||||
|
||||
removeResource([mainResolve("app/build")]);
|
||||
removeResource([mainResolve("app/bin")]);
|
||||
|
||||
const path = "build/Release/better_sqlite3.node";
|
||||
|
||||
@@ -17,25 +19,35 @@ copyResource([
|
||||
from: mainResolve("node_modules/better-sqlite3", path),
|
||||
to: mainResolve("app", path),
|
||||
},
|
||||
{
|
||||
from: mainResolve("bin"),
|
||||
to: mainResolve("app/bin"),
|
||||
},
|
||||
]);
|
||||
|
||||
esbuild.build({
|
||||
entryPoints: [
|
||||
mainResolve("src/index.ts"),
|
||||
mainResolve("src/preload.ts"),
|
||||
mainResolve("src/webview.ts"),
|
||||
],
|
||||
entryPoints: [mainResolve("src/index.ts")],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
sourcemap: false,
|
||||
target: ["node16.13"],
|
||||
external: [
|
||||
"electron",
|
||||
"nock",
|
||||
"aws-sdk",
|
||||
"mock-aws-s3",
|
||||
"@cliqz/adblocker-electron-preload",
|
||||
],
|
||||
external,
|
||||
define: {
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
...mainDefined,
|
||||
},
|
||||
outdir: mainResolve("app/build/main"),
|
||||
loader: { ".png": "file" },
|
||||
minify: true,
|
||||
});
|
||||
|
||||
esbuild.build({
|
||||
entryPoints: [mainResolve("src/preload.ts")],
|
||||
bundle: true,
|
||||
platform: "browser",
|
||||
target: ["chrome89"],
|
||||
sourcemap: false,
|
||||
external,
|
||||
define: {
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
...mainDefined,
|
||||
@@ -0,0 +1,7 @@
|
||||
export const external = [
|
||||
"electron",
|
||||
"nock",
|
||||
"aws-sdk",
|
||||
"mock-aws-s3",
|
||||
"@cliqz/adblocker-electron-preload",
|
||||
];
|
||||
@@ -0,0 +1,110 @@
|
||||
import { ChildProcessWithoutNullStreams, spawn } from "child_process";
|
||||
import electron from "electron";
|
||||
import * as esbuild from "esbuild";
|
||||
import chokidar from "chokidar";
|
||||
import { loadDotEnvRuntime, mainResolve, log, copyResource } from "./utils";
|
||||
import { external } from "./config";
|
||||
import { build } from "vite";
|
||||
import path from "path";
|
||||
|
||||
let electronProcess: ChildProcessWithoutNullStreams | null = null;
|
||||
|
||||
process.env.NODE_ENV = "development";
|
||||
loadDotEnvRuntime();
|
||||
|
||||
async function copySource() {
|
||||
const path = "build/Release/better_sqlite3.node";
|
||||
|
||||
copyResource([
|
||||
{
|
||||
from: mainResolve("node_modules/better-sqlite3", path),
|
||||
to: mainResolve("app", path),
|
||||
},
|
||||
{
|
||||
from: mainResolve("bin"),
|
||||
to: mainResolve("app/bin"),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const buildConfig: esbuild.BuildOptions = {
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
sourcemap: true,
|
||||
target: ["node20.9"],
|
||||
external,
|
||||
define: {
|
||||
// 开发环境中二进制可执行文件的路径
|
||||
__bin__: `"${mainResolve("bin", process.platform).replace(/\\/g, "\\\\")}"`,
|
||||
},
|
||||
plugins: [],
|
||||
outdir: mainResolve("app/build/main"),
|
||||
loader: { ".png": "file" },
|
||||
};
|
||||
|
||||
function startElectron() {
|
||||
const args = ["--inspect=5858", mainResolve("app/build/main/index.js")];
|
||||
|
||||
electronProcess = spawn(String(electron), args);
|
||||
|
||||
electronProcess.stdout.on("data", (data) => {
|
||||
log(String(data));
|
||||
});
|
||||
|
||||
electronProcess.stderr.on("data", (data) => {
|
||||
log(String(data));
|
||||
});
|
||||
}
|
||||
|
||||
function restartElectron() {
|
||||
if (electronProcess && electronProcess.pid) {
|
||||
if (process.platform === "darwin") {
|
||||
spawn("kill", ["-9", String(electronProcess.pid)]);
|
||||
} else {
|
||||
process.kill(electronProcess.pid);
|
||||
}
|
||||
electronProcess = null;
|
||||
startElectron();
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const mainContext = await esbuild.context({
|
||||
...buildConfig,
|
||||
entryPoints: [mainResolve("src/index.ts")],
|
||||
});
|
||||
|
||||
const preloadContext = await esbuild.context({
|
||||
...buildConfig,
|
||||
entryPoints: [mainResolve("src/preload.ts")],
|
||||
platform: "browser",
|
||||
});
|
||||
|
||||
const watcher = chokidar.watch("./src");
|
||||
watcher.on("change", async () => {
|
||||
await mainContext.rebuild();
|
||||
await preloadContext.rebuild();
|
||||
log("watch build succeed.");
|
||||
restartElectron();
|
||||
});
|
||||
|
||||
const pluginRoot = path.resolve(__dirname, "../plugin");
|
||||
const watcher2 = chokidar.watch(pluginRoot);
|
||||
watcher2.on("change", async () => {
|
||||
console.log("plugin changed");
|
||||
await build({ root: pluginRoot });
|
||||
restartElectron();
|
||||
});
|
||||
|
||||
try {
|
||||
await mainContext.rebuild();
|
||||
await preloadContext.rebuild();
|
||||
await copySource();
|
||||
await startElectron();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
process.exit();
|
||||
}
|
||||
}
|
||||
|
||||
start();
|
||||
@@ -1,29 +1,18 @@
|
||||
import builder from "electron-builder";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { loadDotEnvRuntime, log, mainResolve, removeResource } from "./utils";
|
||||
import * as builder from "electron-builder";
|
||||
import semver from "semver";
|
||||
import {
|
||||
mainResolve,
|
||||
loadDotEnvRuntime,
|
||||
log,
|
||||
removeResource,
|
||||
} from "./utils.mjs";
|
||||
import pkg from "../app/package.json";
|
||||
|
||||
removeResource([mainResolve("release")]);
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(mainResolve("./app/package.json")));
|
||||
loadDotEnvRuntime();
|
||||
|
||||
if (semver.neq(process.env.APP_VERSION, packageJson.version)) {
|
||||
if (semver.neq(process.env.APP_VERSION || "", pkg.version)) {
|
||||
log("请先同步构建版本和发布版本");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Let's get that intellisense working
|
||||
/**
|
||||
* @type {import('electron-builder').Configuration}
|
||||
* @see https://www.electron.build/configuration/configuration
|
||||
*/
|
||||
const options = {
|
||||
const options: builder.Configuration = {
|
||||
productName: process.env.APP_NAME,
|
||||
buildVersion: process.env.APP_VERSION,
|
||||
appId: process.env.APP_ID,
|
||||
@@ -44,6 +33,10 @@ const options = {
|
||||
from: "./app/mobile",
|
||||
to: "mobile",
|
||||
},
|
||||
{
|
||||
from: "./app/plugin",
|
||||
to: "plugin",
|
||||
},
|
||||
{
|
||||
from: "./app/bin/${platform}/",
|
||||
to: "bin",
|
||||
@@ -101,19 +94,23 @@ const options = {
|
||||
},
|
||||
};
|
||||
|
||||
const target =
|
||||
process.env.NODE_ENV === "development"
|
||||
? builder.DIR_TARGET
|
||||
: builder.DEFAULT_TARGET;
|
||||
try {
|
||||
const targets =
|
||||
process.platform === "win32"
|
||||
? builder.Platform.WINDOWS.createTarget(target)
|
||||
: builder.Platform.MAC.createTarget(target);
|
||||
await builder.build({
|
||||
targets,
|
||||
config: options,
|
||||
});
|
||||
} catch (e) {
|
||||
log(e);
|
||||
async function start() {
|
||||
const target =
|
||||
process.env.NODE_ENV === "development"
|
||||
? builder.DIR_TARGET
|
||||
: builder.DEFAULT_TARGET;
|
||||
try {
|
||||
const targets =
|
||||
process.platform === "win32"
|
||||
? builder.Platform.WINDOWS.createTarget(target)
|
||||
: builder.Platform.MAC.createTarget(target);
|
||||
await builder.build({
|
||||
targets,
|
||||
config: options,
|
||||
});
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
}
|
||||
|
||||
start();
|
||||
@@ -1,28 +1,27 @@
|
||||
import { existsSync, cpSync, rmSync } from "node:fs";
|
||||
import { existsSync, cpSync, rmSync } from "fs";
|
||||
import dotenv from "dotenv";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolve } from "path";
|
||||
|
||||
// FIXME: 有没有什么办法可以不用这么写?
|
||||
const con = console;
|
||||
export const log = con.log;
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
export const mainResolve = (...r) => resolve(__dirname, "..", ...r);
|
||||
export const rootResolve = (...r) => resolve(__dirname, "../../..", ...r);
|
||||
export const mainResolve = (...r: any[]) => resolve(__dirname, "..", ...r);
|
||||
export const rootResolve = (...r: any[]) =>
|
||||
resolve(__dirname, "../../..", ...r);
|
||||
const nodeEnv = process.env.NODE_ENV;
|
||||
log("当前的环境是: ", nodeEnv);
|
||||
|
||||
function loadEnv(path) {
|
||||
const result = {};
|
||||
function loadEnv(path: string) {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
const _loadEnv = (path) => {
|
||||
const _loadEnv = (path: string) => {
|
||||
if (!existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { error, parsed } = dotenv.config({ path });
|
||||
if (error != null) {
|
||||
if (error != null || !parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -57,14 +56,14 @@ export function loadDotEnvRuntime() {
|
||||
export function loadDotEnvDefined() {
|
||||
const env = loadDotEnv();
|
||||
|
||||
return Object.keys(env).reduce((prev, cur) => {
|
||||
return Object.keys(env).reduce<Record<string, string>>((prev, cur) => {
|
||||
if (!cur.startsWith("APP_")) return prev;
|
||||
prev[`process.env.${[cur]}`] = JSON.stringify(env[cur]);
|
||||
return prev;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function copyResource(resource) {
|
||||
export function copyResource(resource: { from: string; to: string }[]) {
|
||||
resource.forEach((r) => {
|
||||
const { from, to } = r;
|
||||
cpSync(from, to, {
|
||||
@@ -73,7 +72,7 @@ export function copyResource(resource) {
|
||||
});
|
||||
}
|
||||
|
||||
export function removeResource(resource) {
|
||||
export function removeResource(resource: string[]) {
|
||||
resource.forEach((r) => {
|
||||
rmSync(r, { recursive: true, force: true });
|
||||
});
|
||||
+19
-22
@@ -1,28 +1,25 @@
|
||||
import { inject, injectable } from "inversify";
|
||||
import {
|
||||
DatabaseService,
|
||||
DownloadStatus,
|
||||
IpcHandlerService,
|
||||
MainWindowService,
|
||||
ProtocolService,
|
||||
UpdateService,
|
||||
VideoRepository,
|
||||
WebviewService,
|
||||
type App,
|
||||
WebService,
|
||||
DevToolsService,
|
||||
StoreService,
|
||||
} from "./interfaces";
|
||||
import { DownloadStatus } from "./interfaces";
|
||||
import { TYPES } from "./types";
|
||||
import { Menu, Tray, app, nativeImage, nativeTheme } from "electron";
|
||||
import TrayIcon from "./tray-icon.png";
|
||||
import path from "path";
|
||||
import MainWindow from "./windows/MainWindow";
|
||||
import ProtocolService from "./services/ProtocolService";
|
||||
import UpdateService from "./services/UpdateService";
|
||||
import IpcHandlerService from "./services/IpcHandlerService";
|
||||
import DatabaseService from "./services/DatabaseService";
|
||||
import WebviewService from "./services/WebviewService";
|
||||
import DevToolsService from "./services/DevToolsService";
|
||||
import StoreService from "./services/StoreService";
|
||||
import VideoRepository from "./repository/VideoRepository";
|
||||
import VideoService from "./services/VideoService";
|
||||
|
||||
@injectable()
|
||||
export default class ElectronApp implements App {
|
||||
export default class ElectronApp {
|
||||
constructor(
|
||||
@inject(TYPES.MainWindowService)
|
||||
private readonly mainWindow: MainWindowService,
|
||||
@inject(TYPES.MainWindow)
|
||||
private readonly mainWindow: MainWindow,
|
||||
@inject(TYPES.ProtocolService)
|
||||
private readonly protocolService: ProtocolService,
|
||||
@inject(TYPES.UpdateService)
|
||||
@@ -37,10 +34,10 @@ export default class ElectronApp implements App {
|
||||
private readonly videoRepository: VideoRepository,
|
||||
@inject(TYPES.DevToolsService)
|
||||
private readonly devTools: DevToolsService,
|
||||
@inject(TYPES.WebService)
|
||||
private readonly webService: WebService,
|
||||
@inject(TYPES.StoreService)
|
||||
private readonly storeService: StoreService
|
||||
private readonly storeService: StoreService,
|
||||
@inject(TYPES.VideoService)
|
||||
private readonly videoService: VideoService,
|
||||
) {}
|
||||
|
||||
private async seriveInit(): Promise<void> {
|
||||
@@ -50,7 +47,7 @@ export default class ElectronApp implements App {
|
||||
this.ipcHandler.init();
|
||||
this.updateService.init();
|
||||
this.webview.init(), this.devTools.init();
|
||||
this.webService.init();
|
||||
this.videoService.init();
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
@@ -93,7 +90,7 @@ export default class ElectronApp implements App {
|
||||
const videoIds = videos.map((video) => video.id);
|
||||
await this.videoRepository.changeVideoStatus(
|
||||
videoIds,
|
||||
DownloadStatus.Failed
|
||||
DownloadStatus.Failed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { IpcMainEvent } from "electron/main";
|
||||
import { inject, injectable } from "inversify";
|
||||
import { handle } from "../helper/decorator";
|
||||
import { handle } from "../helper";
|
||||
import {
|
||||
StoreService,
|
||||
type Controller,
|
||||
VideoRepository,
|
||||
DownloadItem,
|
||||
DownloadItemPagination,
|
||||
DownloadService,
|
||||
Task,
|
||||
DownloadStatus,
|
||||
} from "../interfaces";
|
||||
import { TYPES } from "../types";
|
||||
import { spawnDownload } from "helper";
|
||||
import MainWindowServiceImpl from "windows/Main";
|
||||
import { downloader } from "../helper";
|
||||
import MainWindow from "../windows/MainWindow";
|
||||
import StoreService from "../services/StoreService";
|
||||
import DownloadService from "../services/DownloadService";
|
||||
import VideoRepository from "../repository/VideoRepository";
|
||||
import { existsSync } from "fs-extra";
|
||||
|
||||
@injectable()
|
||||
export default class DownloadController implements Controller {
|
||||
@@ -24,8 +25,8 @@ export default class DownloadController implements Controller {
|
||||
private readonly videoRepository: VideoRepository,
|
||||
@inject(TYPES.DownloadService)
|
||||
private readonly downloadService: DownloadService,
|
||||
@inject(TYPES.MainWindowService)
|
||||
private readonly mainWindow: MainWindowServiceImpl
|
||||
@inject(TYPES.MainWindow)
|
||||
private readonly mainWindow: MainWindow,
|
||||
) {}
|
||||
|
||||
@handle("add-download-item")
|
||||
@@ -36,6 +37,14 @@ export default class DownloadController implements Controller {
|
||||
return item;
|
||||
}
|
||||
|
||||
@handle("add-download-items")
|
||||
async addDownloadItems(e: IpcMainEvent, videos: DownloadItem[]) {
|
||||
const items = await this.videoRepository.addVideos(videos);
|
||||
// 这里向页面发送消息,通知页面更新
|
||||
this.mainWindow.window?.webContents.send("download-item-notifier", items);
|
||||
return items;
|
||||
}
|
||||
|
||||
@handle("edit-download-item")
|
||||
async editDownloadItem(e: IpcMainEvent, video: DownloadItem) {
|
||||
const item = await this.videoRepository.editVideo(video);
|
||||
@@ -53,7 +62,22 @@ export default class DownloadController implements Controller {
|
||||
|
||||
@handle("get-download-items")
|
||||
async getDownloadItems(e: IpcMainEvent, pagination: DownloadItemPagination) {
|
||||
return await this.videoRepository.findVideos(pagination);
|
||||
const localDir = this.storeService.get("local");
|
||||
const videos = await this.videoRepository.findVideos(pagination);
|
||||
const newVideos = videos.list.map((video) => {
|
||||
if (video.status === DownloadStatus.Success) {
|
||||
return {
|
||||
...video,
|
||||
exist: existsSync(`${localDir}/${video.name}.mp4`),
|
||||
};
|
||||
}
|
||||
return video;
|
||||
});
|
||||
|
||||
return {
|
||||
total: videos.total,
|
||||
list: newVideos,
|
||||
};
|
||||
}
|
||||
|
||||
@handle("start-download")
|
||||
@@ -63,7 +87,7 @@ export default class DownloadController implements Controller {
|
||||
if (!video) {
|
||||
return Promise.reject("没有找到该视频");
|
||||
}
|
||||
const { name, url, headers } = video;
|
||||
const { name, url, headers, type } = video;
|
||||
const local = this.storeService.get("local");
|
||||
|
||||
// 从配置中添加参数
|
||||
@@ -73,12 +97,13 @@ export default class DownloadController implements Controller {
|
||||
id: vid,
|
||||
params: {
|
||||
url,
|
||||
type,
|
||||
local,
|
||||
name,
|
||||
headers,
|
||||
deleteSegments,
|
||||
},
|
||||
process: spawnDownload,
|
||||
process: downloader,
|
||||
};
|
||||
await this.videoRepository.changeVideoStatus(vid, DownloadStatus.Watting);
|
||||
this.downloadService.addTask(task);
|
||||
|
||||
@@ -8,25 +8,22 @@ import {
|
||||
shell,
|
||||
clipboard,
|
||||
} from "electron";
|
||||
import { Favorite } from "entity/Favorite";
|
||||
import { convertToAudio } from "helper";
|
||||
import { db, workspace } from "helper/variables";
|
||||
import { Favorite } from "../entity/Favorite";
|
||||
import { convertToAudio, db, workspace } from "../helper";
|
||||
import { inject, injectable } from "inversify";
|
||||
import { AppStore, EnvPath } from "main";
|
||||
import { AppStore, EnvPath } from "../main";
|
||||
import path from "path";
|
||||
import { handle, getLocalIP } from "../helper";
|
||||
import {
|
||||
StoreService,
|
||||
type Controller,
|
||||
FavoriteRepository,
|
||||
MainWindowService,
|
||||
VideoRepository,
|
||||
BrowserWindowService,
|
||||
PlayerWindowService,
|
||||
WebviewService,
|
||||
} from "../interfaces";
|
||||
import { type Controller } from "../interfaces";
|
||||
import { TYPES } from "../types";
|
||||
import fs from "fs-extra";
|
||||
import MainWindow from "../windows/MainWindow";
|
||||
import BrowserWindow from "../windows/BrowserWindow";
|
||||
import PlayerWindow from "../windows/PlayerWindow";
|
||||
import StoreService from "../services/StoreService";
|
||||
import WebviewService from "../services/WebviewService";
|
||||
import FavoriteRepository from "../repository/FavoriteRepository";
|
||||
import VideoRepository from "../repository/VideoRepository";
|
||||
|
||||
@injectable()
|
||||
export default class HomeController implements Controller {
|
||||
@@ -37,16 +34,16 @@ export default class HomeController implements Controller {
|
||||
private readonly storeService: StoreService,
|
||||
@inject(TYPES.FavoriteRepository)
|
||||
private readonly favoriteRepository: FavoriteRepository,
|
||||
@inject(TYPES.MainWindowService)
|
||||
private readonly mainWindow: MainWindowService,
|
||||
@inject(TYPES.MainWindow)
|
||||
private readonly mainWindow: MainWindow,
|
||||
@inject(TYPES.VideoRepository)
|
||||
private readonly videoRepository: VideoRepository,
|
||||
@inject(TYPES.BrowserWindowService)
|
||||
private readonly browserWindow: BrowserWindowService,
|
||||
@inject(TYPES.PlayerWindowService)
|
||||
private readonly playerWindow: PlayerWindowService,
|
||||
@inject(TYPES.BrowserWindow)
|
||||
private readonly browserWindow: BrowserWindow,
|
||||
@inject(TYPES.PlayerWindow)
|
||||
private readonly playerWindow: PlayerWindow,
|
||||
@inject(TYPES.WebviewService)
|
||||
private readonly webviewService: WebviewService
|
||||
private readonly webviewService: WebviewService,
|
||||
) {}
|
||||
|
||||
@handle("get-env-path")
|
||||
@@ -244,9 +241,9 @@ export default class HomeController implements Controller {
|
||||
}
|
||||
|
||||
@handle("open-player-window")
|
||||
async openPlayerWindow(event: IpcMainEvent, id: number) {
|
||||
async openPlayerWindow(event: IpcMainEvent, name: string) {
|
||||
// 打开播放器窗口
|
||||
this.playerWindow.openWindow(id);
|
||||
this.playerWindow.openWindow(name);
|
||||
}
|
||||
|
||||
@handle("get-local-ip")
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { IpcMainEvent } from "electron";
|
||||
import { inject, injectable } from "inversify";
|
||||
import { handle } from "../helper/decorator";
|
||||
import { type Controller, WebviewService, StoreService } from "../interfaces";
|
||||
import { handle } from "../helper";
|
||||
import { type Controller } from "../interfaces";
|
||||
import { TYPES } from "../types";
|
||||
import WebviewService from "../services/WebviewService";
|
||||
import StoreService from "../services/StoreService";
|
||||
|
||||
@injectable()
|
||||
export default class WebviewController implements Controller {
|
||||
@@ -10,7 +12,7 @@ export default class WebviewController implements Controller {
|
||||
@inject(TYPES.WebviewService)
|
||||
private readonly webview: WebviewService,
|
||||
@inject(TYPES.StoreService)
|
||||
private readonly storeService: StoreService
|
||||
private readonly storeService: StoreService,
|
||||
) {}
|
||||
|
||||
@handle("set-webview-bounds")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DownloadStatus } from "interfaces";
|
||||
import { DownloadStatus, DownloadType } from "../interfaces";
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -20,6 +20,13 @@ export class Video {
|
||||
})
|
||||
name: string;
|
||||
|
||||
@Column({
|
||||
type: "text",
|
||||
nullable: false,
|
||||
default: DownloadType.m3u8,
|
||||
})
|
||||
type: DownloadType;
|
||||
|
||||
@Column({
|
||||
type: "text",
|
||||
nullable: false,
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
import { execa } from "execa";
|
||||
import { macDownloaderPath, winDownloaderPath } from "./variables";
|
||||
import iconv from "iconv-lite";
|
||||
import { formatHeaders, stripColors } from "./utils";
|
||||
import { DownloadParams, DownloadProgress } from "interfaces";
|
||||
|
||||
export const spawnDownload = (params: DownloadParams): Promise<void> => {
|
||||
if (process.platform === "win32") {
|
||||
return winSpawnDownload(params);
|
||||
} else {
|
||||
return macSpawnDownload(params);
|
||||
}
|
||||
};
|
||||
|
||||
const winSpawnDownload = async (params: DownloadParams): Promise<void> => {
|
||||
const {
|
||||
id,
|
||||
abortSignal,
|
||||
url,
|
||||
local,
|
||||
name,
|
||||
deleteSegments,
|
||||
headers,
|
||||
callback,
|
||||
} = params;
|
||||
const progressReg = /Progress:\s(\d+)\/(\d+)\s\(.+?\).+?\((.+?\/s).*?\)/g;
|
||||
const isLiveReg = /识别为直播流, 开始录制/g;
|
||||
const startDownloadReg = /开始下载文件/g;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const spawnParams = [url, "--workDir", local, "--saveName", name];
|
||||
|
||||
if (headers) {
|
||||
spawnParams.push("--headers", formatHeaders(headers));
|
||||
}
|
||||
|
||||
if (deleteSegments) {
|
||||
spawnParams.push("--enableDelAfterDone");
|
||||
}
|
||||
|
||||
const downloader = execa(winDownloaderPath, spawnParams, {
|
||||
signal: abortSignal.signal,
|
||||
});
|
||||
|
||||
let isLive = false;
|
||||
downloader.stdout?.on("data", (data) => {
|
||||
const str = iconv.decode(Buffer.from(data), "gbk");
|
||||
str.split("\n").forEach((item) => {
|
||||
if (item.trim() == "") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLiveReg.test(item) || startDownloadReg.test(item)) {
|
||||
callback({
|
||||
id,
|
||||
type: "ready",
|
||||
isLive,
|
||||
cur: "",
|
||||
total: "",
|
||||
speed: "",
|
||||
});
|
||||
isLive = true;
|
||||
}
|
||||
|
||||
const result = progressReg.exec(item);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [, cur, total, speed] = result;
|
||||
const progress: DownloadProgress = {
|
||||
id,
|
||||
type: "progress",
|
||||
cur,
|
||||
total,
|
||||
speed,
|
||||
isLive,
|
||||
};
|
||||
callback(progress);
|
||||
});
|
||||
});
|
||||
|
||||
downloader.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
downloader.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error("未知错误"));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const macSpawnDownload = (params: DownloadParams): Promise<void> => {
|
||||
const {
|
||||
id,
|
||||
abortSignal,
|
||||
url,
|
||||
local,
|
||||
name,
|
||||
deleteSegments,
|
||||
headers,
|
||||
callback,
|
||||
} = params;
|
||||
// const progressReg = /([\d.]+)% .*? ([\d.\w]+?) /g;
|
||||
const progressReg = /([\d.]+)%/g;
|
||||
const errorReg = /ERROR/g;
|
||||
const startDownloadReg = /保存文件名:/g;
|
||||
const isLiveReg = /检测到直播流/g;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const spawnParams = [
|
||||
url,
|
||||
"--tmp-dir",
|
||||
local,
|
||||
"--save-dir",
|
||||
local,
|
||||
"--save-name",
|
||||
name,
|
||||
"--auto-select",
|
||||
];
|
||||
|
||||
if (headers) {
|
||||
const h: Record<string, unknown> = JSON.parse(headers);
|
||||
Object.entries(h).forEach(([k, v]) => {
|
||||
spawnParams.push("-H", `${k}: ${v}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (deleteSegments) {
|
||||
spawnParams.push("--del-after-done");
|
||||
}
|
||||
|
||||
const downloader = execa(macDownloaderPath, spawnParams, {
|
||||
signal: abortSignal.signal,
|
||||
});
|
||||
|
||||
let isLive = false;
|
||||
downloader.stdout?.on("data", (data) => {
|
||||
const str = String(Buffer.from(data));
|
||||
str.split("\n").forEach((item) => {
|
||||
if (item.trim() == "") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLiveReg.test(item) || startDownloadReg.test(item)) {
|
||||
callback({
|
||||
id,
|
||||
type: "ready",
|
||||
isLive,
|
||||
cur: "",
|
||||
total: "",
|
||||
speed: "",
|
||||
});
|
||||
isLive = true;
|
||||
}
|
||||
|
||||
const log = stripColors(item);
|
||||
|
||||
if (errorReg.test(log)) {
|
||||
reject(new Error(log));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = progressReg.exec(log);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [, precentage, speed] = result;
|
||||
const cur = String(Number(precentage) * 10);
|
||||
if (cur === "0") {
|
||||
return;
|
||||
}
|
||||
|
||||
const total = "1000";
|
||||
// FIXME: 无法获取是否为直播流
|
||||
const progress: DownloadProgress = {
|
||||
id,
|
||||
type: "progress",
|
||||
cur,
|
||||
total,
|
||||
speed,
|
||||
isLive,
|
||||
};
|
||||
callback(progress);
|
||||
});
|
||||
});
|
||||
|
||||
downloader.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
downloader.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error("未知错误"));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { stripColors, biliDownloaderBin } from "../../helper";
|
||||
import { DownloadParams, DownloadProgress } from "../../interfaces";
|
||||
import { cmdr } from "./cmdr";
|
||||
|
||||
export const biliDownloader = async (params: DownloadParams): Promise<void> => {
|
||||
const { id, abortSignal, url, local, callback } = params;
|
||||
// const progressReg = /([\d.]+)% .*? ([\d.\w]+?) /g;
|
||||
const progressReg = /([\d.]+)%/g;
|
||||
const errorReg = /ERROR/g;
|
||||
const startDownloadReg = /保存文件名:/g;
|
||||
const isLiveReg = /检测到直播流/g;
|
||||
|
||||
const spawnParams = [url, "--work-dir", local];
|
||||
|
||||
await cmdr(biliDownloaderBin, spawnParams, {
|
||||
detached: true,
|
||||
shell: true,
|
||||
abortSignal,
|
||||
onMessage: (message) => {
|
||||
if (isLiveReg.test(message) || startDownloadReg.test(message)) {
|
||||
callback({
|
||||
id,
|
||||
type: "ready",
|
||||
isLive: false,
|
||||
cur: "",
|
||||
total: "",
|
||||
speed: "",
|
||||
});
|
||||
}
|
||||
|
||||
const log = stripColors(message);
|
||||
if (errorReg.test(log)) {
|
||||
throw new Error(log);
|
||||
}
|
||||
|
||||
const result = progressReg.exec(log);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [, precentage, speed] = result;
|
||||
const cur = String(Number(precentage) * 10);
|
||||
if (cur === "0") {
|
||||
return;
|
||||
}
|
||||
|
||||
const total = "1000";
|
||||
// FIXME: 无法获取是否为直播流
|
||||
const progress: DownloadProgress = {
|
||||
id,
|
||||
type: "progress",
|
||||
cur,
|
||||
total,
|
||||
speed,
|
||||
isLive: false,
|
||||
};
|
||||
callback(progress);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { execa, Options } from "execa";
|
||||
import iconv from "iconv-lite";
|
||||
|
||||
export interface CmdrParams {
|
||||
abortSignal: AbortController;
|
||||
encoding?: string;
|
||||
onMessage?: (message: string) => void;
|
||||
onErrMessage?: (message: string) => void;
|
||||
}
|
||||
|
||||
export const cmdr = (
|
||||
binPath: string,
|
||||
args: string[],
|
||||
params: CmdrParams & Options,
|
||||
): Promise<void> => {
|
||||
const {
|
||||
abortSignal,
|
||||
encoding = "utf-8",
|
||||
onMessage,
|
||||
onErrMessage,
|
||||
...execOptions
|
||||
} = params;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const process = (data: Buffer, callback: (message: string) => void) => {
|
||||
const items = iconv.decode(data, encoding);
|
||||
items.split("\n").forEach((item) => {
|
||||
const message = item.trim();
|
||||
if (!message) return;
|
||||
try {
|
||||
callback(message);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const downloader = execa(binPath, args, {
|
||||
...execOptions,
|
||||
signal: abortSignal.signal,
|
||||
});
|
||||
|
||||
if (downloader.stdout && onMessage) {
|
||||
downloader.stdout.on("data", (data) => process(data, onMessage));
|
||||
}
|
||||
|
||||
if (downloader.stderr && onErrMessage) {
|
||||
downloader.stderr.on("data", (data) => process(data, onErrMessage));
|
||||
}
|
||||
|
||||
downloader.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
downloader.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error("未知错误"));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { DownloadParams } from "../../interfaces";
|
||||
import { m3u8DownloaderWin32 } from "./m3u8-win32";
|
||||
import { m3u8DownloaderDarwin } from "./m3u8-darwin";
|
||||
import { biliDownloader } from "./bilibili";
|
||||
|
||||
export const downloader = (params: DownloadParams): Promise<void> => {
|
||||
if (params.type === "bilibili") {
|
||||
return biliDownloader(params);
|
||||
}
|
||||
|
||||
if (params.type === "m3u8") {
|
||||
if (process.platform === "win32") {
|
||||
return m3u8DownloaderWin32(params);
|
||||
} else {
|
||||
return m3u8DownloaderDarwin(params);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject();
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { stripColors, m3u8DownloaderBin } from "../../helper";
|
||||
import { DownloadParams, DownloadProgress } from "../../interfaces";
|
||||
import { cmdr } from "./cmdr";
|
||||
|
||||
export const m3u8DownloaderDarwin = async (
|
||||
params: DownloadParams,
|
||||
): Promise<void> => {
|
||||
const {
|
||||
id,
|
||||
abortSignal,
|
||||
url,
|
||||
local,
|
||||
name,
|
||||
deleteSegments,
|
||||
headers,
|
||||
callback,
|
||||
proxy,
|
||||
} = params;
|
||||
// const progressReg = /([\d.]+)% .*? ([\d.\w]+?) /g;
|
||||
const progressReg = /([\d.]+)%/g;
|
||||
const errorReg = /ERROR/g;
|
||||
const startDownloadReg = /保存文件名:/g;
|
||||
const isLiveReg = /检测到直播流/g;
|
||||
|
||||
const spawnParams = [
|
||||
url,
|
||||
"--tmp-dir",
|
||||
local,
|
||||
"--save-dir",
|
||||
local,
|
||||
"--save-name",
|
||||
name,
|
||||
"--auto-select",
|
||||
];
|
||||
|
||||
if (headers) {
|
||||
const h: Record<string, unknown> = JSON.parse(headers);
|
||||
Object.entries(h).forEach(([k, v]) => {
|
||||
spawnParams.push("-H", `${k}: ${v}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (deleteSegments) {
|
||||
spawnParams.push("--del-after-done");
|
||||
}
|
||||
|
||||
if (proxy) {
|
||||
spawnParams.push("--custom-proxy", proxy);
|
||||
}
|
||||
|
||||
let isLive = false;
|
||||
await cmdr(m3u8DownloaderBin, spawnParams, {
|
||||
abortSignal,
|
||||
onMessage: (message) => {
|
||||
if (isLiveReg.test(message) || startDownloadReg.test(message)) {
|
||||
callback({
|
||||
id,
|
||||
type: "ready",
|
||||
isLive,
|
||||
cur: "",
|
||||
total: "",
|
||||
speed: "",
|
||||
});
|
||||
isLive = true;
|
||||
}
|
||||
|
||||
const log = stripColors(message);
|
||||
|
||||
if (errorReg.test(log)) {
|
||||
throw new Error(log);
|
||||
}
|
||||
|
||||
const result = progressReg.exec(log);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [, precentage, speed] = result;
|
||||
const cur = String(Number(precentage) * 10);
|
||||
if (cur === "0") {
|
||||
return;
|
||||
}
|
||||
|
||||
const total = "1000";
|
||||
// FIXME: 无法获取是否为直播流
|
||||
const progress: DownloadProgress = {
|
||||
id,
|
||||
type: "progress",
|
||||
cur,
|
||||
total,
|
||||
speed,
|
||||
isLive,
|
||||
};
|
||||
callback(progress);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { formatHeaders, m3u8DownloaderBin } from "../../helper";
|
||||
import { DownloadParams, DownloadProgress } from "../../interfaces";
|
||||
import { cmdr } from "./cmdr";
|
||||
|
||||
export const m3u8DownloaderWin32 = async (
|
||||
params: DownloadParams,
|
||||
): Promise<void> => {
|
||||
const {
|
||||
id,
|
||||
abortSignal,
|
||||
url,
|
||||
local,
|
||||
name,
|
||||
deleteSegments,
|
||||
headers,
|
||||
callback,
|
||||
proxy,
|
||||
} = params;
|
||||
const progressReg = /Progress:\s(\d+)\/(\d+)\s\(.+?\).+?\((.+?\/s).*?\)/g;
|
||||
const isLiveReg = /识别为直播流, 开始录制/g;
|
||||
const startDownloadReg = /开始下载文件/g;
|
||||
|
||||
const spawnParams = [url, "--workDir", local, "--saveName", name];
|
||||
|
||||
if (headers) {
|
||||
spawnParams.push("--headers", formatHeaders(headers));
|
||||
}
|
||||
|
||||
if (deleteSegments) {
|
||||
spawnParams.push("--enableDelAfterDone");
|
||||
}
|
||||
|
||||
if (proxy) {
|
||||
spawnParams.push("--proxyAddress", proxy);
|
||||
}
|
||||
|
||||
let isLive = false;
|
||||
await cmdr(m3u8DownloaderBin, spawnParams, {
|
||||
abortSignal,
|
||||
onMessage: (message) => {
|
||||
if (isLiveReg.test(message) || startDownloadReg.test(message)) {
|
||||
callback({
|
||||
id,
|
||||
type: "ready",
|
||||
isLive,
|
||||
cur: "",
|
||||
total: "",
|
||||
speed: "",
|
||||
});
|
||||
isLive = true;
|
||||
}
|
||||
|
||||
const result = progressReg.exec(message);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [, cur, total, speed] = result;
|
||||
const progress: DownloadProgress = {
|
||||
id,
|
||||
type: "progress",
|
||||
cur,
|
||||
total,
|
||||
speed,
|
||||
isLive,
|
||||
};
|
||||
callback(progress);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { ffmpegPath } from "./variables";
|
||||
|
||||
export const convertToAudio = async (
|
||||
input: string,
|
||||
output: string
|
||||
output: string,
|
||||
): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ffmpeg = spawn(ffmpegPath, [
|
||||
|
||||
@@ -11,7 +11,7 @@ export function getLocalIP() {
|
||||
|
||||
// 过滤出 IPv4 地址且非回环地址
|
||||
const filteredIface = iface.filter(
|
||||
(details) => details.family === "IPv4" && !details.internal
|
||||
(details) => details.family === "IPv4" && !details.internal,
|
||||
);
|
||||
|
||||
if (filteredIface.length > 0) {
|
||||
@@ -23,9 +23,9 @@ export function getLocalIP() {
|
||||
return localIP;
|
||||
}
|
||||
|
||||
export { sleep } from "./utils";
|
||||
export { sleep, stripColors, formatHeaders } from "./utils";
|
||||
export * from "./variables";
|
||||
export { on, handle } from "./decorator";
|
||||
export { spawnDownload } from "./download";
|
||||
export { downloader } from "./downloader";
|
||||
export { convertToAudio } from "./ffmpeg";
|
||||
export { default as http } from "./http";
|
||||
|
||||
@@ -22,3 +22,25 @@ export function formatHeaders(headersStr: string): string {
|
||||
}
|
||||
|
||||
export const event = new EventEmitter();
|
||||
|
||||
export interface IpcResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: Record<string, any> | null;
|
||||
}
|
||||
|
||||
export function success(data: Record<string, any>): IpcResponse {
|
||||
return {
|
||||
code: 0,
|
||||
message: "success",
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
export function error(message = "fail"): IpcResponse {
|
||||
return {
|
||||
code: -1,
|
||||
message,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,28 +1,45 @@
|
||||
import { app } from "electron";
|
||||
import isDev from "electron-is-dev";
|
||||
import path from "path";
|
||||
import { resolve } from "path";
|
||||
|
||||
const appPath = app.getAppPath();
|
||||
export const appData = app.getPath("appData");
|
||||
export const download = app.getPath("downloads");
|
||||
|
||||
export const isMac = process.platform === "darwin";
|
||||
export const isWin = process.platform === "win32";
|
||||
|
||||
if (!isDev) {
|
||||
global.__bin__ = path.resolve(app.getAppPath(), "../bin");
|
||||
global.__bin__ = resolve(appPath, "../bin");
|
||||
}
|
||||
|
||||
export function resolveStatic(path: string) {
|
||||
const relativePath = isDev ? "../.." : "..";
|
||||
return resolve(appPath, relativePath, path);
|
||||
}
|
||||
export function resolveBin(path: string) {
|
||||
return resolve(__bin__, path);
|
||||
}
|
||||
|
||||
export const appData = app.getPath("appData");
|
||||
export const appName = process.env.APP_NAME || "electron-template";
|
||||
export const workspace = path.resolve(appData, appName);
|
||||
export const workspace = resolve(appData, appName);
|
||||
export const defaultScheme = "mediago";
|
||||
export const download = app.getPath("downloads");
|
||||
export const PERSIST_MEDIAGO = "persist:mediago";
|
||||
export const PERSIST_WEBVIEW = "persist:webview";
|
||||
export const db = path.resolve(workspace, "app.db");
|
||||
export const macDownloaderPath = path.resolve(__bin__, "N_m3u8DL-RE");
|
||||
export const winDownloaderPath = path.resolve(__bin__, "N_m3u8DL-CLI.exe");
|
||||
export const ffmpegPath =
|
||||
process.platform === "win32"
|
||||
? path.resolve(__bin__, "ffmpeg.exe")
|
||||
: path.resolve(__bin__, "ffmpeg");
|
||||
export const mobilePath = isDev
|
||||
? path.resolve(app.getAppPath(), "../../mobile")
|
||||
: path.resolve(app.getAppPath(), "../mobile");
|
||||
export const db = resolve(workspace, "app.db");
|
||||
|
||||
// bin path
|
||||
export const ffmpegPath = resolveBin("ffmpeg");
|
||||
export const biliDownloaderBin = resolveBin("BBDown");
|
||||
const downloaderBinName = isWin ? "N_m3u8DL-CLI" : "N_m3u8DL-RE";
|
||||
export const m3u8DownloaderBin = resolveBin(downloaderBinName);
|
||||
export const videoServerBin = resolveBin("server");
|
||||
|
||||
// mobile path
|
||||
export const mobilePath = resolveStatic("mobile");
|
||||
// plugin path
|
||||
export const pluginPath = resolveStatic("plugin/index.js");
|
||||
export const pluginStylePath = resolveStatic("plugin/style.css");
|
||||
|
||||
// user agent
|
||||
export const pcUA =
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import "reflect-metadata";
|
||||
import { app, protocol } from "electron";
|
||||
import { defaultScheme } from "./helper/variables";
|
||||
import { type App } from "./interfaces";
|
||||
import { defaultScheme } from "./helper";
|
||||
import { container } from "./inversify.config";
|
||||
import { TYPES } from "./types";
|
||||
import ElectronApp from "./app";
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
const start = async (): Promise<void> => {
|
||||
@@ -21,7 +21,7 @@ const start = async (): Promise<void> => {
|
||||
},
|
||||
]);
|
||||
await app.whenReady();
|
||||
const mediago = container.get<App>(TYPES.App);
|
||||
const mediago = container.get<ElectronApp>(TYPES.ElectronApp);
|
||||
mediago.init();
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
|
||||
+13
-118
@@ -1,68 +1,13 @@
|
||||
import { type BrowserWindow, BrowserView } from "electron";
|
||||
import { ElectronLog } from "electron-log";
|
||||
import Store from "electron-store";
|
||||
import { Favorite } from "entity/Favorite";
|
||||
import { Video } from "entity/Video";
|
||||
import EventEmitter from "events";
|
||||
import { AppStore } from "main";
|
||||
import { DataSource, EntityManager, UpdateResult, DeleteResult } from "typeorm";
|
||||
|
||||
export interface MainWindowService {
|
||||
window: BrowserWindow | null;
|
||||
init: () => void;
|
||||
}
|
||||
|
||||
export interface PlayerWindowService {
|
||||
openWindow: (id: number) => void;
|
||||
}
|
||||
|
||||
export interface BrowserWindowService {
|
||||
window: BrowserWindow | null;
|
||||
showWindow: () => void;
|
||||
hideWindow: () => void;
|
||||
}
|
||||
|
||||
export interface App {
|
||||
init: () => void;
|
||||
}
|
||||
|
||||
export interface IpcHandlerService {
|
||||
init: () => void;
|
||||
}
|
||||
|
||||
export interface ProtocolService {
|
||||
create: () => void;
|
||||
}
|
||||
|
||||
export interface UpdateService {
|
||||
init: () => void;
|
||||
}
|
||||
|
||||
export interface DatabaseService {
|
||||
manager: EntityManager;
|
||||
appDataSource: DataSource;
|
||||
init: () => void;
|
||||
}
|
||||
|
||||
export type Controller = Record<string | symbol, any>;
|
||||
|
||||
export interface LoggerService {
|
||||
logger: ElectronLog;
|
||||
info: (...args: any[]) => void;
|
||||
error: (...args: any[]) => void;
|
||||
warn: (...args: any[]) => void;
|
||||
debug: (...args: any[]) => void;
|
||||
}
|
||||
|
||||
export interface StoreService extends Store<AppStore> {
|
||||
init: () => void;
|
||||
}
|
||||
|
||||
export interface DownloadItem {
|
||||
id?: number;
|
||||
id: number;
|
||||
type: DownloadType;
|
||||
name: string;
|
||||
url: string;
|
||||
headers: string;
|
||||
headers?: string;
|
||||
status?: DownloadStatus;
|
||||
isLive?: boolean;
|
||||
}
|
||||
|
||||
export enum DownloadFilter {
|
||||
@@ -81,53 +26,6 @@ export interface VideoResponse {
|
||||
list: DownloadItem[];
|
||||
}
|
||||
|
||||
export interface VideoRepository {
|
||||
addVideo: (video: DownloadItem) => Promise<Video>;
|
||||
editVideo: (video: DownloadItem) => Promise<Video>;
|
||||
findVideos: (pagiantion: DownloadItemPagination) => Promise<VideoResponse>;
|
||||
findVideo: (id: number) => Promise<Video | null>;
|
||||
changeVideoStatus: (
|
||||
id: number | number[],
|
||||
status: DownloadStatus
|
||||
) => Promise<UpdateResult>;
|
||||
findWattingAndDownloadingVideos: () => Promise<Video[]>;
|
||||
deleteDownloadItem: (id: number) => Promise<DeleteResult>;
|
||||
findAllVideos(): Promise<Video[]>;
|
||||
changeVideoIsLive: (
|
||||
id: number | number[],
|
||||
isLive: boolean
|
||||
) => Promise<UpdateResult>;
|
||||
findVideoByUrl: (url: string) => Promise<Video | null>;
|
||||
}
|
||||
|
||||
export interface FavoriteRepository {
|
||||
findFavorites: () => Promise<Favorite[]>;
|
||||
addFavorite: (favorite: Favorite) => Promise<Favorite>;
|
||||
removeFavorite: (id: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface WebviewService {
|
||||
view: BrowserView;
|
||||
init: () => void;
|
||||
getBounds: () => Electron.Rectangle;
|
||||
setAutoResize: (options: Electron.AutoResizeOptions) => void;
|
||||
setBackgroundColor: (color: string) => void;
|
||||
setBounds: (bounds: Electron.Rectangle) => void;
|
||||
loadURL: (url?: string) => Promise<void>;
|
||||
goBack: () => Promise<boolean>;
|
||||
reload: () => Promise<void>;
|
||||
goHome: () => Promise<void>;
|
||||
hide: () => void;
|
||||
show: () => void;
|
||||
setProxy: (useProxy: boolean, proxy: string) => void;
|
||||
setBlocking: (enableBlocking: boolean) => void;
|
||||
setUserAgent: (isMobile: boolean) => void;
|
||||
}
|
||||
|
||||
export interface WebService {
|
||||
init: () => void;
|
||||
}
|
||||
|
||||
export enum DownloadStatus {
|
||||
Ready = "ready",
|
||||
Watting = "watting",
|
||||
@@ -137,14 +35,9 @@ export enum DownloadStatus {
|
||||
Failed = "failed",
|
||||
}
|
||||
|
||||
export interface DownloadService extends EventEmitter {
|
||||
addTask: (task: Task) => Promise<void>;
|
||||
stopTask: (id: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export type Task = {
|
||||
id: number;
|
||||
params: Omit<DownloadParams, "id" | "abortSignal">;
|
||||
params: Omit<DownloadParams, "id" | "abortSignal" | "callback">;
|
||||
process: (params: DownloadParams) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -157,17 +50,19 @@ export interface DownloadProgress {
|
||||
isLive: boolean;
|
||||
}
|
||||
|
||||
export enum DownloadType {
|
||||
m3u8 = "m3u8",
|
||||
bilibili = "bilibili",
|
||||
}
|
||||
export interface DownloadParams {
|
||||
id: number;
|
||||
type: DownloadType;
|
||||
url: string;
|
||||
local: string;
|
||||
name: string;
|
||||
headers: string;
|
||||
headers?: string;
|
||||
abortSignal: AbortController;
|
||||
proxy?: string;
|
||||
deleteSegments?: boolean;
|
||||
callback: (progress: DownloadProgress) => void;
|
||||
}
|
||||
|
||||
export interface DevToolsService {
|
||||
init: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,42 +1,25 @@
|
||||
import DownloadController from "controller/DownloadController";
|
||||
import HomeController from "controller/HomeController";
|
||||
import WebviewController from "controller/WebviewController";
|
||||
import DownloadController from "./controller/DownloadController";
|
||||
import HomeController from "./controller/HomeController";
|
||||
import WebviewController from "./controller/WebviewController";
|
||||
import { Container } from "inversify";
|
||||
import FavoriteRepositoryImpl from "repository/favoriteRepositoryImpl";
|
||||
import VideoRepositoryImpl from "repository/videoRepositoryImpl";
|
||||
import DatabaseServiceImpl from "services/DatabaseServiceImpl";
|
||||
import DevToolsServiceImpl from "services/DevToolsServiceImpl";
|
||||
import DownloadServiceImpl from "services/DownloadServiceImpl";
|
||||
import StoreServiceImpl from "services/StoreServiceImpl";
|
||||
import WebviewServiceImpl from "services/WebviewServiceImpl";
|
||||
import FavoriteRepository from "./repository/FavoriteRepository";
|
||||
import VideoRepository from "./repository/VideoRepository";
|
||||
import DatabaseService from "./services/DatabaseService";
|
||||
import DevToolsService from "./services/DevToolsService";
|
||||
import DownloadService from "./services/DownloadService";
|
||||
import StoreService from "./services/StoreService";
|
||||
import WebviewService from "./services/WebviewService";
|
||||
import ElectronApp from "./app";
|
||||
import {
|
||||
App,
|
||||
StoreService,
|
||||
Controller,
|
||||
IpcHandlerService,
|
||||
LoggerService,
|
||||
MainWindowService,
|
||||
ProtocolService,
|
||||
UpdateService,
|
||||
VideoRepository,
|
||||
DatabaseService,
|
||||
FavoriteRepository,
|
||||
WebviewService,
|
||||
BrowserWindowService,
|
||||
DevToolsService,
|
||||
WebService,
|
||||
PlayerWindowService,
|
||||
} from "./interfaces";
|
||||
import IpcHandlerServiceImpl from "./services/IpcHandlerServiceImpl";
|
||||
import LoggerServiceImpl from "./services/LoggerServiceImpl";
|
||||
import ProtocolServiceImpl from "./services/ProtocolServiceImpl";
|
||||
import UpdateServiceImpl from "./services/UpdateServiceImpl";
|
||||
import { Controller } from "./interfaces";
|
||||
import IpcHandlerService from "./services/IpcHandlerService";
|
||||
import LoggerService from "./services/LoggerService";
|
||||
import ProtocolService from "./services/ProtocolService";
|
||||
import UpdateService from "./services/UpdateService";
|
||||
import { TYPES } from "./types";
|
||||
import WebServiceImpl from "services/WebServiceImpl";
|
||||
import MainWindow from "./windows/Main";
|
||||
import PlayerWindow from "windows/Player";
|
||||
import BrowserWin from "windows/Browser";
|
||||
import MainWindow from "./windows/MainWindow";
|
||||
import PlayerWindow from "./windows/PlayerWindow";
|
||||
import BrowserWindow from "./windows/BrowserWindow";
|
||||
import VideoService from "./services/VideoService";
|
||||
|
||||
const container = new Container({
|
||||
skipBaseClassChecks: true,
|
||||
@@ -44,36 +27,36 @@ const container = new Container({
|
||||
autoBindInjectable: true,
|
||||
});
|
||||
|
||||
container.bind<App>(TYPES.App).to(ElectronApp);
|
||||
container.bind<ElectronApp>(TYPES.ElectronApp).to(ElectronApp);
|
||||
|
||||
// services
|
||||
container
|
||||
.bind<IpcHandlerService>(TYPES.IpcHandlerService)
|
||||
.to(IpcHandlerServiceImpl);
|
||||
container.bind<ProtocolService>(TYPES.ProtocolService).to(ProtocolServiceImpl);
|
||||
container.bind<UpdateService>(TYPES.UpdateService).to(UpdateServiceImpl);
|
||||
container.bind<LoggerService>(TYPES.LoggerService).to(LoggerServiceImpl);
|
||||
container.bind<StoreService>(TYPES.StoreService).to(StoreServiceImpl);
|
||||
container.bind<DatabaseService>(TYPES.DatabaseService).to(DatabaseServiceImpl);
|
||||
container.bind<WebviewService>(TYPES.WebviewService).to(WebviewServiceImpl);
|
||||
container
|
||||
.bind<DownloadServiceImpl>(TYPES.DownloadService)
|
||||
.to(DownloadServiceImpl);
|
||||
container.bind<DevToolsService>(TYPES.DevToolsService).to(DevToolsServiceImpl);
|
||||
container.bind<WebService>(TYPES.WebService).to(WebServiceImpl);
|
||||
.to(IpcHandlerService);
|
||||
container.bind<ProtocolService>(TYPES.ProtocolService).to(ProtocolService);
|
||||
container.bind<UpdateService>(TYPES.UpdateService).to(UpdateService);
|
||||
container.bind<LoggerService>(TYPES.LoggerService).to(LoggerService);
|
||||
container.bind<StoreService>(TYPES.StoreService).to(StoreService);
|
||||
container.bind<DatabaseService>(TYPES.DatabaseService).to(DatabaseService);
|
||||
container.bind<WebviewService>(TYPES.WebviewService).to(WebviewService);
|
||||
container.bind<DownloadService>(TYPES.DownloadService).to(DownloadService);
|
||||
container.bind<DevToolsService>(TYPES.DevToolsService).to(DevToolsService);
|
||||
container.bind<VideoService>(TYPES.VideoService).to(VideoService);
|
||||
|
||||
// windows
|
||||
container.bind<MainWindowService>(TYPES.MainWindowService).to(MainWindow);
|
||||
container.bind<BrowserWindowService>(TYPES.BrowserWindowService).to(BrowserWin);
|
||||
container.bind<PlayerWindowService>(TYPES.PlayerWindowService).to(PlayerWindow);
|
||||
container.bind<MainWindow>(TYPES.MainWindow).to(MainWindow);
|
||||
container.bind<BrowserWindow>(TYPES.BrowserWindow).to(BrowserWindow);
|
||||
container.bind<PlayerWindow>(TYPES.PlayerWindow).to(PlayerWindow);
|
||||
|
||||
// === controller
|
||||
// controller
|
||||
container.bind<Controller>(TYPES.Controller).to(HomeController);
|
||||
container.bind<Controller>(TYPES.Controller).to(WebviewController);
|
||||
container.bind<Controller>(TYPES.Controller).to(DownloadController);
|
||||
|
||||
// === repository
|
||||
container.bind<VideoRepository>(TYPES.VideoRepository).to(VideoRepositoryImpl);
|
||||
// repository
|
||||
container.bind<VideoRepository>(TYPES.VideoRepository).to(VideoRepository);
|
||||
container
|
||||
.bind<FavoriteRepository>(TYPES.FavoriteRepository)
|
||||
.to(FavoriteRepositoryImpl);
|
||||
.to(FavoriteRepository);
|
||||
|
||||
export { container };
|
||||
|
||||
Vendored
+4
-49
@@ -1,10 +1,5 @@
|
||||
import { Video, Rectangle } from "electron";
|
||||
import { type Favorite } from "entity/Favorite";
|
||||
import {
|
||||
DownloadItem,
|
||||
DownloadItemPagination,
|
||||
VideoResponse,
|
||||
} from "interfaces";
|
||||
import { Rectangle } from "electron";
|
||||
import { type DownloadType } from "interfaces";
|
||||
|
||||
declare interface EnvPath {
|
||||
binPath: string;
|
||||
@@ -19,51 +14,11 @@ declare interface BrowserWindowInitialVal {
|
||||
sourceList?: WebSource[];
|
||||
}
|
||||
|
||||
declare interface ElectronAPI {
|
||||
getEnvPath: () => Promise<EnvPath>;
|
||||
getFavorites: () => Promise<Favorite>;
|
||||
addFavorite: (favorite: Favorite) => Promise<Favorite>;
|
||||
removeFavorite: (id: number) => Promise<void>;
|
||||
setWebviewBounds: (rect: Electron.Rectangle) => Promise<void>;
|
||||
webviewLoadURL: (url?: string) => Promise<void>;
|
||||
webviewGoBack: () => Promise<boolean>;
|
||||
webviewReload: () => Promise<void>;
|
||||
webwiewGoHome: () => Promise<void>;
|
||||
getAppStore: () => Promise<AppStore>;
|
||||
onSelectDownloadDir: () => Promise<string>;
|
||||
setAppStore: (key: keyof AppStore, val: any) => Promise<void>;
|
||||
openDir: (dir: string) => Promise<void>;
|
||||
addDownloadItem: (video: DownloadItem) => Promise<Video>;
|
||||
getDownloadItems: (
|
||||
pagiantion: DownloadItemPagination
|
||||
) => Promise<VideoResponse>;
|
||||
startDownload: (vid: number) => Promise<void>;
|
||||
openUrl: (url: string) => Promise<void>;
|
||||
stopDownload: (id: number) => Promise<void>;
|
||||
onDownloadListContextMenu: (id: number) => Promise<void>;
|
||||
onFavoriteItemContextMenu: (id: number) => Promise<void>;
|
||||
deleteDownloadItem: (id: number) => Promise<void>;
|
||||
convertToAudio: (id: number) => Promise<void>;
|
||||
rendererEvent: (channel: string, funcId: string, listener: any) => void;
|
||||
removeEventListener: (channel: string, funcId: string) => void;
|
||||
showBrowserWindow: () => Promise<void>;
|
||||
webviewHide: () => Promise<void>;
|
||||
webviewShow: () => Promise<void>;
|
||||
downloadNow: (video: DownloadItem) => Promise<void>;
|
||||
combineToHomePage: (initialVal: BrowserStore) => Promise<void>;
|
||||
editDownloadItem: (video: DownloadItem) => Promise<void>;
|
||||
openPlayerWindow: (videoId: number) => Promise<void>;
|
||||
getLocalIP: () => Promise<string>;
|
||||
openBrowser: (url: string) => Promise<void>;
|
||||
getSharedState: () => Promise<any>;
|
||||
setSharedState: (state: any) => Promise<void>;
|
||||
setUserAgent: (isMobile: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
declare interface WebSource {
|
||||
url: string;
|
||||
type: DownloadType;
|
||||
name: string;
|
||||
headers: string;
|
||||
headers?: string;
|
||||
}
|
||||
|
||||
declare interface AppStore {
|
||||
|
||||
@@ -1,60 +1,91 @@
|
||||
import { contextBridge, ipcRenderer, shell } from "electron";
|
||||
import { ElectronAPI } from "./main";
|
||||
import { AppStore, BrowserStore, EnvPath } from "./main";
|
||||
import { type Favorite } from "./entity/Favorite";
|
||||
import {
|
||||
VideoResponse,
|
||||
type DownloadItem,
|
||||
type DownloadItemPagination,
|
||||
} from "./interfaces";
|
||||
import { Video } from "./entity/Video";
|
||||
|
||||
const apiFunctions: Record<string, any> = {};
|
||||
|
||||
const apiKey = "electron";
|
||||
const handleApi: ElectronAPI = {
|
||||
getEnvPath: () => ipcRenderer.invoke("get-env-path"),
|
||||
getFavorites: () => ipcRenderer.invoke("get-favorites"),
|
||||
addFavorite: (favorite) => ipcRenderer.invoke("add-favorite", favorite),
|
||||
removeFavorite: (id) => ipcRenderer.invoke("remove-favorite", id),
|
||||
setWebviewBounds: (rect) => ipcRenderer.invoke("set-webview-bounds", rect),
|
||||
webviewGoBack: () => ipcRenderer.invoke("webview-go-back"),
|
||||
webviewReload: () => ipcRenderer.invoke("webview-reload"),
|
||||
webviewLoadURL: (url?: string) => ipcRenderer.invoke("webview-load-url", url),
|
||||
webwiewGoHome: () => ipcRenderer.invoke("webview-go-home"),
|
||||
getAppStore: () => ipcRenderer.invoke("get-app-store"),
|
||||
onSelectDownloadDir: () => ipcRenderer.invoke("select-download-dir"),
|
||||
setAppStore: (key, val) => ipcRenderer.invoke("set-app-store", key, val),
|
||||
openDir: (dir) => ipcRenderer.invoke("open-dir", dir),
|
||||
addDownloadItem: (video) => ipcRenderer.invoke("add-download-item", video),
|
||||
getDownloadItems: (p) => ipcRenderer.invoke("get-download-items", p),
|
||||
startDownload: (vid) => ipcRenderer.invoke("start-download", vid),
|
||||
openUrl: (url: string) => ipcRenderer.invoke("open-url", url),
|
||||
stopDownload: (id) => ipcRenderer.invoke("stop-download", id),
|
||||
onDownloadListContextMenu: (id) =>
|
||||
|
||||
export type ElectronApi = typeof electronApi;
|
||||
|
||||
const electronApi = {
|
||||
getEnvPath: (): Promise<EnvPath> => ipcRenderer.invoke("get-env-path"),
|
||||
getFavorites: (): Promise<Favorite[]> => ipcRenderer.invoke("get-favorites"),
|
||||
addFavorite: (
|
||||
favorite: Omit<Favorite, "id" | "createdDate" | "updatedDate">,
|
||||
): Promise<Favorite> => ipcRenderer.invoke("add-favorite", favorite),
|
||||
removeFavorite: (id: number): Promise<void> =>
|
||||
ipcRenderer.invoke("remove-favorite", id),
|
||||
setWebviewBounds: (rect: Electron.Rectangle): Promise<void> =>
|
||||
ipcRenderer.invoke("set-webview-bounds", rect),
|
||||
webviewGoBack: (): Promise<boolean> => ipcRenderer.invoke("webview-go-back"),
|
||||
webviewReload: (): Promise<void> => ipcRenderer.invoke("webview-reload"),
|
||||
webviewLoadURL: (url?: string): Promise<void> =>
|
||||
ipcRenderer.invoke("webview-load-url", url),
|
||||
webviewGoHome: (): Promise<void> => ipcRenderer.invoke("webview-go-home"),
|
||||
getAppStore: (): Promise<AppStore> => ipcRenderer.invoke("get-app-store"),
|
||||
onSelectDownloadDir: (): Promise<string> =>
|
||||
ipcRenderer.invoke("select-download-dir"),
|
||||
setAppStore: (
|
||||
key: keyof AppStore,
|
||||
val: AppStore[keyof AppStore],
|
||||
): Promise<void> => ipcRenderer.invoke("set-app-store", key, val),
|
||||
openDir: (dir: string): Promise<void> => ipcRenderer.invoke("open-dir", dir),
|
||||
addDownloadItem: (video: Omit<DownloadItem, "id">): Promise<Video> =>
|
||||
ipcRenderer.invoke("add-download-item", video),
|
||||
addDownloadItems: (videos: DownloadItem[]): Promise<Video[]> =>
|
||||
ipcRenderer.invoke("add-download-items", videos),
|
||||
getDownloadItems: (p: DownloadItemPagination): Promise<VideoResponse> =>
|
||||
ipcRenderer.invoke("get-download-items", p),
|
||||
startDownload: (vid: number): Promise<void> =>
|
||||
ipcRenderer.invoke("start-download", vid),
|
||||
openUrl: (url: string): Promise<void> => ipcRenderer.invoke("open-url", url),
|
||||
stopDownload: (id: number): Promise<void> =>
|
||||
ipcRenderer.invoke("stop-download", id),
|
||||
onDownloadListContextMenu: (id: number): Promise<void> =>
|
||||
ipcRenderer.invoke("on-download-list-context-menu", id),
|
||||
onFavoriteItemContextMenu: (id) =>
|
||||
onFavoriteItemContextMenu: (id: number): Promise<void> =>
|
||||
ipcRenderer.invoke("on-favorite-item-context-menu", id),
|
||||
deleteDownloadItem: (id) => ipcRenderer.invoke("delete-download-item", id),
|
||||
convertToAudio: (id) => ipcRenderer.invoke("convert-to-audio", id),
|
||||
rendererEvent: (channel, funcId, listener) => {
|
||||
deleteDownloadItem: (id: number): Promise<void> =>
|
||||
ipcRenderer.invoke("delete-download-item", id),
|
||||
convertToAudio: (id: number): Promise<void> =>
|
||||
ipcRenderer.invoke("convert-to-audio", id),
|
||||
rendererEvent: (channel: string, funcId: string, listener: any): void => {
|
||||
const key = `${channel}-${funcId}`;
|
||||
apiFunctions[key] = listener;
|
||||
ipcRenderer.on(channel, listener);
|
||||
},
|
||||
removeEventListener: (channel, funcId) => {
|
||||
removeEventListener: (channel: string, funcId: string): void => {
|
||||
const key = `${channel}-${funcId}`;
|
||||
const fun = apiFunctions[key];
|
||||
ipcRenderer.removeListener(channel, fun);
|
||||
delete apiFunctions[key];
|
||||
},
|
||||
showBrowserWindow: () => ipcRenderer.invoke("show-browser-window"),
|
||||
webviewHide: () => ipcRenderer.invoke("webview-hide"),
|
||||
webviewShow: () => ipcRenderer.invoke("webview-show"),
|
||||
downloadNow: (video) => ipcRenderer.invoke("download-now", video),
|
||||
combineToHomePage: (store) =>
|
||||
showBrowserWindow: (): Promise<void> =>
|
||||
ipcRenderer.invoke("show-browser-window"),
|
||||
webviewHide: (): Promise<void> => ipcRenderer.invoke("webview-hide"),
|
||||
webviewShow: (): Promise<void> => ipcRenderer.invoke("webview-show"),
|
||||
downloadNow: (video: DownloadItem): Promise<void> =>
|
||||
ipcRenderer.invoke("download-now", video),
|
||||
combineToHomePage: (store: BrowserStore): Promise<void> =>
|
||||
ipcRenderer.invoke("combine-to-home-page", store),
|
||||
editDownloadItem: (video) => ipcRenderer.invoke("edit-download-item", video),
|
||||
openPlayerWindow: (videoId) =>
|
||||
editDownloadItem: (video: DownloadItem): Promise<void> =>
|
||||
ipcRenderer.invoke("edit-download-item", video),
|
||||
openPlayerWindow: (videoId: string): Promise<void> =>
|
||||
ipcRenderer.invoke("open-player-window", videoId),
|
||||
getLocalIP: () => ipcRenderer.invoke("get-local-ip"),
|
||||
openBrowser: (url: string) => shell.openExternal(url),
|
||||
getSharedState: () => ipcRenderer.invoke("get-shared-state"),
|
||||
setSharedState: (state) => ipcRenderer.invoke("set-shared-state", state),
|
||||
setUserAgent: (isMobile: boolean) =>
|
||||
getLocalIP: (): Promise<string> => ipcRenderer.invoke("get-local-ip"),
|
||||
openBrowser: (url: string): Promise<void> => shell.openExternal(url),
|
||||
getSharedState: (): Promise<any> => ipcRenderer.invoke("get-shared-state"),
|
||||
setSharedState: (state: any): Promise<void> =>
|
||||
ipcRenderer.invoke("set-shared-state", state),
|
||||
setUserAgent: (isMobile: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke("webview-change-user-agent", isMobile),
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld(apiKey, handleApi);
|
||||
contextBridge.exposeInMainWorld(apiKey, electronApi);
|
||||
|
||||
+3
-9
@@ -1,19 +1,13 @@
|
||||
import { inject, injectable } from "inversify";
|
||||
import {
|
||||
DatabaseService,
|
||||
FavoriteRepository,
|
||||
LoggerService,
|
||||
} from "../interfaces";
|
||||
import { TYPES } from "../types";
|
||||
import { Favorite } from "entity/Favorite";
|
||||
import { Favorite } from "../entity/Favorite";
|
||||
import DatabaseService from "../services/DatabaseService";
|
||||
|
||||
@injectable()
|
||||
export default class FavoriteRepositoryImpl implements FavoriteRepository {
|
||||
export default class FavoriteRepository {
|
||||
constructor(
|
||||
@inject(TYPES.DatabaseService)
|
||||
private readonly dataService: DatabaseService,
|
||||
@inject(TYPES.LoggerService)
|
||||
private readonly logger: LoggerService
|
||||
) {}
|
||||
|
||||
async findFavorites(): Promise<Favorite[]> {
|
||||
+24
-8
@@ -1,34 +1,43 @@
|
||||
import { Video } from "../entity/Video";
|
||||
import { inject, injectable } from "inversify";
|
||||
import {
|
||||
DatabaseService,
|
||||
DownloadFilter,
|
||||
DownloadItem,
|
||||
DownloadItemPagination,
|
||||
DownloadStatus,
|
||||
LoggerService,
|
||||
VideoRepository,
|
||||
} from "../interfaces";
|
||||
import { TYPES } from "../types";
|
||||
import { In, Not } from "typeorm";
|
||||
import DatabaseService from "../services/DatabaseService";
|
||||
|
||||
@injectable()
|
||||
export default class VideoRepositoryImpl implements VideoRepository {
|
||||
export default class VideoRepository {
|
||||
constructor(
|
||||
@inject(TYPES.DatabaseService)
|
||||
private readonly dataService: DatabaseService,
|
||||
@inject(TYPES.LoggerService)
|
||||
private readonly logger: LoggerService
|
||||
) {}
|
||||
|
||||
async addVideo(video: DownloadItem) {
|
||||
async addVideo(video: Omit<DownloadItem, "id">) {
|
||||
const item = new Video();
|
||||
item.name = video.name;
|
||||
item.url = video.url;
|
||||
item.headers = video.headers;
|
||||
item.type = video.type;
|
||||
video.headers && (item.headers = video.headers);
|
||||
return await this.dataService.manager.save(item);
|
||||
}
|
||||
|
||||
async addVideos(videos: DownloadItem[]) {
|
||||
const items = videos.map((video) => {
|
||||
const item = new Video();
|
||||
item.name = video.name;
|
||||
item.url = video.url;
|
||||
item.type = video.type;
|
||||
video.headers && (item.headers = video.headers);
|
||||
return item;
|
||||
});
|
||||
return await this.dataService.manager.save(items);
|
||||
}
|
||||
|
||||
// 编辑视频
|
||||
async editVideo(video: DownloadItem) {
|
||||
const item = await this.dataService.appDataSource
|
||||
@@ -39,6 +48,7 @@ export default class VideoRepositoryImpl implements VideoRepository {
|
||||
}
|
||||
item.name = video.name;
|
||||
item.url = video.url;
|
||||
video.headers && (item.headers = video.headers);
|
||||
return await this.dataService.manager.save(item);
|
||||
}
|
||||
|
||||
@@ -86,6 +96,12 @@ export default class VideoRepositoryImpl implements VideoRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findVideoByName(name: string) {
|
||||
return this.dataService.appDataSource.getRepository(Video).findOneBy({
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
async changeVideoStatus(id: number | number[], status: DownloadStatus) {
|
||||
const ids = !Array.isArray(id) ? [id] : id;
|
||||
return this.dataService.appDataSource
|
||||
+5
-5
@@ -1,18 +1,18 @@
|
||||
import { db } from "../helper/variables";
|
||||
import { db } from "../helper";
|
||||
import { inject, injectable } from "inversify";
|
||||
import { DataSource, EntityManager } from "typeorm";
|
||||
import { TYPES } from "../types";
|
||||
import { DatabaseService, LoggerService } from "../interfaces";
|
||||
import { Video } from "../entity/Video";
|
||||
import { Favorite } from "entity/Favorite";
|
||||
import { Favorite } from "../entity/Favorite";
|
||||
import LoggerService from "./LoggerService";
|
||||
|
||||
@injectable()
|
||||
export default class DatabaseServiceImpl implements DatabaseService {
|
||||
export default class DatabaseService {
|
||||
appDataSource: DataSource;
|
||||
|
||||
constructor(
|
||||
@inject(TYPES.LoggerService)
|
||||
private readonly logger: LoggerService
|
||||
private readonly logger: LoggerService,
|
||||
) {
|
||||
this.appDataSource = new DataSource({
|
||||
type: "better-sqlite3",
|
||||
+3
-3
@@ -1,4 +1,3 @@
|
||||
import { DevToolsService, LoggerService } from "../interfaces";
|
||||
import { inject, injectable } from "inversify";
|
||||
import { TYPES } from "../types";
|
||||
import isDev from "electron-is-dev";
|
||||
@@ -6,12 +5,13 @@ import installExtension, {
|
||||
REDUX_DEVTOOLS,
|
||||
REACT_DEVELOPER_TOOLS,
|
||||
} from "electron-devtools-installer";
|
||||
import LoggerService from "./LoggerService";
|
||||
|
||||
@injectable()
|
||||
export default class DevToolsServiceImpl implements DevToolsService {
|
||||
export default class DevToolsService {
|
||||
constructor(
|
||||
@inject(TYPES.LoggerService)
|
||||
private readonly logger: LoggerService
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async init(): Promise<void> {
|
||||
+34
-26
@@ -1,20 +1,18 @@
|
||||
import EventEmitter from "events";
|
||||
import { inject, injectable } from "inversify";
|
||||
import {
|
||||
DownloadService,
|
||||
DownloadParams,
|
||||
DownloadProgress,
|
||||
DownloadStatus,
|
||||
StoreService,
|
||||
Task,
|
||||
VideoRepository,
|
||||
} from "../interfaces";
|
||||
import { TYPES } from "../types";
|
||||
import LoggerServiceImpl from "./LoggerServiceImpl";
|
||||
import LoggerServiceImpl from "./LoggerService";
|
||||
import StoreService from "./StoreService";
|
||||
import VideoRepository from "../repository/VideoRepository";
|
||||
|
||||
@injectable()
|
||||
export default class DownloadServiceImpl
|
||||
extends EventEmitter
|
||||
implements DownloadService
|
||||
{
|
||||
export default class DownloadService extends EventEmitter {
|
||||
private queue: Task[] = [];
|
||||
|
||||
private active: Task[] = [];
|
||||
@@ -31,7 +29,7 @@ export default class DownloadServiceImpl
|
||||
@inject(TYPES.VideoRepository)
|
||||
private readonly videoRepository: VideoRepository,
|
||||
@inject(TYPES.StoreService)
|
||||
private readonly storeService: StoreService
|
||||
private readonly storeService: StoreService,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -59,34 +57,44 @@ export default class DownloadServiceImpl
|
||||
try {
|
||||
await this.videoRepository.changeVideoStatus(
|
||||
task.id,
|
||||
DownloadStatus.Downloading
|
||||
DownloadStatus.Downloading,
|
||||
);
|
||||
this.emit("download-start", task.id);
|
||||
|
||||
this.log(`taskId: ${task.id} start`);
|
||||
const controller = new AbortController();
|
||||
this.signal[task.id] = controller;
|
||||
await task.process({
|
||||
|
||||
const callback = (progress: DownloadProgress) => {
|
||||
if (progress.type === "progress") {
|
||||
this.emit("download-progress", progress);
|
||||
} else if (progress.type === "ready") {
|
||||
this.emit("download-ready-start", progress);
|
||||
if (progress.isLive) {
|
||||
this.removeTask(progress.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const params: DownloadParams = {
|
||||
...task.params,
|
||||
id: task.id,
|
||||
abortSignal: controller,
|
||||
callback: (progress) => {
|
||||
if (progress.type === "progress") {
|
||||
this.emit("download-progress", progress);
|
||||
} else if (progress.type === "ready") {
|
||||
this.emit("download-ready-start", progress);
|
||||
if (progress.isLive) {
|
||||
this.removeTask(progress.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
callback,
|
||||
};
|
||||
|
||||
const { proxy, useProxy } = this.storeService.store;
|
||||
if (useProxy) {
|
||||
params.proxy = proxy;
|
||||
}
|
||||
|
||||
await task.process(params);
|
||||
delete this.signal[task.id];
|
||||
this.log(`taskId: ${task.id} success`);
|
||||
|
||||
await this.videoRepository.changeVideoStatus(
|
||||
task.id,
|
||||
DownloadStatus.Success
|
||||
DownloadStatus.Success,
|
||||
);
|
||||
this.emit("download-success", task.id);
|
||||
} catch (err: any) {
|
||||
@@ -95,14 +103,14 @@ export default class DownloadServiceImpl
|
||||
// 下载暂停
|
||||
await this.videoRepository.changeVideoStatus(
|
||||
task.id,
|
||||
DownloadStatus.Stopped
|
||||
DownloadStatus.Stopped,
|
||||
);
|
||||
this.emit("download-stop", task.id);
|
||||
} else {
|
||||
// 下载失败
|
||||
await this.videoRepository.changeVideoStatus(
|
||||
task.id,
|
||||
DownloadStatus.Failed
|
||||
DownloadStatus.Failed,
|
||||
);
|
||||
this.emit("download-failed", task.id, err);
|
||||
}
|
||||
@@ -136,7 +144,7 @@ export default class DownloadServiceImpl
|
||||
}
|
||||
}
|
||||
|
||||
log(...args: any[]) {
|
||||
log(...args: unknown[]) {
|
||||
if (this.debug) {
|
||||
this.logger.info(`[DownloadService] `, ...args);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ipcMain } from "electron";
|
||||
import { inject, injectable, multiInject } from "inversify";
|
||||
import { Controller } from "../interfaces";
|
||||
import { TYPES } from "../types";
|
||||
import LoggerService from "./LoggerService";
|
||||
import { error, success } from "../helper/utils";
|
||||
|
||||
@injectable()
|
||||
export default class IpcHandlerService {
|
||||
constructor(
|
||||
@multiInject(TYPES.Controller)
|
||||
private readonly controllers: Controller[],
|
||||
@inject(TYPES.LoggerService)
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
private registerIpc(
|
||||
controller: Controller,
|
||||
propertyKey: string | symbol,
|
||||
): void {
|
||||
const property = controller[propertyKey];
|
||||
if (typeof property !== "function") return;
|
||||
|
||||
const channel: string = Reflect.getMetadata(
|
||||
"ipc-channel",
|
||||
controller,
|
||||
propertyKey,
|
||||
);
|
||||
if (!channel) return;
|
||||
|
||||
const ipcMethod: "on" | "handle" = Reflect.getMetadata(
|
||||
"ipc-method",
|
||||
controller,
|
||||
propertyKey,
|
||||
);
|
||||
if (!ipcMethod) return;
|
||||
|
||||
ipcMain[ipcMethod](channel, async (...args: unknown[]) => {
|
||||
try {
|
||||
let res = property.call(controller, ...args);
|
||||
if (res.then) {
|
||||
res = await res;
|
||||
}
|
||||
return success(res);
|
||||
} catch (e: unknown) {
|
||||
this.logger.error("处理 ipc 事件时异常: ", e);
|
||||
if (e instanceof Error) {
|
||||
return error(e.message);
|
||||
} else {
|
||||
return error(String(e));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
init(): void {
|
||||
for (const controller of this.controllers) {
|
||||
const Class = Object.getPrototypeOf(controller);
|
||||
Object.getOwnPropertyNames(Class).forEach((propertyKey) =>
|
||||
this.registerIpc(controller, propertyKey),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { ipcMain } from "electron";
|
||||
import { inject, injectable, multiInject } from "inversify";
|
||||
import { Controller, IpcHandlerService, LoggerService } from "../interfaces";
|
||||
import { TYPES } from "../types";
|
||||
|
||||
@injectable()
|
||||
export default class IpcHandlerServiceImpl implements IpcHandlerService {
|
||||
constructor(
|
||||
@multiInject(TYPES.Controller)
|
||||
private readonly controllers: Controller[],
|
||||
@inject(TYPES.LoggerService)
|
||||
private readonly logger: LoggerService
|
||||
) {}
|
||||
|
||||
private registerIpc(controller: Controller, property: string | symbol): void {
|
||||
const fun = controller[property];
|
||||
const channel: string = Reflect.getMetadata(
|
||||
"ipc-channel",
|
||||
controller,
|
||||
property
|
||||
);
|
||||
const method: "on" | "handle" = Reflect.getMetadata(
|
||||
"ipc-method",
|
||||
controller,
|
||||
property
|
||||
);
|
||||
if (typeof fun === "function" && method && channel) {
|
||||
ipcMain[method](channel, async (...args: any[]) => {
|
||||
try {
|
||||
const handler = fun.bind(controller);
|
||||
let res = handler(...args);
|
||||
if (res.then) {
|
||||
res = await res;
|
||||
}
|
||||
return this.success(res);
|
||||
} catch (e: any) {
|
||||
this.logger.error("处理 ipc 事件时异常: ", e);
|
||||
if (e instanceof Error) {
|
||||
return this.error(e.message);
|
||||
} else if (typeof e === "string") {
|
||||
return this.error(e);
|
||||
} else {
|
||||
return this.error(String(e));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
success(data: any) {
|
||||
return {
|
||||
code: 0,
|
||||
message: "success",
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
error(message = "fail") {
|
||||
return {
|
||||
code: -1,
|
||||
message,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
private bindIpcMethods(controller: Controller): void {
|
||||
const Class = Object.getPrototypeOf(controller);
|
||||
Object.getOwnPropertyNames(Class).forEach((property) =>
|
||||
this.registerIpc(controller, property as never)
|
||||
);
|
||||
}
|
||||
|
||||
init(): void {
|
||||
this.controllers.forEach((controller) => this.bindIpcMethods(controller));
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -1,13 +1,12 @@
|
||||
import logger, { ElectronLog } from "electron-log";
|
||||
import logger, { Logger } from "electron-log";
|
||||
import { injectable } from "inversify";
|
||||
import dayjs from "dayjs";
|
||||
import path from "path";
|
||||
import { appName, workspace } from "../helper";
|
||||
import { LoggerService } from "../interfaces";
|
||||
|
||||
@injectable()
|
||||
export default class LoggerServiceImpl implements LoggerService {
|
||||
logger: ElectronLog;
|
||||
export default class LoggerService {
|
||||
logger: Logger;
|
||||
|
||||
constructor() {
|
||||
const datetime = dayjs().format("YYYY-MM-DD");
|
||||
+3
-4
@@ -4,11 +4,10 @@ import { pathExists, readFile } from "fs-extra";
|
||||
import { injectable } from "inversify";
|
||||
import { extname, join } from "path";
|
||||
import { URL } from "url";
|
||||
import { defaultScheme } from "../helper/variables";
|
||||
import { ProtocolService } from "../interfaces";
|
||||
import { defaultScheme } from "../helper";
|
||||
|
||||
@injectable()
|
||||
export default class ProtocolServiceImpl implements ProtocolService {
|
||||
export default class ProtocolService {
|
||||
create(): void {
|
||||
if (isDev) return;
|
||||
|
||||
@@ -45,7 +44,7 @@ export default class ProtocolServiceImpl implements ProtocolService {
|
||||
const data = await readFile(filePath);
|
||||
callback({ mimeType: "text/html", data });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
-8
@@ -1,15 +1,11 @@
|
||||
import Store from "electron-store";
|
||||
import { injectable } from "inversify";
|
||||
import { AppStore } from "main";
|
||||
import { download, workspace } from "../helper/variables";
|
||||
import { StoreService } from "../interfaces";
|
||||
import { AppTheme } from "types";
|
||||
import { AppStore } from "../main";
|
||||
import { download, workspace } from "../helper";
|
||||
import { AppTheme } from "../types";
|
||||
|
||||
@injectable()
|
||||
export default class StoreServiceImpl
|
||||
extends Store<AppStore>
|
||||
implements StoreService
|
||||
{
|
||||
export default class StoreService extends Store<AppStore> {
|
||||
constructor() {
|
||||
super({
|
||||
name: "config",
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { inject, injectable } from "inversify";
|
||||
import { LoggerService, type UpdateService } from "../interfaces";
|
||||
import { TYPES } from "../types";
|
||||
import isDev from "electron-is-dev";
|
||||
import LoggerService from "./LoggerService";
|
||||
|
||||
@injectable()
|
||||
export default class UpdateServiceImpl implements UpdateService {
|
||||
export default class UpdateService {
|
||||
constructor(
|
||||
@inject(TYPES.LoggerService) private readonly logger: LoggerService
|
||||
@inject(TYPES.LoggerService) private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async init(): Promise<void> {
|
||||
@@ -0,0 +1,29 @@
|
||||
import { inject, injectable } from "inversify";
|
||||
import { execa } from "execa";
|
||||
import { mobilePath, videoServerBin } from "../helper";
|
||||
import { TYPES } from "../types";
|
||||
import StoreService from "./StoreService";
|
||||
|
||||
@injectable()
|
||||
export default class VideoService {
|
||||
constructor(
|
||||
@inject(TYPES.StoreService)
|
||||
private readonly storeService: StoreService,
|
||||
) {}
|
||||
|
||||
init(): void {
|
||||
const local = this.storeService.get("local");
|
||||
// empty
|
||||
const child = execa(videoServerBin, [
|
||||
"--static-path",
|
||||
mobilePath,
|
||||
"--video-path",
|
||||
local,
|
||||
"--port",
|
||||
process.env.APP_SERVER_PORT || "",
|
||||
]);
|
||||
|
||||
child.stdout?.pipe(process.stdout);
|
||||
child.stderr?.pipe(process.stderr);
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import {
|
||||
LoggerService,
|
||||
StoreService,
|
||||
VideoRepository,
|
||||
WebService,
|
||||
} from "../interfaces";
|
||||
import { inject, injectable } from "inversify";
|
||||
import Koa, { Context } from "koa";
|
||||
import Router from "@koa/router";
|
||||
import serve from "koa-static";
|
||||
import range from "koa-range";
|
||||
import { TYPES } from "types";
|
||||
import { glob } from "glob";
|
||||
import send from "koa-send";
|
||||
import cors from "@koa/cors";
|
||||
import { getLocalIP, mobilePath } from "helper";
|
||||
|
||||
@injectable()
|
||||
export default class WebServiceImpl implements WebService {
|
||||
private readonly app: Koa;
|
||||
private readonly router: Router;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
@inject(TYPES.StoreService)
|
||||
private readonly storeService: StoreService,
|
||||
@inject(TYPES.VideoRepository)
|
||||
private readonly videoRepository: VideoRepository,
|
||||
@inject(TYPES.LoggerService)
|
||||
private readonly logger: LoggerService
|
||||
) {
|
||||
this.app = new Koa();
|
||||
this.router = new Router();
|
||||
this.baseUrl = `http://${getLocalIP()}:${
|
||||
process.env.APP_SERVER_PORT
|
||||
}/video/`;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.router.get("/api/video-list", this.videoList);
|
||||
this.router.get("/video/:filename", this.serveVideo);
|
||||
const local = this.storeService.get("local");
|
||||
|
||||
this.app.use(cors());
|
||||
this.app.use(range);
|
||||
this.app.use(serve(local));
|
||||
this.app.use(
|
||||
serve(mobilePath, {
|
||||
extensions: ["html", "js", "css"],
|
||||
index: "index.html",
|
||||
})
|
||||
);
|
||||
this.app.use(this.router.routes());
|
||||
this.app.use(this.router.allowedMethods());
|
||||
|
||||
this.app
|
||||
.listen(process.env.APP_SERVER_PORT, () => {
|
||||
this.logger.info("web server init success.");
|
||||
})
|
||||
.on("error", (err: unknown) => {
|
||||
this.logger.error("server error", err);
|
||||
});
|
||||
}
|
||||
|
||||
private videoList = async (ctx: Context) => {
|
||||
const local = this.storeService.get("local");
|
||||
const mp4Files = await glob("*.mp4", {
|
||||
cwd: local,
|
||||
});
|
||||
const videoList = await this.videoRepository.findAllVideos();
|
||||
|
||||
const res = mp4Files
|
||||
.map((file) => {
|
||||
const name = file.split(".").shift();
|
||||
|
||||
const video = videoList.find((video) => video.name === name);
|
||||
if (!video) return null;
|
||||
|
||||
return {
|
||||
id: video.id,
|
||||
name: video.name,
|
||||
url: `${this.baseUrl}${file}`,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
ctx.body = res;
|
||||
};
|
||||
|
||||
private serveVideo = async (ctx: Context) => {
|
||||
const { filename } = ctx.params;
|
||||
const local = this.storeService.get("local");
|
||||
const file = filename;
|
||||
await send(ctx, file, { root: local, index: false });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import { BrowserView, session } from "electron";
|
||||
import { DownloadType } from "../interfaces";
|
||||
import { inject, injectable } from "inversify";
|
||||
import { TYPES } from "../types";
|
||||
import isDev from "electron-is-dev";
|
||||
import {
|
||||
PERSIST_WEBVIEW,
|
||||
mobileUA,
|
||||
pcUA,
|
||||
pluginPath,
|
||||
pluginStylePath,
|
||||
} from "../helper";
|
||||
import { ElectronBlocker } from "@cliqz/adblocker-electron";
|
||||
import fetch from "cross-fetch";
|
||||
import { WebSource } from "../main";
|
||||
import { load } from "cheerio";
|
||||
import LoggerService from "./LoggerService";
|
||||
import StoreService from "./StoreService";
|
||||
import MainWindow from "../windows/MainWindow";
|
||||
import BrowserWindow from "../windows/BrowserWindow";
|
||||
import VideoRepository from "../repository/VideoRepository";
|
||||
import { readFileSync } from "fs-extra";
|
||||
|
||||
interface SourceParams {
|
||||
url: string;
|
||||
requestId: string;
|
||||
headers: Record<string, any>;
|
||||
filter: SourceFilter;
|
||||
documentURL: string;
|
||||
}
|
||||
|
||||
interface SourceFilter {
|
||||
matches: RegExp[];
|
||||
type: DownloadType;
|
||||
handler: (this: WebviewService, params: SourceParams) => Promise<WebSource>;
|
||||
}
|
||||
|
||||
const filterList: SourceFilter[] = [
|
||||
{
|
||||
matches: [/\.m3u8/],
|
||||
type: DownloadType.m3u8,
|
||||
async handler(params) {
|
||||
const { url, headers } = params;
|
||||
const webContents = this.view.webContents;
|
||||
const title = webContents.getTitle();
|
||||
|
||||
return {
|
||||
url,
|
||||
type: DownloadType.m3u8,
|
||||
name: title || "没有获取到名称",
|
||||
headers: JSON.stringify(headers),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
// TODO: 合集、列表、收藏夹
|
||||
matches: [/^https?:\/\/(www\.)?bilibili.com\/video/],
|
||||
type: DownloadType.bilibili,
|
||||
async handler(params) {
|
||||
const { url, requestId } = params;
|
||||
const response = await this.debugger.sendCommand(
|
||||
"Network.getResponseBody",
|
||||
{
|
||||
requestId,
|
||||
},
|
||||
);
|
||||
const $ = load(response.body);
|
||||
const title = $("title").text();
|
||||
|
||||
return {
|
||||
url,
|
||||
type: DownloadType.bilibili,
|
||||
name: title || "没有获取到名称",
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const styleText = readFileSync(pluginStylePath, "utf-8");
|
||||
|
||||
// FIXME: 需要重构
|
||||
@injectable()
|
||||
export default class WebviewService {
|
||||
public view: BrowserView;
|
||||
private blocker?: ElectronBlocker;
|
||||
private pageSources = new Set();
|
||||
requestMap: Record<string, SourceParams> = {};
|
||||
responseMap: Record<string, string[]> = {};
|
||||
|
||||
constructor(
|
||||
@inject(TYPES.MainWindow)
|
||||
private readonly mainWindow: MainWindow,
|
||||
@inject(TYPES.LoggerService)
|
||||
private readonly logger: LoggerService,
|
||||
@inject(TYPES.BrowserWindow)
|
||||
private readonly browserWindow: BrowserWindow,
|
||||
@inject(TYPES.StoreService)
|
||||
private readonly storeService: StoreService,
|
||||
@inject(TYPES.VideoRepository)
|
||||
private readonly videoRepository: VideoRepository,
|
||||
) {
|
||||
// 初始化 blocker
|
||||
this.initBlocker();
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.view = new BrowserView({
|
||||
webPreferences: {
|
||||
partition: PERSIST_WEBVIEW,
|
||||
preload: pluginPath,
|
||||
},
|
||||
});
|
||||
this.view.setBackgroundColor("#fff");
|
||||
this.view.webContents.setAudioMuted(true);
|
||||
|
||||
const { useProxy, proxy, isMobile } = this.storeService.store;
|
||||
this.setProxy(useProxy, proxy);
|
||||
this.setUserAgent(isMobile);
|
||||
|
||||
this.view.webContents.on("dom-ready", async () => {
|
||||
this.pageSources.clear();
|
||||
this.view.webContents.insertCSS(styleText);
|
||||
const title = this.view.webContents.getTitle();
|
||||
const url = this.view.webContents.getURL();
|
||||
this.curWindow?.webContents.send("webview-dom-ready", { title, url });
|
||||
});
|
||||
this.view.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url === "about:blank") {
|
||||
// 兼容一些网站跳转到 about:blank
|
||||
this.view.webContents.once("will-redirect", async (event, url) => {
|
||||
this.loadURL(url, true);
|
||||
});
|
||||
} else {
|
||||
this.loadURL(url, true);
|
||||
}
|
||||
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
try {
|
||||
this.debugger.attach("1.1");
|
||||
} catch (err) {
|
||||
this.logger.error("Debugger attach failed : ", err);
|
||||
}
|
||||
|
||||
this.debugger.on("detach", (event, reason) => {
|
||||
this.logger.error("Debugger detached due to : ", reason);
|
||||
});
|
||||
|
||||
this.debugger.on("message", async (event, method, params) => {
|
||||
if (method === "Network.requestWillBeSent") {
|
||||
const { requestId, documentURL } = params;
|
||||
const { url, headers } = params.request;
|
||||
|
||||
for (const filter of filterList) {
|
||||
for (const match of filter.matches) {
|
||||
if (!match.test(url)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.pageSources.has(url)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.pageSources.add(url);
|
||||
this.responseMap[documentURL] = [];
|
||||
this.requestMap[requestId] = {
|
||||
url,
|
||||
headers,
|
||||
requestId,
|
||||
filter,
|
||||
documentURL,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (method === "Network.loadingFinished") {
|
||||
const { requestId } = params;
|
||||
const sourceParams = this.requestMap[requestId];
|
||||
|
||||
if (sourceParams) {
|
||||
const { filter, url, documentURL } = sourceParams;
|
||||
const objUrl = new URL(url);
|
||||
|
||||
const { body, base64Encoded } = await this.debugger.sendCommand(
|
||||
"Network.getResponseBody",
|
||||
{
|
||||
requestId: params.requestId,
|
||||
},
|
||||
);
|
||||
if (this.responseMap[documentURL].length > 0) {
|
||||
const bodys = this.responseMap[documentURL];
|
||||
const exist = bodys.some((item) =>
|
||||
new RegExp(objUrl.pathname).test(item),
|
||||
);
|
||||
if (exist) return;
|
||||
}
|
||||
if (base64Encoded) {
|
||||
// base64 解码
|
||||
const bodyContent = Buffer.from(body, "base64").toString();
|
||||
this.responseMap[documentURL].push(bodyContent);
|
||||
} else {
|
||||
this.responseMap[documentURL].push(body);
|
||||
}
|
||||
|
||||
this.logger.info(`在窗口中捕获视频链接: ${url}`);
|
||||
// 这里需要判断是否使用浏览器插件
|
||||
const useExtension = this.storeService.get("useExtension");
|
||||
if (useExtension) {
|
||||
const webContents = this.view.webContents;
|
||||
const item = await filter.handler.call(this, sourceParams);
|
||||
webContents.send("webview-link-message", item);
|
||||
} else {
|
||||
const item = await filter.handler.call(this, sourceParams);
|
||||
const video = await this.videoRepository.findVideoByName(item.name);
|
||||
if (video) {
|
||||
item.name = `${item.name}-${Date.now()}`;
|
||||
}
|
||||
const res = await this.videoRepository.addVideo(item);
|
||||
const mainWebContents = this.mainWindow.window?.webContents;
|
||||
if (!mainWebContents) return;
|
||||
// 这里向页面发送消息,通知页面更新
|
||||
mainWebContents.send("download-item-notifier", res);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.debugger.sendCommand("Network.enable");
|
||||
}
|
||||
|
||||
getBounds(): Electron.Rectangle {
|
||||
return this.view.getBounds();
|
||||
}
|
||||
|
||||
setAutoResize(options: Electron.AutoResizeOptions): void {
|
||||
this.view.setAutoResize(options);
|
||||
}
|
||||
|
||||
setBackgroundColor(color: string): void {
|
||||
this.view.setBackgroundColor(color);
|
||||
}
|
||||
|
||||
show() {
|
||||
this.curWindow?.setBrowserView(this.view);
|
||||
isDev && this.view.webContents.openDevTools();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.curWindow?.setBrowserView(null);
|
||||
isDev && this.view.webContents.closeDevTools();
|
||||
}
|
||||
|
||||
setBounds(bounds: Electron.Rectangle): void {
|
||||
if (process.platform === "darwin") {
|
||||
bounds.y = bounds.y + 30;
|
||||
}
|
||||
this.view.setBounds(bounds);
|
||||
}
|
||||
|
||||
async loadURL(url?: string, isNewWindow?: boolean) {
|
||||
const canGoBack = this.view.webContents.canGoBack();
|
||||
try {
|
||||
await this.view.webContents.loadURL(url || "");
|
||||
} catch (err: unknown) {
|
||||
this.logger.error("加载 url 时出现错误: ", err);
|
||||
throw err;
|
||||
} finally {
|
||||
if (!canGoBack && !isNewWindow) {
|
||||
this.view.webContents.clearHistory();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async goBack() {
|
||||
if (this.view.webContents.canGoBack()) {
|
||||
this.view.webContents.goBack();
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async reload() {
|
||||
this.view.webContents.reload();
|
||||
}
|
||||
|
||||
async goHome() {
|
||||
this.view.webContents.clearHistory();
|
||||
}
|
||||
|
||||
get curWindow() {
|
||||
if (this.browserWindow.window) return this.browserWindow.window;
|
||||
if (this.mainWindow.window) return this.mainWindow.window;
|
||||
return null;
|
||||
}
|
||||
|
||||
private get session() {
|
||||
return session.fromPartition(PERSIST_WEBVIEW);
|
||||
}
|
||||
|
||||
private enableProxy(proxy: string) {
|
||||
if (!proxy) {
|
||||
this.logger.error("[proxy] 代理地址不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理 proxy 地址的合法性
|
||||
if (!/https?:\/\//.test(proxy)) {
|
||||
proxy = `http://${proxy}`;
|
||||
}
|
||||
|
||||
this.session.setProxy({ proxyRules: proxy });
|
||||
this.logger.info(`[proxy] 代理开启成功,代理地址为${proxy}`);
|
||||
}
|
||||
|
||||
private disableProxy() {
|
||||
this.session.setProxy({ proxyRules: "" });
|
||||
this.logger.info("[proxy] 代理关闭成功");
|
||||
}
|
||||
|
||||
setProxy(useProxy: boolean, proxy: string): void {
|
||||
if (useProxy) {
|
||||
this.enableProxy(proxy);
|
||||
} else {
|
||||
this.disableProxy();
|
||||
}
|
||||
}
|
||||
|
||||
setBlocking(enableBlocking: boolean): void {
|
||||
if (enableBlocking) {
|
||||
this.enableBlocking();
|
||||
} else {
|
||||
this.disableBlocking();
|
||||
}
|
||||
}
|
||||
|
||||
async initBlocker() {
|
||||
this.blocker = await ElectronBlocker.fromPrebuiltAdsAndTracking(fetch);
|
||||
|
||||
const enableBlocking = this.storeService.get("blockAds");
|
||||
this.setBlocking(enableBlocking);
|
||||
}
|
||||
|
||||
private enableBlocking() {
|
||||
if (!this.blocker) {
|
||||
this.logger.error("开启 blocker 失败,未初始化");
|
||||
return;
|
||||
}
|
||||
this.blocker.enableBlockingInSession(this.session);
|
||||
this.logger.info("开启 blocker 成功");
|
||||
}
|
||||
|
||||
private disableBlocking() {
|
||||
if (!this.blocker) {
|
||||
this.logger.error("关闭 blocker 失败,未初始化");
|
||||
return;
|
||||
}
|
||||
if (!this.blocker.isBlockingEnabled(this.session)) {
|
||||
return;
|
||||
}
|
||||
this.blocker.disableBlockingInSession(this.session);
|
||||
this.logger.info("关闭 blocker 成功");
|
||||
}
|
||||
|
||||
setUserAgent(isMobile?: boolean) {
|
||||
if (isMobile) {
|
||||
this.view.webContents.setUserAgent(mobileUA);
|
||||
} else {
|
||||
this.view.webContents.setUserAgent(pcUA);
|
||||
}
|
||||
this.logger.info("设置 user-agent 成功", isMobile);
|
||||
}
|
||||
|
||||
get debugger() {
|
||||
return this.view.webContents.debugger;
|
||||
}
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
import {
|
||||
BrowserView,
|
||||
CallbackResponse,
|
||||
OnBeforeSendHeadersListenerDetails,
|
||||
session,
|
||||
} from "electron";
|
||||
import {
|
||||
BrowserWindowService,
|
||||
LoggerService,
|
||||
MainWindowService,
|
||||
StoreService,
|
||||
VideoRepository,
|
||||
WebviewService,
|
||||
} from "../interfaces";
|
||||
import { inject, injectable } from "inversify";
|
||||
import { TYPES } from "../types";
|
||||
import isDev from "electron-is-dev";
|
||||
import { PERSIST_WEBVIEW, mobileUA, pcUA } from "helper/variables";
|
||||
import { ElectronBlocker } from "@cliqz/adblocker-electron";
|
||||
import fetch from "cross-fetch";
|
||||
import path from "path";
|
||||
import { WebSource } from "main";
|
||||
|
||||
// FIXME: 需要重构
|
||||
@injectable()
|
||||
export default class WebviewServiceImpl implements WebviewService {
|
||||
public view: BrowserView;
|
||||
private blocker?: ElectronBlocker;
|
||||
private sources = new Set();
|
||||
|
||||
constructor(
|
||||
@inject(TYPES.MainWindowService)
|
||||
private readonly mainWindow: MainWindowService,
|
||||
@inject(TYPES.LoggerService)
|
||||
private readonly logger: LoggerService,
|
||||
@inject(TYPES.BrowserWindowService)
|
||||
private readonly browserWindow: BrowserWindowService,
|
||||
@inject(TYPES.StoreService)
|
||||
private readonly storeService: StoreService,
|
||||
@inject(TYPES.VideoRepository)
|
||||
private readonly videoRepository: VideoRepository
|
||||
) {
|
||||
// 初始化 blocker
|
||||
this.initBlocker();
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.view = new BrowserView({
|
||||
webPreferences: {
|
||||
partition: PERSIST_WEBVIEW,
|
||||
preload: path.resolve(__dirname, "./webview.js"),
|
||||
},
|
||||
});
|
||||
this.view.setBackgroundColor("#fff");
|
||||
this.view.webContents.setAudioMuted(true);
|
||||
|
||||
const { useProxy, proxy, isMobile } = this.storeService.store;
|
||||
this.setProxy(useProxy, proxy);
|
||||
this.setUserAgent(isMobile);
|
||||
|
||||
this.view.webContents.on("dom-ready", () => {
|
||||
this.sources.clear();
|
||||
const title = this.view.webContents.getTitle();
|
||||
const url = this.view.webContents.getURL();
|
||||
this.curWindow?.webContents.send("webview-dom-ready", { title, url });
|
||||
});
|
||||
this.view.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url === "about:blank") {
|
||||
// 兼容一些网站跳转到 about:blank
|
||||
this.view.webContents.once("will-redirect", async (event, url) => {
|
||||
this.loadURL(url, true);
|
||||
});
|
||||
} else {
|
||||
this.loadURL(url, true);
|
||||
}
|
||||
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
const urls = ["<all_urls>"];
|
||||
this.session.webRequest.onBeforeSendHeaders({ urls }, this.before);
|
||||
}
|
||||
|
||||
getBounds(): Electron.Rectangle {
|
||||
return this.view.getBounds();
|
||||
}
|
||||
|
||||
setAutoResize(options: Electron.AutoResizeOptions): void {
|
||||
this.view.setAutoResize(options);
|
||||
}
|
||||
|
||||
setBackgroundColor(color: string): void {
|
||||
this.view.setBackgroundColor(color);
|
||||
}
|
||||
|
||||
show() {
|
||||
this.curWindow?.setBrowserView(this.view);
|
||||
isDev && this.view.webContents.openDevTools();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.curWindow?.setBrowserView(null);
|
||||
isDev && this.view.webContents.closeDevTools();
|
||||
}
|
||||
|
||||
setBounds(bounds: Electron.Rectangle): void {
|
||||
if (process.platform === "darwin") {
|
||||
bounds.y = bounds.y + 30;
|
||||
}
|
||||
this.view.setBounds(bounds);
|
||||
}
|
||||
|
||||
async loadURL(url?: string, isNewWindow?: boolean) {
|
||||
const canGoBack = this.view.webContents.canGoBack();
|
||||
try {
|
||||
await this.view.webContents.loadURL(url || "");
|
||||
} catch (err: unknown) {
|
||||
this.logger.error("加载 url 时出现错误: ", err);
|
||||
throw err;
|
||||
} finally {
|
||||
if (!canGoBack && !isNewWindow) {
|
||||
this.view.webContents.clearHistory();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async goBack() {
|
||||
if (this.view.webContents.canGoBack()) {
|
||||
this.view.webContents.goBack();
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async reload() {
|
||||
this.view.webContents.reload();
|
||||
}
|
||||
|
||||
async goHome() {
|
||||
this.view.webContents.clearHistory();
|
||||
}
|
||||
|
||||
get curWindow() {
|
||||
if (this.browserWindow.window) return this.browserWindow.window;
|
||||
if (this.mainWindow.window) return this.mainWindow.window;
|
||||
return null;
|
||||
}
|
||||
|
||||
private get session() {
|
||||
return session.fromPartition(PERSIST_WEBVIEW);
|
||||
}
|
||||
|
||||
private enableProxy(proxy: string) {
|
||||
if (!proxy) {
|
||||
this.logger.error("[proxy] 代理地址不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理 proxy 地址的合法性
|
||||
if (!/https?:\/\//.test(proxy)) {
|
||||
proxy = `http://${proxy}`;
|
||||
}
|
||||
|
||||
this.session.setProxy({ proxyRules: proxy });
|
||||
this.logger.info(`[proxy] 代理开启成功,代理地址为${proxy}`);
|
||||
}
|
||||
|
||||
private disableProxy() {
|
||||
this.session.setProxy({ proxyRules: "" });
|
||||
this.logger.info("[proxy] 代理关闭成功");
|
||||
}
|
||||
|
||||
setProxy(useProxy: boolean, proxy: string): void {
|
||||
if (useProxy) {
|
||||
this.enableProxy(proxy);
|
||||
} else {
|
||||
this.disableProxy();
|
||||
}
|
||||
}
|
||||
|
||||
setBlocking(enableBlocking: boolean): void {
|
||||
if (enableBlocking) {
|
||||
this.enableBlocking();
|
||||
} else {
|
||||
this.disableBlocking();
|
||||
}
|
||||
}
|
||||
|
||||
async initBlocker() {
|
||||
this.blocker = await ElectronBlocker.fromPrebuiltAdsAndTracking(fetch);
|
||||
|
||||
const enableBlocking = this.storeService.get("blockAds");
|
||||
this.setBlocking(enableBlocking);
|
||||
}
|
||||
|
||||
private enableBlocking() {
|
||||
if (!this.blocker) {
|
||||
this.logger.error("开启 blocker 失败,未初始化");
|
||||
return;
|
||||
}
|
||||
this.blocker.enableBlockingInSession(this.session);
|
||||
this.logger.info("开启 blocker 成功");
|
||||
}
|
||||
|
||||
private disableBlocking() {
|
||||
if (!this.blocker) {
|
||||
this.logger.error("关闭 blocker 失败,未初始化");
|
||||
return;
|
||||
}
|
||||
if (!this.blocker.isBlockingEnabled(this.session)) {
|
||||
return;
|
||||
}
|
||||
this.blocker.disableBlockingInSession(this.session);
|
||||
this.logger.info("关闭 blocker 成功");
|
||||
}
|
||||
|
||||
before = (
|
||||
details: OnBeforeSendHeadersListenerDetails,
|
||||
callback: (response: CallbackResponse) => void
|
||||
): void => {
|
||||
const { url } = details;
|
||||
|
||||
const sourceReg = /\.m3u8$/;
|
||||
const detailsUrl = new URL(url);
|
||||
|
||||
if (sourceReg.test(detailsUrl.pathname)) {
|
||||
try {
|
||||
this.handleM3u8(details);
|
||||
} catch (e) {
|
||||
// empty
|
||||
}
|
||||
}
|
||||
|
||||
callback({});
|
||||
};
|
||||
|
||||
handleM3u8 = async (
|
||||
details: OnBeforeSendHeadersListenerDetails
|
||||
): Promise<void> => {
|
||||
const { id, url } = details;
|
||||
|
||||
this.logger.info(`在窗口中捕获 m3u8 链接: ${url} id: ${id}`);
|
||||
const webContents = details.webContents;
|
||||
|
||||
const source: WebSource = {
|
||||
url,
|
||||
name: webContents?.getTitle() || "没有获取到名称",
|
||||
headers: JSON.stringify(details.requestHeaders),
|
||||
};
|
||||
// 这里需要判断是否使用浏览器插件
|
||||
const useExtension = this.storeService.get("useExtension");
|
||||
|
||||
if (!this.sources.has(source.url)) {
|
||||
this.sources.add(source.url);
|
||||
if (useExtension) {
|
||||
this.view.webContents.send("webview-link-message", source);
|
||||
} else {
|
||||
const item = await this.videoRepository.addVideo(source);
|
||||
// 这里向页面发送消息,通知页面更新
|
||||
this.mainWindow.window?.webContents.send(
|
||||
"download-item-notifier",
|
||||
item
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setUserAgent(isMobile?: boolean) {
|
||||
if (isMobile) {
|
||||
this.view.webContents.setUserAgent(mobileUA);
|
||||
} else {
|
||||
this.view.webContents.setUserAgent(pcUA);
|
||||
}
|
||||
this.logger.info("设置 user-agent 成功", isMobile);
|
||||
}
|
||||
}
|
||||
+15
-12
@@ -1,22 +1,25 @@
|
||||
export const TYPES = {
|
||||
App: Symbol.for("App"),
|
||||
MainWindowService: Symbol.for("MainWindowService"),
|
||||
ElectronApp: Symbol.for("ElectronApp"),
|
||||
IpcHandlerService: Symbol.for("IpcHandlerService"),
|
||||
Controller: Symbol.for("Controller"),
|
||||
// repository
|
||||
VideoRepository: Symbol.for("VideoRepository"),
|
||||
FavoriteRepository: Symbol.for("FavoriteRepository"),
|
||||
// windows
|
||||
BrowserWindow: Symbol.for("BrowserWindow"),
|
||||
PlayerWindow: Symbol.for("PlayerWindow"),
|
||||
MainWindow: Symbol.for("MainWindow"),
|
||||
// services
|
||||
WebviewService: Symbol.for("WebviewService"),
|
||||
DownloadService: Symbol.for("DownloadService"),
|
||||
DevToolsService: Symbol.for("DevToolsService"),
|
||||
VideoService: Symbol.for("VideoService"),
|
||||
StoreService: Symbol.for("StoreService"),
|
||||
SessionService: Symbol.for("SessionService"),
|
||||
DatabaseService: Symbol.for("DatabaseService"),
|
||||
ProtocolService: Symbol.for("ProtocolService"),
|
||||
UpdateService: Symbol.for("UpdateService"),
|
||||
LoggerService: Symbol.for("LoggerService"),
|
||||
IpcHandlerService: Symbol.for("IpcHandlerService"),
|
||||
Controller: Symbol.for("Controller"),
|
||||
VideoRepository: Symbol.for("VideoRepository"),
|
||||
FavoriteRepository: Symbol.for("FavoriteRepository"),
|
||||
WebviewService: Symbol.for("WebviewService"),
|
||||
DownloadService: Symbol.for("DownloadService"),
|
||||
BrowserWindowService: Symbol.for("BrowserWindowService"),
|
||||
DevToolsService: Symbol.for("DevToolsService"),
|
||||
WebService: Symbol.for("WebService"),
|
||||
PlayerWindowService: Symbol.for("PlayerWindowService"),
|
||||
};
|
||||
|
||||
export enum AppTheme {
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { WebSource } from "main";
|
||||
import { IpcRendererEvent, ipcRenderer } from "electron/renderer";
|
||||
|
||||
// 创建浮窗容器元素
|
||||
const floatingContainer = document.createElement("div");
|
||||
floatingContainer.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 200px;
|
||||
z-index: 10000;
|
||||
display: none;
|
||||
font-size: 16px;
|
||||
`;
|
||||
|
||||
// 创建浮窗按钮元素
|
||||
const floatingButton = document.createElement("div");
|
||||
floatingButton.textContent = "检测到视频资源……";
|
||||
floatingButton.style.cssText = `
|
||||
background-color: #000;
|
||||
color: #fff;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
`;
|
||||
|
||||
// 创建浮窗列表元素
|
||||
const floatingList = document.createElement("ul");
|
||||
floatingList.style.cssText = `
|
||||
display: none;
|
||||
background-color: #000;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const itemStyle = `
|
||||
padding: 10px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const items: WebSource[] = [];
|
||||
ipcRenderer.on(
|
||||
"webview-link-message",
|
||||
(e: IpcRendererEvent, data: WebSource) => {
|
||||
floatingContainer.style.display = "block";
|
||||
|
||||
items.push(data);
|
||||
|
||||
// 清空列表
|
||||
while (floatingList?.firstChild) {
|
||||
floatingList.removeChild(floatingList.firstChild);
|
||||
}
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
// 创建列表项元素
|
||||
const elItem = document.createElement("li");
|
||||
elItem.textContent = item.name;
|
||||
elItem.title = item.name;
|
||||
elItem.style.cssText = itemStyle;
|
||||
elItem.addEventListener("click", () => {
|
||||
ipcRenderer.invoke("add-download-item", {
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
});
|
||||
});
|
||||
elItem.addEventListener("mouseenter", () => {
|
||||
elItem.style.backgroundColor = "#fff";
|
||||
elItem.style.color = "#000";
|
||||
});
|
||||
elItem.addEventListener("mouseleave", () => {
|
||||
elItem.style.backgroundColor = "#000";
|
||||
elItem.style.color = "#fff";
|
||||
});
|
||||
|
||||
// 将列表项添加到浮窗列表
|
||||
floatingList.appendChild(elItem);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 将按钮和列表添加到浮窗容器
|
||||
floatingContainer.appendChild(floatingList);
|
||||
floatingContainer.appendChild(floatingButton);
|
||||
|
||||
// 鼠标进入浮窗按钮时的处理函数
|
||||
floatingContainer.addEventListener("mouseenter", () => {
|
||||
floatingButton.style.opacity = "1";
|
||||
floatingList.style.display = "block";
|
||||
});
|
||||
|
||||
// 鼠标离开浮窗按钮时的处理函数
|
||||
floatingContainer.addEventListener("mouseleave", () => {
|
||||
floatingButton.style.opacity = "0.8";
|
||||
floatingList.style.display = "none";
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// 将浮窗容器添加到body元素
|
||||
document.body.appendChild(floatingContainer);
|
||||
});
|
||||
@@ -2,22 +2,19 @@ import isDev from "electron-is-dev";
|
||||
import { inject, injectable } from "inversify";
|
||||
import { resolve } from "path";
|
||||
import { TYPES } from "../types";
|
||||
import { BrowserWindowService, StoreService } from "../interfaces";
|
||||
import _ from "lodash";
|
||||
import Window from "./window";
|
||||
import StoreService from "../services/StoreService";
|
||||
|
||||
@injectable()
|
||||
export default class BrowserWindowServiceImpl
|
||||
extends Window
|
||||
implements BrowserWindowService
|
||||
{
|
||||
export default class BrowserWindow extends Window {
|
||||
url = isDev
|
||||
? "http://localhost:8555/browser"
|
||||
: "mediago://index.html/browser";
|
||||
|
||||
constructor(
|
||||
@inject(TYPES.StoreService)
|
||||
private readonly storeService: StoreService
|
||||
private readonly storeService: StoreService,
|
||||
) {
|
||||
super({
|
||||
width: 1100,
|
||||
@@ -3,23 +3,16 @@ import isDev from "electron-is-dev";
|
||||
import { inject, injectable } from "inversify";
|
||||
import { resolve } from "path";
|
||||
import { TYPES } from "../types";
|
||||
import {
|
||||
DownloadProgress,
|
||||
DownloadService,
|
||||
LoggerService,
|
||||
MainWindowService,
|
||||
StoreService,
|
||||
VideoRepository,
|
||||
} from "../interfaces";
|
||||
import { event } from "helper/utils";
|
||||
import { DownloadProgress } from "../interfaces";
|
||||
import _ from "lodash";
|
||||
import Window from "./window";
|
||||
import LoggerService from "../services/LoggerService";
|
||||
import DownloadService from "../services/DownloadService";
|
||||
import StoreService from "../services/StoreService";
|
||||
import VideoRepository from "../repository/VideoRepository";
|
||||
|
||||
@injectable()
|
||||
export default class MainWindowServiceImpl
|
||||
extends Window
|
||||
implements MainWindowService
|
||||
{
|
||||
export default class MainWindow extends Window {
|
||||
url = isDev ? "http://localhost:8555/" : "mediago://index.html/";
|
||||
constructor(
|
||||
@inject(TYPES.LoggerService)
|
||||
@@ -29,7 +22,7 @@ export default class MainWindowServiceImpl
|
||||
@inject(TYPES.VideoRepository)
|
||||
private readonly videoRepository: VideoRepository,
|
||||
@inject(TYPES.StoreService)
|
||||
private readonly storeService: StoreService
|
||||
private readonly storeService: StoreService,
|
||||
) {
|
||||
super({
|
||||
width: 1100,
|
||||
@@ -1,21 +1,13 @@
|
||||
import isDev from "electron-is-dev";
|
||||
import { inject, injectable } from "inversify";
|
||||
import { injectable } from "inversify";
|
||||
import { resolve } from "path";
|
||||
import { PlayerWindowService, VideoRepository } from "../interfaces";
|
||||
import { TYPES } from "types";
|
||||
import Window from "./window";
|
||||
|
||||
@injectable()
|
||||
export default class PlayerWindowServiceImpl
|
||||
extends Window
|
||||
implements PlayerWindowService
|
||||
{
|
||||
export default class PlayerWindowServiceImpl extends Window {
|
||||
url = isDev ? "http://localhost:8555/player" : "mediago://index.html/player";
|
||||
|
||||
constructor(
|
||||
@inject(TYPES.VideoRepository)
|
||||
private readonly videoRepository: VideoRepository
|
||||
) {
|
||||
constructor() {
|
||||
super({
|
||||
width: 1100,
|
||||
minWidth: 1100,
|
||||
@@ -29,16 +21,15 @@ export default class PlayerWindowServiceImpl
|
||||
});
|
||||
}
|
||||
|
||||
openWindow = async (id: number) => {
|
||||
openWindow = async (name: string) => {
|
||||
if (!this.window) {
|
||||
const url = new URL(this.url);
|
||||
url.searchParams.set("name", encodeURIComponent(name));
|
||||
this.url = url.toString();
|
||||
this.window = this.create();
|
||||
}
|
||||
|
||||
const video = await this.videoRepository.findVideo(id);
|
||||
if (!video) throw new Error("video not found");
|
||||
|
||||
isDev && this.window.webContents.openDevTools();
|
||||
this.window.webContents.send("open-player-window", video.id);
|
||||
this.window.show();
|
||||
};
|
||||
}
|
||||
@@ -6,14 +6,20 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": "./src",
|
||||
"resolveJsonModule": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"moduleResolution": "Node",
|
||||
"strictPropertyInitialization": false,
|
||||
"typeRoots": ["node_modules/@types"],
|
||||
"declaration": true,
|
||||
"declarationDir": "./types",
|
||||
"outDir": "app/output/build/main"
|
||||
},
|
||||
"include": ["./src/**/*"]
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"composite": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ES2015",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["script", "app/package.json"]
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>media download</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"jsdom": "^22.1.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^2.8.8",
|
||||
"typescript": "~5.1.3",
|
||||
"typescript": "~5.3.3",
|
||||
"unplugin-vue-components": "^0.25.1",
|
||||
"vite": "^4.3.9",
|
||||
"vitest": "^0.32.2",
|
||||
|
||||
@@ -19,5 +19,6 @@ module.exports = {
|
||||
plugins: ["react", "@typescript-eslint", "prettier"],
|
||||
rules: {
|
||||
"prettier/prettier": "warn",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name=referrer content=no-referrer>
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<title>media downloader</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -10,43 +10,44 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.1.4",
|
||||
"@ant-design/pro-components": "^2.6.2",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"ahooks": "^3.7.7",
|
||||
"antd": "^5.6.2",
|
||||
"axios": "^1.4.0",
|
||||
"classnames": "^2.3.2",
|
||||
"dayjs": "^1.11.8",
|
||||
"match-sorter": "^6.3.1",
|
||||
"nanoid": "^4.0.2",
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@ant-design/pro-components": "^2.6.48",
|
||||
"@reduxjs/toolkit": "^2.0.1",
|
||||
"ahooks": "^3.7.8",
|
||||
"antd": "^5.13.2",
|
||||
"axios": "^1.6.5",
|
||||
"classnames": "^2.5.1",
|
||||
"dayjs": "^1.11.10",
|
||||
"match-sorter": "^6.3.3",
|
||||
"nanoid": "^5.0.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^8.1.1",
|
||||
"react-router-dom": "^6.13.0",
|
||||
"react-redux": "^9.1.0",
|
||||
"react-router-dom": "^6.21.3",
|
||||
"sort-by": "^1.2.0",
|
||||
"xgplayer": "^3.0.4"
|
||||
"xgplayer": "^3.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/sort-by": "^1.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.60.0",
|
||||
"@typescript-eslint/parser": "5.60.0",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"eslint": "^8.43.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-config-standard-with-typescript": "^35.0.0",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-n": "^15.0.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"@types/node": "^20.11.5",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/react-redux": "^7.1.33",
|
||||
"@types/sort-by": "^1.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "6.19.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-standard-with-typescript": "^43.0.1",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-n": "^16.6.2",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-promise": "^6.1.1",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"prettier": "2.8.8",
|
||||
"sass": "^1.63.6",
|
||||
"typescript": "~5.1.3",
|
||||
"vite": "^4.3.9"
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"prettier": "3.2.4",
|
||||
"sass": "^1.70.0",
|
||||
"typescript": "~5.3.3",
|
||||
"vite": "^5.0.12"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
.container {
|
||||
html,
|
||||
body {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
.container-sider {
|
||||
.like-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
@mixin scrollbar() {
|
||||
&::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
.container-footer {
|
||||
padding: 5px;
|
||||
background: #fff;
|
||||
text-align: right;
|
||||
border-top: #f0f0f0 solid 1px;
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.container-footer {
|
||||
background: #141414;
|
||||
border-top: #313131 solid 1px;
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
@include scrollbar();
|
||||
}
|
||||
|
||||
+77
-128
@@ -1,144 +1,93 @@
|
||||
import React, { FC, useState } from "react";
|
||||
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Badge, Button, Layout, Menu, MenuProps } from "antd";
|
||||
import { ConfigProvider, theme } from "antd";
|
||||
import "antd/dist/reset.css";
|
||||
import React, { FC, useEffect } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import AppLayout from "./layout/App";
|
||||
import HomePage, { DownloadFilter } from "./nodes/HomePage";
|
||||
import SettingPage from "./nodes/SettingPage";
|
||||
import PlayerPage from "./nodes/PlayerPage";
|
||||
import SourceExtract from "./nodes/SourceExtract";
|
||||
import { setAppStore, increase } from "./store";
|
||||
import "dayjs/locale/zh-cn";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import "./App.scss";
|
||||
import {
|
||||
DownloadOutlined,
|
||||
ExportOutlined,
|
||||
ProfileOutlined,
|
||||
QuestionCircleOutlined,
|
||||
SettingOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import useElectron from "./hooks/electron";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { selectAppStore, setAppStore, clearCount, selectCount } from "./store";
|
||||
import { useAsyncEffect } from "ahooks";
|
||||
import { tdApp } from "./utils";
|
||||
|
||||
const { Footer, Sider, Content } = Layout;
|
||||
|
||||
type MenuItem = Required<MenuProps>["items"][number];
|
||||
function getAlgorithm(appTheme: "dark" | "light") {
|
||||
return appTheme === "dark" ? theme.darkAlgorithm : theme.defaultAlgorithm;
|
||||
}
|
||||
|
||||
const App: FC = () => {
|
||||
const {
|
||||
getAppStore: ipcGetAppStore,
|
||||
openUrl,
|
||||
setAppStore: ipcSetAppStore,
|
||||
showBrowserWindow,
|
||||
} = useElectron();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const [showExport, setShowExport] = useState(false);
|
||||
const count = useSelector(selectCount);
|
||||
const appStore = useSelector(selectAppStore);
|
||||
const [appTheme, setAppTheme] = React.useState<"dark" | "light">("light");
|
||||
const { addIpcListener, removeIpcListener } = useElectron();
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
label: (
|
||||
<Link
|
||||
to="/"
|
||||
className="like-item"
|
||||
onClick={() => {
|
||||
dispatch(clearCount());
|
||||
}}
|
||||
>
|
||||
<DownloadOutlined />
|
||||
<span>下载列表</span>
|
||||
{count > 0 && (
|
||||
<Badge count={count} offset={[5, 1]} size="small"></Badge>
|
||||
)}
|
||||
</Link>
|
||||
),
|
||||
key: "home",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Link to="/source-extract" className="like-item">
|
||||
<ProfileOutlined />
|
||||
<span>素材提取</span>
|
||||
{showExport && (
|
||||
<Button
|
||||
title="在新窗口中打开"
|
||||
type="text"
|
||||
style={{ marginLeft: "auto" }}
|
||||
icon={<ExportOutlined />}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
dispatch(setAppStore({ openInNewWindow: true }));
|
||||
if (location.pathname === "/source-extract") {
|
||||
navigate("/");
|
||||
}
|
||||
// FIXME: 有可能 webview 还没有完全隐藏
|
||||
await ipcSetAppStore("openInNewWindow", true);
|
||||
await showBrowserWindow();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
),
|
||||
key: "source",
|
||||
onMouseEnter: () => {
|
||||
setShowExport(true);
|
||||
},
|
||||
onMouseLeave: () => {
|
||||
setShowExport(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Link to="/settings" className="like-item">
|
||||
<SettingOutlined />
|
||||
<span>设置</span>
|
||||
</Link>
|
||||
),
|
||||
key: "settings",
|
||||
},
|
||||
];
|
||||
|
||||
const finalItems = items.filter((item) =>
|
||||
appStore.openInNewWindow ? item?.key !== "source" : true
|
||||
);
|
||||
|
||||
const openHelpUrl = () => {
|
||||
tdApp.openHelpPage();
|
||||
const url = "https://downloader.caorushizi.cn/guides.html?form=client";
|
||||
openUrl(url);
|
||||
const themeChange = (event: MediaQueryListEvent) => {
|
||||
if (event.matches) {
|
||||
setAppTheme("dark");
|
||||
} else {
|
||||
setAppTheme("light");
|
||||
}
|
||||
};
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
const store = await ipcGetAppStore();
|
||||
// 监听store变化
|
||||
const onAppStoreChange = (event: any, store: AppStore) => {
|
||||
dispatch(setAppStore(store));
|
||||
};
|
||||
|
||||
const onReceiveDownloadItem = () => {
|
||||
dispatch(increase());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
addIpcListener("store-change", onAppStoreChange);
|
||||
addIpcListener("download-item-notifier", onReceiveDownloadItem);
|
||||
|
||||
return () => {
|
||||
removeIpcListener("store-change", onAppStoreChange);
|
||||
removeIpcListener("download-item-notifier", onReceiveDownloadItem);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const isDarkTheme = matchMedia("(prefers-color-scheme: dark)");
|
||||
isDarkTheme.addEventListener("change", themeChange);
|
||||
|
||||
if (isDarkTheme.matches) {
|
||||
setAppTheme("dark");
|
||||
} else {
|
||||
setAppTheme("light");
|
||||
}
|
||||
|
||||
return () => {
|
||||
isDarkTheme.removeEventListener("change", themeChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout className="container">
|
||||
<Sider className="container-sider" theme="light">
|
||||
<Menu
|
||||
style={{ height: "100%" }}
|
||||
defaultSelectedKeys={["home"]}
|
||||
mode="vertical"
|
||||
theme="light"
|
||||
items={finalItems}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Content className="container-inner">
|
||||
<Outlet />
|
||||
</Content>
|
||||
<Footer className="container-footer">
|
||||
<Button
|
||||
type={"link"}
|
||||
onClick={openHelpUrl}
|
||||
icon={<QuestionCircleOutlined />}
|
||||
>
|
||||
使用帮助
|
||||
</Button>
|
||||
</Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
componentSize="small"
|
||||
theme={{ algorithm: getAlgorithm(appTheme) }}
|
||||
>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route
|
||||
path="done"
|
||||
element={<HomePage filter={DownloadFilter.done} />}
|
||||
/>
|
||||
<Route path="source" element={<SourceExtract />} />
|
||||
<Route path="settings" element={<SettingPage />} />
|
||||
<Route path="*" element={<div>404</div>} />
|
||||
</Route>
|
||||
<Route path="/browser" element={<SourceExtract page={true} />} />
|
||||
<Route path="/player" element={<PlayerPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
ModalForm,
|
||||
ProFormSwitch,
|
||||
ProFormText,
|
||||
ProFormTextArea,
|
||||
} from "@ant-design/pro-components";
|
||||
import { Form } from "antd";
|
||||
import React, { FC } from "react";
|
||||
import "./index.scss";
|
||||
|
||||
export interface DownloadFormProps {
|
||||
onFinish: (values: DownloadItem) => Promise<boolean | void>;
|
||||
trigger: JSX.Element;
|
||||
isEdit?: boolean;
|
||||
item?: DownloadItem;
|
||||
}
|
||||
|
||||
const preProcessHeaders = (value: string | null) => {
|
||||
if (!value) return;
|
||||
|
||||
let headers: Record<string, any> = {};
|
||||
try {
|
||||
headers = JSON.parse(value);
|
||||
} catch (err) {
|
||||
// empty
|
||||
}
|
||||
return Object.entries(headers)
|
||||
.map(([key, value]) => {
|
||||
return `${key}: ${value}`;
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
const postProcessHeaders = (value: string | null) => {
|
||||
if (!value) return "";
|
||||
|
||||
const headers = value
|
||||
.split("\n")
|
||||
.reduce<Record<string, any>>((prev, curr) => {
|
||||
const [key, value] = curr.split(":");
|
||||
if (key) {
|
||||
prev[key.trim()] = value ? value.trim() : "";
|
||||
}
|
||||
return prev;
|
||||
}, {});
|
||||
|
||||
return JSON.stringify(headers);
|
||||
};
|
||||
|
||||
const DownloadFrom: FC<DownloadFormProps> = ({
|
||||
onFinish,
|
||||
trigger,
|
||||
isEdit,
|
||||
item,
|
||||
}) => {
|
||||
const [form] = Form.useForm<DownloadItem>();
|
||||
|
||||
return (
|
||||
<ModalForm<DownloadItem>
|
||||
key={isEdit ? "edit" : "new"}
|
||||
title={isEdit ? "编辑下载" : "新建下载"}
|
||||
width={500}
|
||||
trigger={trigger}
|
||||
form={form}
|
||||
autoFocusFirstInput
|
||||
onOpenChange={() => {
|
||||
if (item) {
|
||||
// 处理 headers 字段
|
||||
const headers = preProcessHeaders(item.headers);
|
||||
|
||||
form.setFieldsValue({
|
||||
...item,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
}}
|
||||
modalProps={{
|
||||
destroyOnClose: true,
|
||||
styles: {
|
||||
body: {
|
||||
paddingTop: 5,
|
||||
},
|
||||
},
|
||||
}}
|
||||
submitTimeout={2000}
|
||||
onFinish={(values) => {
|
||||
// 处理 headers 字段
|
||||
const headers = postProcessHeaders(values.headers);
|
||||
|
||||
return onFinish({ ...values, headers });
|
||||
}}
|
||||
labelCol={{ span: 4 }}
|
||||
layout="horizontal"
|
||||
colon={false}
|
||||
>
|
||||
{!isEdit && <ProFormSwitch label="批量下载" name={"batch"} />}
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{(form) => {
|
||||
if (!isEdit && form.getFieldValue("batch")) {
|
||||
return (
|
||||
<ProFormTextArea
|
||||
name="batchList"
|
||||
fieldProps={{
|
||||
rows: 6,
|
||||
}}
|
||||
label="视频链接"
|
||||
placeholder={`请输入视频链接, 确保一行一个,格式: 视频链接 视频名称(非必填), 例如
|
||||
https://www.bilibili.com/video/XXX 哔哩哔哩视频
|
||||
http://example.com/xxx.m3u8 m3u8视频
|
||||
http://example.com/xxx.m3u8`}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "请输入站点视频链接",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<ProFormText
|
||||
name="url"
|
||||
label="视频链接"
|
||||
placeholder="请输入在线网络视频URL,或者将M3U8拖拽至此"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "请输入在线网络视频URL",
|
||||
},
|
||||
{
|
||||
pattern: /^(file|https?):\/\/.+/,
|
||||
message: "请输入正确的视频链接",
|
||||
},
|
||||
]}
|
||||
fieldProps={{
|
||||
onDrop: (e) => {
|
||||
const file: any = e.dataTransfer.files[0];
|
||||
form.setFieldValue("url", `file://${file.path}`);
|
||||
form.validateFields(["url"]);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{(form) => {
|
||||
if (isEdit || !form.getFieldValue("batch")) {
|
||||
return (
|
||||
<ProFormText
|
||||
name="name"
|
||||
label="视频名称"
|
||||
placeholder="请输入视频名称,默认以当前时间作为视频名称"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{(form) => {
|
||||
if (isEdit || !form.getFieldValue("batch")) {
|
||||
return (
|
||||
<ProFormText
|
||||
name="key"
|
||||
label="私有KEY"
|
||||
placeholder="KEY和IV值(HEX格式)"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}}
|
||||
</Form.Item>
|
||||
<ProFormTextArea
|
||||
name="headers"
|
||||
label="附加标头"
|
||||
fieldProps={{
|
||||
rows: 4,
|
||||
}}
|
||||
placeholder={`请输入附加标头,确保一行一个 Header, 例如:
|
||||
Origin: http://www.example.com
|
||||
Referer: http://www.example.com`}
|
||||
/>
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadFrom;
|
||||
@@ -1,22 +1,14 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { ElectronApi } from "../../../main/types/preload";
|
||||
|
||||
const eventFun = ["rendererEvent", "removeEventListener"];
|
||||
const eventMap = new Map();
|
||||
|
||||
const electronApi = Object.keys(window.electron).reduce<any>((res, funName) => {
|
||||
const api = Object.keys(window.electron).reduce<any>((res, funName) => {
|
||||
const fun = async (...args: any[]) => {
|
||||
const electronFun = (window.electron as any)[funName];
|
||||
if (eventFun.includes(funName)) {
|
||||
const [eventName, func = {}] = args;
|
||||
let id = "";
|
||||
if (eventMap.get(func)) {
|
||||
id = eventMap.get(func);
|
||||
} else {
|
||||
id = nanoid();
|
||||
eventMap.set(func, id);
|
||||
}
|
||||
|
||||
return electronFun(eventName, id, func);
|
||||
const electronFun: any = window.electron[funName];
|
||||
if (eventFun.includes(String(funName))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { code, data, message } = await electronFun(...args);
|
||||
@@ -29,6 +21,32 @@ const electronApi = Object.keys(window.electron).reduce<any>((res, funName) => {
|
||||
return res;
|
||||
}, {});
|
||||
|
||||
export default function useElectron(): ElectronAPI {
|
||||
return electronApi;
|
||||
interface IpcListener {
|
||||
addIpcListener: (eventName: string, func: any) => void;
|
||||
removeIpcListener: (eventName: string, func: any) => void;
|
||||
}
|
||||
|
||||
const getIpcId = (func: any) => {
|
||||
let id = "";
|
||||
if (eventMap.get(func)) {
|
||||
id = eventMap.get(func);
|
||||
} else {
|
||||
id = nanoid();
|
||||
eventMap.set(func, id);
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
export default function useElectron(): ElectronApi & IpcListener {
|
||||
return {
|
||||
...api,
|
||||
addIpcListener: (eventName: string, func: any) => {
|
||||
const id = getIpcId(func);
|
||||
window.electron.rendererEvent(eventName, id, func);
|
||||
},
|
||||
removeIpcListener: (eventName: string, func: any) => {
|
||||
const id = getIpcId(func);
|
||||
window.electron.removeEventListener(eventName, id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
html,
|
||||
body {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
@mixin scrollbar() {
|
||||
&::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
@include scrollbar();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.container {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
.container-sider {
|
||||
.like-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
.container-footer {
|
||||
padding: 5px;
|
||||
background: #fff;
|
||||
text-align: right;
|
||||
border-top: #f0f0f0 solid 1px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.container-footer {
|
||||
background: #141414;
|
||||
border-top: #313131 solid 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { FC, useState } from "react";
|
||||
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Badge, Button, Layout, Menu, MenuProps } from "antd";
|
||||
import "./index.scss";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
DownloadOutlined,
|
||||
ExportOutlined,
|
||||
ProfileOutlined,
|
||||
QuestionCircleOutlined,
|
||||
SettingOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import useElectron from "../../hooks/electron";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import {
|
||||
selectAppStore,
|
||||
setAppStore,
|
||||
clearCount,
|
||||
selectCount,
|
||||
} from "../../store";
|
||||
import { useAsyncEffect } from "ahooks";
|
||||
|
||||
const { Footer, Sider, Content } = Layout;
|
||||
|
||||
type MenuItem = Required<MenuProps>["items"][number];
|
||||
|
||||
function processLocation(pathname: string) {
|
||||
let name = pathname;
|
||||
if (pathname === "/") {
|
||||
name = "/home";
|
||||
}
|
||||
return name.substring(1);
|
||||
}
|
||||
|
||||
const App: FC = () => {
|
||||
const {
|
||||
getAppStore: ipcGetAppStore,
|
||||
openUrl,
|
||||
setAppStore: ipcSetAppStore,
|
||||
showBrowserWindow,
|
||||
} = useElectron();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const [showExport, setShowExport] = useState(false);
|
||||
const count = useSelector(selectCount);
|
||||
const appStore = useSelector(selectAppStore);
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
label: (
|
||||
<Link
|
||||
to="/"
|
||||
className="like-item"
|
||||
onClick={() => {
|
||||
dispatch(clearCount());
|
||||
}}
|
||||
>
|
||||
<DownloadOutlined />
|
||||
<span>下载列表</span>
|
||||
{count > 0 && (
|
||||
<Badge count={count} offset={[5, 1]} size="small"></Badge>
|
||||
)}
|
||||
</Link>
|
||||
),
|
||||
key: "home",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Link to="/done" className="like-item">
|
||||
<CheckCircleOutlined />
|
||||
<span>下载完成</span>
|
||||
</Link>
|
||||
),
|
||||
key: "done",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Link to="/source" className="like-item">
|
||||
<ProfileOutlined />
|
||||
<span>素材提取</span>
|
||||
{showExport && (
|
||||
<Button
|
||||
title="在新窗口中打开"
|
||||
type="text"
|
||||
style={{ marginLeft: "auto" }}
|
||||
icon={<ExportOutlined />}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
dispatch(setAppStore({ openInNewWindow: true }));
|
||||
if (location.pathname === "/source") {
|
||||
navigate("/");
|
||||
}
|
||||
// FIXME: 有可能 webview 还没有完全隐藏
|
||||
await ipcSetAppStore("openInNewWindow", true);
|
||||
await showBrowserWindow();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
),
|
||||
key: "source",
|
||||
onMouseEnter: () => {
|
||||
setShowExport(true);
|
||||
},
|
||||
onMouseLeave: () => {
|
||||
setShowExport(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Link to="/settings" className="like-item">
|
||||
<SettingOutlined />
|
||||
<span>设置</span>
|
||||
</Link>
|
||||
),
|
||||
key: "settings",
|
||||
},
|
||||
];
|
||||
|
||||
const finalItems = items.filter((item) =>
|
||||
appStore.openInNewWindow ? item?.key !== "source" : true,
|
||||
);
|
||||
|
||||
const openHelpUrl = () => {
|
||||
const url = "https://downloader.caorushizi.cn/guides.html?form=client";
|
||||
openUrl(url);
|
||||
};
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
const store = await ipcGetAppStore();
|
||||
dispatch(setAppStore(store));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout className="container">
|
||||
<Sider className="container-sider" theme="light">
|
||||
<Menu
|
||||
style={{ height: "100%" }}
|
||||
defaultSelectedKeys={[processLocation(location.pathname)]}
|
||||
mode="vertical"
|
||||
theme="light"
|
||||
items={finalItems}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Content className="container-inner">
|
||||
<Outlet />
|
||||
</Content>
|
||||
<Footer className="container-footer">
|
||||
<Button
|
||||
type={"link"}
|
||||
onClick={openHelpUrl}
|
||||
icon={<QuestionCircleOutlined />}
|
||||
>
|
||||
使用帮助
|
||||
</Button>
|
||||
</Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -1,102 +1,21 @@
|
||||
import { ConfigProvider, theme } from "antd";
|
||||
import "antd/dist/reset.css";
|
||||
import React, { FC, StrictMode, useEffect } from "react";
|
||||
import React, { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Provider, useDispatch } from "react-redux";
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import HomePage from "./nodes/HomePage";
|
||||
import SettingPage from "./nodes/SettingPage";
|
||||
import PlayerPage from "./nodes/PlayerPage";
|
||||
import SourceExtract from "./nodes/SourceExtract";
|
||||
import store, { setAppStore, increase } from "./store";
|
||||
import { Provider } from "react-redux";
|
||||
import store from "./store";
|
||||
import dayjs from "dayjs";
|
||||
import "dayjs/locale/zh-cn";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import "./index.scss";
|
||||
import { tdApp } from "./utils";
|
||||
import useElectron from "./hooks/electron";
|
||||
import { tdApp, initClarity } from "./utils";
|
||||
import App from "./App";
|
||||
|
||||
dayjs.locale("zh-cn");
|
||||
tdApp.init();
|
||||
|
||||
function getAlgorithm(appTheme: "dark" | "light") {
|
||||
return appTheme === "dark" ? theme.darkAlgorithm : theme.defaultAlgorithm;
|
||||
}
|
||||
|
||||
const Root: FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const [appTheme, setAppTheme] = React.useState<"dark" | "light">("light");
|
||||
const { rendererEvent, removeEventListener } = useElectron();
|
||||
|
||||
const themeChange = (event: MediaQueryListEvent) => {
|
||||
if (event.matches) {
|
||||
setAppTheme("dark");
|
||||
} else {
|
||||
setAppTheme("light");
|
||||
}
|
||||
};
|
||||
|
||||
// 监听store变化
|
||||
const onAppStoreChange = (event: any, store: AppStore) => {
|
||||
dispatch(setAppStore(store));
|
||||
};
|
||||
|
||||
const onReceiveDownloadItem = () => {
|
||||
dispatch(increase());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
rendererEvent("store-change", onAppStoreChange);
|
||||
rendererEvent("download-item-notifier", onReceiveDownloadItem);
|
||||
|
||||
return () => {
|
||||
removeEventListener("store-change", onAppStoreChange);
|
||||
removeEventListener("download-item-notifier", onReceiveDownloadItem);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const isDarkTheme = matchMedia("(prefers-color-scheme: dark)");
|
||||
isDarkTheme.addEventListener("change", themeChange);
|
||||
|
||||
if (isDarkTheme.matches) {
|
||||
setAppTheme("dark");
|
||||
} else {
|
||||
setAppTheme("light");
|
||||
}
|
||||
|
||||
return () => {
|
||||
isDarkTheme.removeEventListener("change", themeChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
componentSize="small"
|
||||
theme={{ algorithm: getAlgorithm(appTheme) }}
|
||||
>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<App />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="source-extract" element={<SourceExtract />} />
|
||||
<Route path="settings" element={<SettingPage />} />
|
||||
<Route path="*" element={<div>404</div>} />
|
||||
</Route>
|
||||
<Route path="/browser" element={<SourceExtract page={true} />} />
|
||||
<Route path="/player" element={<PlayerPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
initClarity();
|
||||
|
||||
createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<StrictMode>
|
||||
<Provider store={store}>
|
||||
<Root />
|
||||
<App />
|
||||
</Provider>
|
||||
</StrictMode>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,23 +1,51 @@
|
||||
.home-page {
|
||||
.home-page-inner {
|
||||
padding-bottom: 10px;
|
||||
.ant-pro-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
.description {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.download-list {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
}
|
||||
.download-progress {
|
||||
.ant-progress {
|
||||
align-items: center;
|
||||
margin-bottom: 0px;
|
||||
.ant-pro-card {
|
||||
height: 100%;
|
||||
.ant-pro-card-body {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.ant-list {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.ant-spin-nested-loading {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
.ant-list-item {
|
||||
padding-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.progress-speed {
|
||||
line-height: 25px;
|
||||
min-width: max-content;
|
||||
.description {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
}
|
||||
.download-progress {
|
||||
.ant-progress {
|
||||
align-items: center;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
.progress-speed {
|
||||
line-height: 25px;
|
||||
min-width: max-content;
|
||||
}
|
||||
}
|
||||
.ant-pro-list-row-title {
|
||||
cursor: default;
|
||||
.title-disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import React, { FC, ReactNode, useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
message,
|
||||
Progress,
|
||||
Radio,
|
||||
RadioChangeEvent,
|
||||
Space,
|
||||
Tag,
|
||||
Popover,
|
||||
@@ -17,35 +14,40 @@ import "./index.scss";
|
||||
import PageContainer from "../../components/PageContainer";
|
||||
import { useAsyncEffect, usePagination } from "ahooks";
|
||||
import useElectron from "../../hooks/electron";
|
||||
import { DownloadStatus } from "../../types";
|
||||
import { ModalForm, ProFormText, ProList } from "@ant-design/pro-components";
|
||||
import { DownloadStatus, DownloadType } from "../../types";
|
||||
import { ProList } from "@ant-design/pro-components";
|
||||
import {
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
FolderOpenOutlined,
|
||||
PauseCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
SyncOutlined,
|
||||
MobileOutlined,
|
||||
MoreOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useSelector } from "react-redux";
|
||||
import { selectAppStore } from "../../store";
|
||||
import { tdApp } from "../../utils";
|
||||
import DownloadFrom from "../../components/DownloadForm";
|
||||
import dayjs from "dayjs";
|
||||
import classNames from "classnames";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
enum DownloadFilter {
|
||||
export enum DownloadFilter {
|
||||
list = "list",
|
||||
done = "done",
|
||||
}
|
||||
|
||||
const HomePage: FC = () => {
|
||||
interface Props {
|
||||
filter?: DownloadFilter;
|
||||
}
|
||||
|
||||
const HomePage: FC<Props> = ({ filter = DownloadFilter.list }) => {
|
||||
const {
|
||||
getDownloadItems,
|
||||
startDownload,
|
||||
rendererEvent,
|
||||
removeEventListener,
|
||||
addIpcListener,
|
||||
removeIpcListener,
|
||||
openDir,
|
||||
stopDownload,
|
||||
onDownloadListContextMenu,
|
||||
@@ -53,13 +55,12 @@ const HomePage: FC = () => {
|
||||
convertToAudio,
|
||||
showBrowserWindow,
|
||||
addDownloadItem,
|
||||
addDownloadItems,
|
||||
editDownloadItem,
|
||||
openPlayerWindow,
|
||||
getLocalIP,
|
||||
} = useElectron();
|
||||
const dispatch = useDispatch();
|
||||
const appStore = useSelector(selectAppStore);
|
||||
const [filter, setFilter] = useState(DownloadFilter.list);
|
||||
const { data, loading, pagination, refresh } = usePagination(
|
||||
({ current, pageSize }) => {
|
||||
return getDownloadItems({
|
||||
@@ -71,15 +72,14 @@ const HomePage: FC = () => {
|
||||
{
|
||||
defaultPageSize: 50,
|
||||
refreshDeps: [filter],
|
||||
}
|
||||
},
|
||||
);
|
||||
const [converting, setConverting] = useState<Record<number, boolean>>({});
|
||||
const [progress, setProgress] = useState<Record<number, DownloadProgress>>(
|
||||
{}
|
||||
{},
|
||||
);
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [addVideoForm] = Form.useForm<DownloadItem>();
|
||||
const [editVideoForm] = Form.useForm<DownloadItem>();
|
||||
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
@@ -95,12 +95,10 @@ const HomePage: FC = () => {
|
||||
};
|
||||
|
||||
const onDownloadSuccess = () => {
|
||||
tdApp.downloadSuccess();
|
||||
refresh();
|
||||
};
|
||||
|
||||
const onDownloadFailed = () => {
|
||||
tdApp.downloadFailed();
|
||||
refresh();
|
||||
};
|
||||
|
||||
@@ -110,7 +108,7 @@ const HomePage: FC = () => {
|
||||
|
||||
const onDownloadMenuEvent = async (
|
||||
e: any,
|
||||
params: { action: string; payload: number }
|
||||
params: { action: string; payload: number },
|
||||
) => {
|
||||
const { action, payload } = params;
|
||||
|
||||
@@ -135,36 +133,31 @@ const HomePage: FC = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
rendererEvent("download-progress", onDownloadProgress);
|
||||
rendererEvent("download-success", onDownloadSuccess);
|
||||
rendererEvent("download-failed", onDownloadFailed);
|
||||
rendererEvent("download-start", onDownloadStart);
|
||||
rendererEvent("download-item-event", onDownloadMenuEvent);
|
||||
rendererEvent("download-item-notifier", onReceiveDownloadItem);
|
||||
rendererEvent("change-video-is-live", onChangeVideoIsLive);
|
||||
addIpcListener("download-progress", onDownloadProgress);
|
||||
addIpcListener("download-success", onDownloadSuccess);
|
||||
addIpcListener("download-failed", onDownloadFailed);
|
||||
addIpcListener("download-start", onDownloadStart);
|
||||
addIpcListener("download-item-event", onDownloadMenuEvent);
|
||||
addIpcListener("download-item-notifier", onReceiveDownloadItem);
|
||||
addIpcListener("change-video-is-live", onChangeVideoIsLive);
|
||||
|
||||
return () => {
|
||||
removeEventListener("download-progress", onDownloadProgress);
|
||||
removeEventListener("download-success", onDownloadSuccess);
|
||||
removeEventListener("download-failed", onDownloadFailed);
|
||||
removeEventListener("download-start", onDownloadStart);
|
||||
removeEventListener("download-item-event", onDownloadMenuEvent);
|
||||
removeEventListener("download-item-notifier", onReceiveDownloadItem);
|
||||
removeEventListener("change-video-is-live", onChangeVideoIsLive);
|
||||
removeIpcListener("download-progress", onDownloadProgress);
|
||||
removeIpcListener("download-success", onDownloadSuccess);
|
||||
removeIpcListener("download-failed", onDownloadFailed);
|
||||
removeIpcListener("download-start", onDownloadStart);
|
||||
removeIpcListener("download-item-event", onDownloadMenuEvent);
|
||||
removeIpcListener("download-item-notifier", onReceiveDownloadItem);
|
||||
removeIpcListener("change-video-is-live", onChangeVideoIsLive);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onStartDownload = async (id: number) => {
|
||||
tdApp.startDownload();
|
||||
await startDownload(id);
|
||||
messageApi.success("添加任务成功");
|
||||
refresh();
|
||||
};
|
||||
|
||||
const onOpenDir = async () => {
|
||||
await openDir(appStore.local);
|
||||
};
|
||||
|
||||
const onClickStopDownload = async (item: DownloadItem) => {
|
||||
await stopDownload(item.id);
|
||||
refresh();
|
||||
@@ -190,26 +183,19 @@ const HomePage: FC = () => {
|
||||
|
||||
const renderEditForm = (item: DownloadItem) => {
|
||||
return (
|
||||
<ModalForm<DownloadItem>
|
||||
key="edit"
|
||||
title="编辑下载"
|
||||
width={500}
|
||||
onOpenChange={() => {
|
||||
editVideoForm.setFieldsValue(item);
|
||||
}}
|
||||
<DownloadFrom
|
||||
key={"edit"}
|
||||
isEdit
|
||||
item={item}
|
||||
trigger={<Button type="text" icon={<EditOutlined />} />}
|
||||
form={editVideoForm}
|
||||
autoFocusFirstInput
|
||||
modalProps={{
|
||||
destroyOnClose: true,
|
||||
}}
|
||||
submitTimeout={2000}
|
||||
onFinish={async (values) => {
|
||||
try {
|
||||
await editDownloadItem({
|
||||
id: item.id,
|
||||
name: values.name,
|
||||
url: values.url,
|
||||
headers: values.headers,
|
||||
type: DownloadType.m3u8,
|
||||
});
|
||||
refresh();
|
||||
return true;
|
||||
@@ -217,40 +203,13 @@ const HomePage: FC = () => {
|
||||
messageApi.error(err.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ProFormText
|
||||
name="name"
|
||||
label="标题"
|
||||
placeholder="请输入标题"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "请输入站点名称",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ProFormText
|
||||
name="url"
|
||||
label="网址"
|
||||
placeholder="请输入网址"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "请输入站点网址",
|
||||
},
|
||||
{
|
||||
pattern: /^https?:\/\/.+/,
|
||||
message: "请输入正确的网址",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ModalForm>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderActionButtons = (
|
||||
dom: ReactNode,
|
||||
item: DownloadItem
|
||||
item: DownloadItem,
|
||||
): ReactNode => {
|
||||
if (item.status === DownloadStatus.Ready) {
|
||||
return [
|
||||
@@ -309,26 +268,15 @@ const HomePage: FC = () => {
|
||||
<Button
|
||||
type="text"
|
||||
key="open-file"
|
||||
icon={<FolderOpenOutlined />}
|
||||
title="打开文件位置"
|
||||
onClick={() => onOpenDir()}
|
||||
/>,
|
||||
<Button
|
||||
type="text"
|
||||
key="redownload"
|
||||
title="重新下载"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => onStartDownload(item.id)}
|
||||
disabled={!item.exist}
|
||||
icon={<PlayCircleOutlined />}
|
||||
title="播放视频"
|
||||
onClick={() => openPlayerWindow(item.name)}
|
||||
/>,
|
||||
<Dropdown
|
||||
key="more"
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
label: "播放视频",
|
||||
key: "play",
|
||||
icon: <PlayCircleOutlined />,
|
||||
},
|
||||
{
|
||||
label: "转换为音频",
|
||||
key: "convert",
|
||||
@@ -337,9 +285,6 @@ const HomePage: FC = () => {
|
||||
},
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === "play") {
|
||||
openPlayerWindow(item.id);
|
||||
}
|
||||
if (key === "convert") {
|
||||
onClickConvertToAudio(item);
|
||||
}
|
||||
@@ -360,7 +305,11 @@ const HomePage: FC = () => {
|
||||
</Tag>
|
||||
);
|
||||
} else if (item.status === DownloadStatus.Success) {
|
||||
tag = <Tag color="success">下载成功</Tag>;
|
||||
if (item.exist) {
|
||||
tag = <Tag color="success">下载成功</Tag>;
|
||||
} else {
|
||||
tag = <Tag color="default">文件不存在</Tag>;
|
||||
}
|
||||
} else if (item.status === DownloadStatus.Failed) {
|
||||
tag = <Tag color="error">下载失败</Tag>;
|
||||
} else if (item.status === DownloadStatus.Stopped) {
|
||||
@@ -369,7 +318,14 @@ const HomePage: FC = () => {
|
||||
|
||||
return (
|
||||
<Space>
|
||||
<Text>{item.name}</Text>
|
||||
<Text
|
||||
className={classNames({
|
||||
"title-disabled":
|
||||
item.status === DownloadStatus.Success && !item.exist,
|
||||
})}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Space size={[0, 8]}>
|
||||
{tag}
|
||||
{item.isLive && <Tag color={"default"}>直播资源</Tag>}
|
||||
@@ -401,13 +357,9 @@ const HomePage: FC = () => {
|
||||
onChange: (keys: React.Key[]) => setSelectedRowKeys(keys),
|
||||
};
|
||||
|
||||
const onFilterChange = (e: RadioChangeEvent) => {
|
||||
setFilter(e.target.value);
|
||||
};
|
||||
|
||||
const onBatchDownload = async () => {
|
||||
for (const id of selectedRowKeys) {
|
||||
await startDownload(+id);
|
||||
await startDownload(Number(id));
|
||||
}
|
||||
|
||||
messageApi.success("添加任务成功");
|
||||
@@ -417,20 +369,9 @@ const HomePage: FC = () => {
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="下载列表"
|
||||
titleExtra={
|
||||
<Radio.Group value={filter} onChange={onFilterChange}>
|
||||
<Radio.Button value="list">下载列表</Radio.Button>
|
||||
<Radio.Button value="done">下载完成</Radio.Button>
|
||||
</Radio.Group>
|
||||
}
|
||||
title={filter === DownloadFilter.list ? "下载列表" : "下载完成"}
|
||||
rightExtra={
|
||||
<Space>
|
||||
{appStore.openInNewWindow && (
|
||||
<Button type="primary" onClick={() => showBrowserWindow()}>
|
||||
打开浏览器
|
||||
</Button>
|
||||
)}
|
||||
{filter === DownloadFilter.done && (
|
||||
<Popover
|
||||
placement="topRight"
|
||||
@@ -451,54 +392,50 @@ const HomePage: FC = () => {
|
||||
<Button icon={<MobileOutlined />}>手机上播放</Button>
|
||||
</Popover>
|
||||
)}
|
||||
<Button onClick={() => refresh()}>刷新</Button>
|
||||
<ModalForm<DownloadItem>
|
||||
title="新建下载"
|
||||
width={500}
|
||||
trigger={<Button>新建下载</Button>}
|
||||
form={addVideoForm}
|
||||
autoFocusFirstInput
|
||||
modalProps={{
|
||||
destroyOnClose: true,
|
||||
}}
|
||||
submitTimeout={2000}
|
||||
onFinish={async (values) => {
|
||||
await addDownloadItem({
|
||||
name: values.name,
|
||||
url: values.url,
|
||||
});
|
||||
refresh();
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ProFormText
|
||||
name="name"
|
||||
label="标题"
|
||||
placeholder="请输入标题"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "请输入站点名称",
|
||||
},
|
||||
]}
|
||||
{filter === DownloadFilter.done && (
|
||||
<Button onClick={() => openDir(appStore.local)}>打开文件夹</Button>
|
||||
)}
|
||||
{filter === DownloadFilter.list && appStore.openInNewWindow && (
|
||||
<Button type="primary" onClick={() => showBrowserWindow()}>
|
||||
打开浏览器
|
||||
</Button>
|
||||
)}
|
||||
{filter === DownloadFilter.list && (
|
||||
<Button onClick={() => refresh()}>刷新</Button>
|
||||
)}
|
||||
{filter === DownloadFilter.list && (
|
||||
<DownloadFrom
|
||||
trigger={<Button>新建下载</Button>}
|
||||
onFinish={async (values: any) => {
|
||||
if (values.batch) {
|
||||
const { batchList = "" } = values;
|
||||
const items = batchList.split("\n").map((item: any) => {
|
||||
let [url, name] = item.split(" ");
|
||||
url = url ? url.trim() : "";
|
||||
name = name
|
||||
? name.trim()
|
||||
: dayjs().format("YYYY-MM-DDTHH:mm:ssZ");
|
||||
return {
|
||||
url,
|
||||
name,
|
||||
headers: values.headers,
|
||||
};
|
||||
});
|
||||
await addDownloadItems(items);
|
||||
} else {
|
||||
await addDownloadItem({
|
||||
name: values.name || dayjs().format("YYYY-MM-DDTHH:mm:ssZ"),
|
||||
url: values.url,
|
||||
headers: values.headers,
|
||||
type: DownloadType.m3u8,
|
||||
});
|
||||
}
|
||||
|
||||
refresh();
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
<ProFormText
|
||||
name="url"
|
||||
label="网址"
|
||||
placeholder="请输入网址"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "请输入站点网址",
|
||||
},
|
||||
{
|
||||
pattern: /^https?:\/\/.+/,
|
||||
message: "请输入正确的网址",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ModalForm>
|
||||
<Button onClick={() => openDir(appStore.local)}>打开文件夹</Button>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
className="home-page"
|
||||
@@ -506,6 +443,7 @@ const HomePage: FC = () => {
|
||||
{contextHolder}
|
||||
<ProList<DownloadItem>
|
||||
loading={loading}
|
||||
className="download-list"
|
||||
pagination={pagination}
|
||||
metas={{
|
||||
title: {
|
||||
@@ -520,14 +458,14 @@ const HomePage: FC = () => {
|
||||
}}
|
||||
onRow={(record) => {
|
||||
return {
|
||||
onContextMenu: (event) => {
|
||||
onContextMenu: () => {
|
||||
onDownloadListContextMenu(record.id);
|
||||
},
|
||||
};
|
||||
}}
|
||||
rowKey="id"
|
||||
rowSelection={rowSelection}
|
||||
dataSource={data?.list}
|
||||
dataSource={data?.list || []}
|
||||
tableAlertOptionRender={({ selectedRowKeys, onCleanSelected }) => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
return null;
|
||||
@@ -539,7 +477,7 @@ const HomePage: FC = () => {
|
||||
type="link"
|
||||
onClick={async () => {
|
||||
for (const id of selectedRowKeys) {
|
||||
await deleteDownloadItem(+id);
|
||||
await deleteDownloadItem(Number(id));
|
||||
}
|
||||
setSelectedRowKeys([]);
|
||||
refresh();
|
||||
@@ -563,7 +501,8 @@ const HomePage: FC = () => {
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => {
|
||||
const ids = data?.list.map((item) => item.id);
|
||||
const list = data?.list || [];
|
||||
const ids = list.map((item) => item.id || 0);
|
||||
if (ids) {
|
||||
setSelectedRowKeys(ids);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { FC, useEffect, useRef, useState } from "react";
|
||||
import React, { FC, useRef, useState } from "react";
|
||||
import Player from "xgplayer";
|
||||
import "xgplayer/dist/index.min.css";
|
||||
import "./index.scss";
|
||||
@@ -6,6 +6,7 @@ import axios from "axios";
|
||||
import { useAsyncEffect, useToggle } from "ahooks";
|
||||
import { List, Space, Button } from "antd";
|
||||
import useElectron from "../../hooks/electron";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
interface Video {
|
||||
id: number;
|
||||
@@ -17,21 +18,25 @@ const port = import.meta.env.APP_SERVER_PORT;
|
||||
|
||||
// 播放器页面
|
||||
const PlayerPage: FC = () => {
|
||||
const { rendererEvent, removeEventListener, getLocalIP } = useElectron();
|
||||
const { getLocalIP } = useElectron();
|
||||
const [showVideoList, { toggle }] = useToggle();
|
||||
const [showButton, setShowButton] = useState(false);
|
||||
const [videoList, setVideoList] = useState<Video[]>([]);
|
||||
const playerRef = useRef<HTMLDivElement>(null);
|
||||
const player = useRef<Player>();
|
||||
const playedVideoId = useRef<number>();
|
||||
const location = useLocation();
|
||||
|
||||
// 获取视频列表
|
||||
const getVideoList = async (): Promise<Video[]> => {
|
||||
const localIP = await getLocalIP();
|
||||
const baseURL = `http://${localIP}:${port}`;
|
||||
|
||||
const videoListUrl = `http://${localIP}:${port}/api/video-list`;
|
||||
const videoListUrl = `${baseURL}/api/video-list`;
|
||||
const res = await axios.get(videoListUrl);
|
||||
return res.data;
|
||||
return res.data.map((item: any) => ({
|
||||
...item,
|
||||
url: `${baseURL}${item.url}`,
|
||||
}));
|
||||
};
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
@@ -43,8 +48,11 @@ const PlayerPage: FC = () => {
|
||||
|
||||
if (!Array.isArray(videoList) || videoList.length === 0) return;
|
||||
let src = videoList[0].url;
|
||||
if (playedVideoId.current) {
|
||||
const video = videoList.find((item) => item.id === playedVideoId.current);
|
||||
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
const name = decodeURIComponent(queryParams.get("name"));
|
||||
if (name) {
|
||||
const video = videoList.find((item) => item.name === name);
|
||||
if (video) {
|
||||
src = video.url;
|
||||
}
|
||||
@@ -62,18 +70,6 @@ const PlayerPage: FC = () => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 打开播放器窗口的时候播放的视频id
|
||||
const openPlayerWindow = async (e: any, videoId: number) => {
|
||||
playedVideoId.current = videoId;
|
||||
|
||||
if (!player.current) return;
|
||||
|
||||
const videoList = await getVideoList();
|
||||
setVideoList(videoList);
|
||||
const vc = videoList?.find((item) => item.id === videoId)?.url || "";
|
||||
player.current.src = vc;
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
if (!player.current) return;
|
||||
|
||||
@@ -82,14 +78,6 @@ const PlayerPage: FC = () => {
|
||||
setVideoList(videoList);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
rendererEvent("open-player-window", openPlayerWindow);
|
||||
|
||||
return () => {
|
||||
removeEventListener("open-player-window", openPlayerWindow);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="player-page">
|
||||
<div
|
||||
|
||||
@@ -122,7 +122,7 @@ const SettingPage: React.FC = () => {
|
||||
name="useProxy"
|
||||
label={renderTooltipLabel(
|
||||
"代理开关",
|
||||
"该代理会对软件自带浏览器以及下载时生效"
|
||||
"该代理会对软件自带浏览器以及下载时生效",
|
||||
)}
|
||||
rules={[
|
||||
({ getFieldValue, setFieldValue }) => ({
|
||||
@@ -141,7 +141,7 @@ const SettingPage: React.FC = () => {
|
||||
<ProFormSwitch
|
||||
label={renderTooltipLabel(
|
||||
"使用沉浸式嗅探",
|
||||
"开启后沉浸式嗅探到的资源将不会直接显示在下载列表中,所有资源嗅探会交给浏览器插件处理"
|
||||
"开启后沉浸式嗅探到的资源将不会直接显示在下载列表中,所有资源嗅探会交给浏览器插件处理",
|
||||
)}
|
||||
name="useExtension"
|
||||
/>
|
||||
@@ -151,11 +151,11 @@ const SettingPage: React.FC = () => {
|
||||
<ProFormDigit
|
||||
label={renderTooltipLabel(
|
||||
"最大同时下载数量",
|
||||
"直播录制不占用同时下载数量"
|
||||
"直播录制不占用同时下载数量",
|
||||
)}
|
||||
name="maxRunner"
|
||||
min={1}
|
||||
max={10}
|
||||
max={50}
|
||||
fieldProps={{ precision: 0 }}
|
||||
/>
|
||||
<ProFormText label="更多操作">
|
||||
|
||||
@@ -51,10 +51,10 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
|
||||
addFavorite,
|
||||
removeFavorite,
|
||||
webviewLoadURL,
|
||||
rendererEvent,
|
||||
removeEventListener,
|
||||
addIpcListener,
|
||||
removeIpcListener,
|
||||
webviewGoBack,
|
||||
webwiewGoHome,
|
||||
webviewGoHome,
|
||||
onFavoriteItemContextMenu,
|
||||
combineToHomePage,
|
||||
getSharedState,
|
||||
@@ -82,21 +82,21 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
|
||||
setBrowserStore({
|
||||
mode: PageMode.Browser,
|
||||
status: BrowserStatus.Loading,
|
||||
})
|
||||
}),
|
||||
);
|
||||
await webviewLoadURL(url);
|
||||
dispatch(
|
||||
setBrowserStore({
|
||||
url: url,
|
||||
status: BrowserStatus.Loaded,
|
||||
})
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
dispatch(
|
||||
setBrowserStore({
|
||||
status: BrowserStatus.Failed,
|
||||
errMsg: (err as any).message,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -139,18 +139,18 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
|
||||
setBrowserStore({
|
||||
url: "",
|
||||
mode: PageMode.Default,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickGoHome = async () => {
|
||||
await webwiewGoHome();
|
||||
await webviewGoHome();
|
||||
dispatch(
|
||||
setBrowserStore({
|
||||
url: "",
|
||||
mode: PageMode.Default,
|
||||
})
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -173,7 +173,7 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
|
||||
setBrowserStore({
|
||||
url: data.url,
|
||||
title: data.title,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -186,7 +186,7 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
|
||||
}: {
|
||||
action: string;
|
||||
payload: number;
|
||||
}
|
||||
},
|
||||
) => {
|
||||
if (action === "open") {
|
||||
const item = favoriteList.find((item) => item.id === payload);
|
||||
@@ -206,13 +206,13 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
|
||||
|
||||
useEffect(() => {
|
||||
const prevTitle = document.title;
|
||||
rendererEvent("webview-dom-ready", onDomReady);
|
||||
rendererEvent("favorite-item-event", onFavoriteEvent);
|
||||
addIpcListener("webview-dom-ready", onDomReady);
|
||||
addIpcListener("favorite-item-event", onFavoriteEvent);
|
||||
|
||||
return () => {
|
||||
document.title = prevTitle;
|
||||
removeEventListener("webview-dom-ready", onDomReady);
|
||||
removeEventListener("favorite-item-event", onFavoriteEvent);
|
||||
removeIpcListener("webview-dom-ready", onDomReady);
|
||||
removeIpcListener("favorite-item-event", onFavoriteEvent);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -228,7 +228,7 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
|
||||
dispatch(
|
||||
setAppStore({
|
||||
isMobile: nextMode,
|
||||
})
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -279,7 +279,7 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
|
||||
e.target.select();
|
||||
}}
|
||||
onKeyDown={onInputKeyDown}
|
||||
placeholder="请输入网址链接……"
|
||||
placeholder="请输入网址"
|
||||
/>
|
||||
<Button
|
||||
title="访问"
|
||||
@@ -338,8 +338,14 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
|
||||
autoFocusFirstInput
|
||||
modalProps={{
|
||||
destroyOnClose: true,
|
||||
styles: {
|
||||
body: {
|
||||
paddingTop: 5,
|
||||
},
|
||||
},
|
||||
}}
|
||||
submitTimeout={2000}
|
||||
layout="horizontal"
|
||||
onFinish={async (values) => {
|
||||
try {
|
||||
const icon = await getFavIcon(values.url);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user