Compare commits

...

86 Commits

Author SHA1 Message Date
士子☀️ f25584cb2f Merge pull request #195 from caorushizi/fix-videoname
fix: 🐛  修复获取视频名称的问题
2024-06-23 00:13:13 +08:00
caorushizi 03999585d0 fix: 🐛 修复获取视频名称的问题 2024-06-23 00:12:06 +08:00
士子☀️ 772c2b0300 Merge pull request #194 from caorushizi/dev-webview
Dev webview
2024-06-22 22:59:28 +08:00
caorushizi 3622e67a56 fix: 🐛 修复浏览器背景颜色 2024-06-22 22:58:25 +08:00
caorushizi c51630cdd6 fix: 🐛 修复切换隐私模式时 Proxy 和嗅探问题 2024-06-22 22:14:49 +08:00
士子☀️ dab1c55a93 Merge pull request #193 from caorushizi/dev-webview
feat(settings):   新增无痕浏览模式
2024-06-22 21:48:11 +08:00
caorushizi 7b966bc988 feat(settings): 新增无痕浏览模式
设置页面新增无痕浏览模式

新增无痕浏览模式 #52
2024-06-22 21:46:27 +08:00
士子☀️ e5e46bee38 Merge pull request #192 from caorushizi/dev-webview
feat(settings):   添加自动更新选项
2024-06-22 15:55:09 +08:00
caorushizi 3365d96a03 feat(settings): 添加自动更新选项
设置页面添加自动更新选项,下次启动时生效

auto upgrade #185
2024-06-22 15:53:42 +08:00
士子☀️ fb223cd493 Merge pull request #191 from caorushizi/dev-webview
feat:   添加下载代理开关 & 添加清空缓存
2024-06-22 15:36:18 +08:00
caorushizi 60d49c4773 feat: 添加下载代理开关 & 添加清空缓存 2024-06-22 15:35:08 +08:00
士子☀️ e7aa4ae147 Merge pull request #190 from caorushizi/dev-webview
build: 🛠  portable
2024-06-22 12:42:50 +08:00
caorushizi c45be41ddc build: 🛠 portable 2024-06-22 12:41:45 +08:00
士子☀️ 85b37facee Merge pull request #189 from caorushizi/dev-webview
Dev webview
2024-06-22 12:27:58 +08:00
caorushizi 90567009ef Merge branch 'dev-webview' of https://github.com/caorushizi/m3u8-downloader into dev-webview 2024-06-22 12:26:36 +08:00
caorushizi 899afc156e build: 🛠 upload artifact 2024-06-22 12:25:51 +08:00
caorushizi 9e33b7e488 build: 🛠 upload artifact 2024-06-22 12:24:24 +08:00
士子☀️ e7ba60a51f Merge pull request #188 from caorushizi/dev-webview
build: 🛠  portable
2024-06-22 12:11:55 +08:00
caorushizi 2a3ad325c2 build: 🛠 portable 2024-06-22 12:11:19 +08:00
caorushizi 7575b80d31 build: 🛠 portable 2024-06-22 12:08:33 +08:00
士子☀️ 8bb9d119b5 Merge pull request #187 from caorushizi/dev-webview
Dev webview
2024-06-22 11:54:18 +08:00
caorushizi 5da3f2b1c7 build: 🛠 优化构建 2024-06-22 11:50:40 +08:00
caorushizi da6cbcfb75 feat: 修复 webview 中加载的问题 2024-06-22 10:44:34 +08:00
caorushizi 3d7aff41ee feat: 优化内置浏览器逻辑 2024-06-22 03:42:15 +08:00
士子☀️ 2cc49e8679 Merge pull request #186 from caorushizi/dev-ap
feat:   ap
2024-06-21 23:44:42 +08:00
caorushizi 0e961c0647 feat: use td app 2024-06-21 23:39:45 +08:00
caorushizi 4d5757a6ce feat: ap 2024-06-21 00:04:21 +08:00
士子☀️ 62ae0e5075 Merge pull request #184 from caorushizi/feat/v2.2.2
feat:   v2.2.2
2024-06-18 09:08:22 +08:00
caorushizi 7513fe2882 feat: v2.2.2 2024-06-18 09:06:42 +08:00
士子☀️ c945dc8360 Merge pull request #183 from caorushizi/feat/v2.2.1
feat:   release v2.2.1
2024-06-17 23:59:20 +08:00
caorushizi de7e0da045 feat: release v2.2.1 2024-06-17 23:57:36 +08:00
士子☀️ a706429395 Merge pull request #182 from caorushizi/feat/v2.2.1
feat:   v2.2.1
2024-06-17 23:34:01 +08:00
caorushizi f26c9761c3 feat: v2.2.1 2024-06-17 23:32:58 +08:00
士子☀️ 9424dd4c0e Merge pull request #181 from caorushizi/dev/form
Dev/form
2024-06-17 23:30:55 +08:00
caorushizi 271294a8e7 feat: conversion page 2024-06-17 23:29:27 +08:00
caorushizi 6b2786292f feat: conversion page 2024-06-10 00:17:32 +08:00
caorushizi c36247b5e1 feat: update form 2024-06-09 23:09:55 +08:00
士子☀️ c182afff2c Merge pull request #177 from caorushizi/feat/v2.2.1-beta.0
feat:   release 2.2.1-beta.0
2024-06-09 20:25:36 +08:00
caorushizi 1129d70dca feat: release 2.2.1-beta.0 2024-06-09 20:24:20 +08:00
士子☀️ bde71d13f9 Merge pull request #176 from caorushizi/dev/download-items
fix: 🐛  download items
2024-06-08 18:37:57 +08:00
caorushizi 67b513d380 fix: 🐛 merge master 2024-06-08 18:36:51 +08:00
caorushizi 8d8d1ee238 fix: 🐛 download item 2024-06-08 18:32:23 +08:00
caorushizi 7c10715607 Merge branch 'master' of https://github.com/caorushizi/m3u8-downloader 2024-06-08 18:27:50 +08:00
caorushizi a939f06fcd Revert "fix: 🐛 下载嗅探到的最后一个文件"
This reverts commit 2a42a02f3d.
2024-06-08 18:24:38 +08:00
士子☀️ c50625b0c8 Merge pull request #170 from caorushizi/feat/addForm
fix: 🐛  下载嗅探到的最后一个文件
2024-06-08 18:20:48 +08:00
caorushizi bfa2d88935 fix: 🐛 download items 2024-06-08 18:02:38 +08:00
士子☀️ b04ea61e83 Merge pull request #170 from caorushizi/feat/addForm
fix: 🐛  下载嗅探到的最后一个文件
2024-05-31 12:10:06 +08:00
caorushizi 2a42a02f3d fix: 🐛 下载嗅探到的最后一个文件 2024-05-31 12:08:25 +08:00
士子☀️ 1e80278a1d Merge pull request #167 from caorushizi/dependabot/npm_and_yarn/cz-git-1.9.2
chore(deps-dev): bump cz-git from 1.9.1 to 1.9.2
2024-05-27 21:20:01 +08:00
士子☀️ f38d9156c5 Merge pull request #169 from caorushizi/feat/addForm
docs: 📝  release
2024-05-27 21:18:58 +08:00
caorushizi 0f32864079 docs: 📝 release 2024-05-27 21:16:59 +08:00
士子☀️ f8fbecebc9 Merge pull request #168 from caorushizi/feat/addForm
fix: 🐛  build scripts
2024-05-27 20:52:39 +08:00
caorushizi 40c5656080 fix: 🐛 build scripts 2024-05-27 20:47:07 +08:00
dependabot[bot] ddf36a7091 chore(deps-dev): bump cz-git from 1.9.1 to 1.9.2
Bumps [cz-git](https://github.com/Zhengqbbb/cz-git/tree/HEAD/packages/cz-git) from 1.9.1 to 1.9.2.
- [Release notes](https://github.com/Zhengqbbb/cz-git/releases)
- [Changelog](https://github.com/Zhengqbbb/cz-git/blob/main/packages/cz-git/CHANGELOG.md)
- [Commits](https://github.com/Zhengqbbb/cz-git/commits/v1.9.2/packages/cz-git)

---
updated-dependencies:
- dependency-name: cz-git
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-27 04:15:52 +00:00
士子☀️ 258c67554d Merge pull request #166 from caorushizi/feat/addForm
feat:   完善表单项
2024-05-27 04:40:01 +08:00
caorushizi 007ff31200 feat: 完善表单项 2024-05-27 04:37:50 +08:00
士子☀️ ff75e3d94b Merge pull request #165 from caorushizi/feat/addForm
feat:   修改创建修改表单
2024-05-27 01:52:01 +08:00
caorushizi aaa18e0048 feat: 修改创建修改表单 2024-05-27 01:51:07 +08:00
士子☀️ 5c6f89a067 Merge pull request #164 from caorushizi/feat/dependencies
refactor: 📦️  update eslint
2024-05-26 20:00:37 +08:00
caorushizi c4efc4c293 refactor: 📦️ update eslint 2024-05-26 19:40:51 +08:00
士子☀️ 2d955257e6 Merge pull request #163 from caorushizi/feat/dependencies
fix: 🐛  update dependencies
2024-05-26 18:57:29 +08:00
caorushizi 3cf84a2992 fix: 🐛 update dependencies 2024-05-26 18:56:02 +08:00
士子☀️ 94a795fe69 Merge pull request #162 from caorushizi/feat/dependencies
feat:   update dependencies
2024-05-26 18:10:18 +08:00
caorushizi cc10ae8b2e feat: update dependencies 2024-05-26 17:58:40 +08:00
caorushizi 63ed7cdaca fix: 🐛 直播视频不展示下载进度 2024-05-26 17:21:58 +08:00
caorushizi 064919e0a2 fix: 🐛 build variable 2024-05-26 17:14:27 +08:00
士子☀️ 31b2652c9b Merge pull request #161 from caorushizi/fix/scripts
fix: 🐛  build script
2024-05-26 16:38:58 +08:00
caorushizi d080d709d1 fix: 🐛 build script 2024-05-26 16:37:59 +08:00
士子☀️ 0c301c79f2 Merge pull request #160 from caorushizi/fix/scripts
fix: 🐛  build
2024-05-26 15:59:19 +08:00
caorushizi a1dbb6234f fix: 🐛 build 2024-05-26 15:56:40 +08:00
士子☀️ 30248194af Merge pull request #159 from caorushizi/fix/scripts
fix: 🐛  pnpm actions
2024-05-26 15:32:03 +08:00
caorushizi 8f54ebeb3a fix: 🐛 pnpm actions 2024-05-26 15:30:34 +08:00
士子☀️ d8ef0b9eee Merge pull request #158 from caorushizi/fix/scripts
fix: 🐛  npm rebuild
2024-05-26 15:10:52 +08:00
caorushizi 6a3bfd071a fix: 🐛 npm rebuild 2024-05-26 15:05:58 +08:00
士子☀️ 849df04c05 Merge pull request #157 from caorushizi/fix/scripts
fix: 🐛  打包分片
2024-05-26 14:29:25 +08:00
caorushizi a40ae5ef82 fix: 🐛 打包分片 2024-05-26 14:28:18 +08:00
士子☀️ 00c535665f Merge pull request #156 from caorushizi/fix/scripts
fix: 🐛  scripts name
2024-05-26 02:29:51 +08:00
caorushizi 8b4a4d8ff8 fix: 🐛 scripts name 2024-05-26 02:29:05 +08:00
士子☀️ f9fecd541c Merge pull request #155 from caorushizi/fix/adblocker
Fix/adblocker
2024-05-26 02:07:25 +08:00
caorushizi 93b1028498 fix: 🐛 some bug
'
2024-05-26 02:05:53 +08:00
caorushizi a0a3dba188 refactor: 📦️ 代码分割 2024-05-25 23:37:23 +08:00
caorushizi 92ded00495 feat: 构建流程优化 2024-05-25 22:00:31 +08:00
caorushizi bcbe258f60 fix: 🐛 修复打包完成后没有权限执行的问题 2024-05-25 15:23:26 +08:00
caorushizi 660f9c7c9b feat: support linux 2024-05-25 03:02:49 +08:00
caorushizi 8c5ef29894 feat: support linux 2024-05-25 02:59:48 +08:00
caorushizi 612fc4c533 fix: 🐛 修复开启广告过滤时无法沉浸式嗅探 2024-05-24 21:27:36 +08:00
100 changed files with 4264 additions and 4402 deletions
+37 -32
View File
@@ -1,39 +1,44 @@
behaviour
commitlint
conventionalcommits
optimizelegibility
pinia
tiptap
vuedraggable
vuejs
TDAPP
reduxjs
bilibili
localforage
acodec
adblocker
ahooks
antd
aplus
behaviour
bili
bilibili
bodys
cancle
cliqz
commitlint
Conpty
consola
conventionalcommits
datetime
DDTHH
execa
idtype
immer
inversify
KHTML
languagedetector
localforage
mediago
metas
nocheck
optimizelegibility
pinia
redownload
Redownload
reduxjs
Sider
stylelint
svgz
TDAPP
tiptap
typeorm
vitepress
vuedraggable
vuejs
waline
watting
Watting
cancle
Sider
inversify
datetime
typeorm
bili
mediago
nocheck
metas
DDTHH
Conpty
execa
bodys
cliqz
adblocker
acodec
KHTML
svgz
stylelint
consola
+1 -1
View File
@@ -1,4 +1,4 @@
APP_NAME=mediago
APP_ID=mediago.ziying.site
APP_COPYRIGHT=caorushizi
APP_VERSION=2.2.0
APP_VERSION=2.2.3-beta.0
-1
View File
@@ -1,5 +1,4 @@
APP_TD_APPID=
APP_CLARITY_APPID=
GH_TOKEN=
LOAD_DEVTOOLS=
-1
View File
@@ -1,5 +1,4 @@
APP_TD_APPID=
APP_CLARITY_APPID=
GH_TOKEN=
DEBUG_PLUGINS=false
-1
View File
@@ -1 +0,0 @@
-20
View File
@@ -1,20 +0,0 @@
/* eslint-env node */
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
],
overrides: [],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
plugins: ["@typescript-eslint"],
rules: {},
};
+2 -1
View File
@@ -23,7 +23,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-12]
os: [windows-latest, macos-latest, ubuntu-latest]
# create steps
steps:
@@ -48,6 +48,7 @@ jobs:
with:
version: latest
run_install: true
standalone: true
- name: Build & release app
run: pnpm run release
+16 -11
View File
@@ -25,12 +25,12 @@
本项目支持 m3u8 视频在线提取工具 流媒体下载 m3u8 下载。
- **✅&nbsp; 无需抓包**: 使用软件自带浏览器可以轻松嗅探网页中的视频资源,通过嗅探到的资源列表选择自己想要下载的资源,简单快速。
- **📱&nbsp; 移动播放**: 可以轻松无缝的在PC和移动设备之前切换,下载完成后即可使用手机观看视频。
- **📱&nbsp; 移动播放**: 可以轻松无缝的在 PC 和移动设备之前切换,下载完成后即可使用手机观看视频。
- **⚡️&nbsp; 批量下载**: 支持同时下载多个视频和直播资源,高速带宽不闲置。
## Quickstart
运行代码需要 node pnpm 和 gonode、go 需要在官网下载安装,pnpm 可以通过`npm i -g pnpm`安装。
运行代码需要 node pnpmnode 需要在官网下载安装,pnpm 可以通过`npm i -g pnpm`安装。
## 运行代码
@@ -50,6 +50,19 @@ pnpm release
## Releases
**v2.2.0 (2024.5.22 发布)**
- [windows mediago v2.2.0](https://github.com/caorushizi/mediago/releases/download/v2.2.0/mediago-setup-2.2.0.exe)
- [macos mediago v2.2.0](https://github.com/caorushizi/mediago/releases/download/v2.2.0/mediago-setup-2.2.0.dmg)
**更新日志**
- 支持下载直播流
- 支持哔哩哔哩视频下载
- 优化沉浸式嗅探流程
- 支持下载控制台输出
- 修复了一些 bug
**v2.0.22023.7.9 发布)**
- [windows mediago v2.0.2](https://github.com/caorushizi/mediago/releases/download/v2.0.2/media-downloader-setup-2.0.2.exe)
@@ -66,12 +79,6 @@ pnpm release
- 支持修改同时下载数量
- 修复了一些 bug
**v1.1.52022.2.5 发布)**
- [windows mediago v1.1.5](https://github.com/caorushizi/mediago/releases/download/1.1.5/media-downloader-setup-1.1.4.exe)
**更新日志**
## 软件截图
![首页](https://static.ziying.site/v2.0.3-beta-home.png)
@@ -88,6 +95,4 @@ pnpm release
## 鸣谢
N_m3u8DL-CLI 来自于 <https://github.com/nilaoda/N_m3u8DL-CLI>
mediago 来自于 <https://github.com/caorushizi/mediago>
N_m3u8DL-RE 来自于 <https://github.com/nilaoda/N_m3u8DL-RE>
+13
View File
@@ -13,6 +13,19 @@ outline: deep
## 下载安装
**v2.2.0 (2024.5.22 发布)**
- [windows mediago v2.2.0](https://github.com/caorushizi/mediago/releases/download/v2.2.0/mediago-setup-2.2.0.exe)
- [macos mediago v2.2.0](https://github.com/caorushizi/mediago/releases/download/v2.2.0/mediago-setup-2.2.0.dmg)
**更新日志**
- 支持下载直播流
- 支持哔哩哔哩视频下载
- 优化沉浸式嗅探流程
- 支持下载控制台输出
- 修复了一些 bug
**v2.0.12023.7.1 发布)**
- [windows mediago v2.0.1](https://github.com/caorushizi/mediago/releases/download/v2.0.1/media-downloader-setup-2.0.1.exe)
+14
View File
@@ -0,0 +1,14 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
export default [
{ languageOptions: { globals: globals.browser } },
pluginJs.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
"@typescript-eslint/no-explicit-any": "warn",
},
},
];
+20 -19
View File
@@ -2,6 +2,8 @@
"name": "mediago",
"version": "0.1.0",
"description": "mediago",
"author": "caorushizi",
"homepage": "https://downloader.caorushizi.cn/",
"private": true,
"type": "module",
"scripts": {
@@ -10,7 +12,7 @@
"build:plugin": "pnpm -F plugin run build",
"build:main": "pnpm -F main run build",
"build:renderer": "pnpm -F renderer run build",
"prerelease": "pnpm run build && pnpm -F mediago run pack",
"beta": "pnpm run build && pnpm -F mediago run pack",
"release": "pnpm run build && pnpm -F main run release",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
@@ -24,7 +26,6 @@
"lint-staged": "pnpm --recursive -F \"./packages/*\" run lint-staged"
},
"keywords": [],
"author": "",
"license": "MIT",
"config": {
"commitizen": {
@@ -32,25 +33,25 @@
}
},
"devDependencies": {
"@commitlint/cli": "^18.6.0",
"@commitlint/config-conventional": "^18.6.0",
"@commitlint/cz-commitlint": "^18.6.0",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@waline/client": "^3.1.3",
"@commitlint/cli": "^19.3.0",
"@commitlint/config-conventional": "^19.2.2",
"@commitlint/core": "^19.3.0",
"@commitlint/cz-commitlint": "^19.2.0",
"@commitlint/format": "^19.3.0",
"@eslint/js": "^9.3.0",
"@waline/client": "^3.2.0",
"commitizen": "^4.3.0",
"cross-env": "^7.0.3",
"cspell": "^8.3.2",
"cz-git": "^1.8.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"husky": "^9.0.10",
"prettier": "3.2.5",
"tsx": "^4.7.1",
"vitepress": "1.0.1",
"vue": "^3.4.15",
"zx": "^7.2.3"
"cspell": "^8.8.3",
"cz-git": "^1.9.2",
"eslint": "^9.3.0",
"globals": "^15.3.0",
"husky": "^9.0.11",
"tsx": "^4.11.0",
"typescript-eslint": "^7.10.0",
"vitepress": "1.2.2",
"vue": "^3.4.27",
"zx": "^8.1.1"
},
"pnpm": {
"patchedDependencies": {
-1
View File
@@ -1 +0,0 @@
./types
-23
View File
@@ -1,23 +0,0 @@
module.exports = {
env: {
node: true,
es2021: true,
},
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
],
overrides: [],
ignorePatterns: ["app"],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
plugins: ["@typescript-eslint"],
rules: {
"prettier/prettier": "warn",
"@typescript-eslint/no-explicit-any": "warn",
},
};
+4 -3
View File
@@ -1,9 +1,10 @@
{
"name": "mediago",
"version": "2.2.0",
"version": "2.2.3-beta.0",
"description": "在线视频下载器",
"main": "main/index.js",
"author": "",
"author": "caorushizi",
"homepage": "https://downloader.caorushizi.cn/",
"license": "MIT",
"dependencies": {
"@cliqz/adblocker-electron": "^1.27.3",
@@ -11,6 +12,6 @@
"node-pty": "^1.0.0"
},
"devDependencies": {
"electron": "30.0.6"
"electron": "30.0.8"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 22 KiB

+16
View File
@@ -0,0 +1,16 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
export default [
{
languageOptions: { globals: globals.node },
},
pluginJs.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
"@typescript-eslint/no-explicit-any": "warn",
},
},
];
+1
View File
@@ -0,0 +1 @@
export { dev, build, release } from "./scripts";
+40 -40
View File
@@ -2,15 +2,16 @@
"name": "main",
"version": "0.1.0",
"description": "在线视频下载器",
"type": "module",
"main": "main/index.js",
"scripts": {
"postinstall": "electron-rebuild -f -w",
"dev": "cross-env NODE_ENV=development VITE_CJS_IGNORE_WARNING=true tsx scripts/dev.ts",
"build": "cross-env NODE_ENV=production tsx scripts/build.ts",
"pack": "cross-env NODE_ENV=development tsx scripts/release.ts",
"dev": "cross-env NODE_ENV=development gulp dev",
"pack": "cross-env NODE_ENV=development gulp release",
"build": "cross-env NODE_ENV=production gulp build",
"release": "cross-env NODE_ENV=production gulp release",
"types": "tsc",
"types:watch": "tsc -w",
"release": "cross-env NODE_ENV=production tsx scripts/release.ts",
"lint-staged": "lint-staged"
},
"lint-staged": {
@@ -20,58 +21,57 @@
"license": "MIT",
"devDependencies": {
"@electron/rebuild": "^3.6.0",
"@rushstack/eslint-patch": "^1.7.0",
"@eslint/js": "^9.3.0",
"@types/electron-devtools-installer": "^2.2.5",
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.14.202",
"@types/glob": "^8.1.0",
"@types/gulp": "^4.0.17",
"@types/lodash": "^4.17.4",
"@types/mime-types": "^2.1.4",
"@types/node": "^20.11.5",
"@types/node": "^20.12.12",
"@types/node-fetch": "^2.6.11",
"@types/semver": "^7.5.6",
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0",
"chokidar": "^3.5.3",
"@types/semver": "^7.5.8",
"consola": "^3.2.3",
"cross-env": "^7.0.3",
"dotenv": "^16.3.2",
"electron": "30.0.6",
"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": "3.2.5",
"semver": "^7.5.4",
"tsx": "^4.7.1",
"typescript": "^5.3.3",
"vite": "^5.0.12"
"del": "^7.1.0",
"dotenv": "^16.4.5",
"electron": "30.0.8",
"electron-builder": "^24.13.3",
"electron-updater": "^6.1.8",
"esbuild": "^0.21.4",
"esbuild-register": "^3.5.0",
"eslint": "^9.3.0",
"glob": "^10.4.1",
"globals": "^15.3.0",
"gulp": "^5.0.0",
"prebuild-install": "^7.1.2",
"semver": "^7.6.2",
"typescript": "^5.4.5",
"typescript-eslint": "^7.10.0"
},
"dependencies": {
"@cliqz/adblocker-electron": "^1.27.3",
"@cliqz/adblocker-electron-preload": "^1.27.3",
"axios": "^1.6.5",
"better-sqlite3": "^9.3.0",
"@cliqz/adblocker-electron": "1.27.3",
"@cliqz/adblocker-electron-preload": "1.27.3",
"axios": "^1.7.2",
"better-sqlite3": "^10.0.0",
"cheerio": "1.0.0-rc.12",
"dayjs": "^1.11.10",
"dayjs": "^1.11.11",
"electron-devtools-installer": "^3.2.0",
"electron-is-dev": "^3.0.1",
"electron-log": "^5.0.3",
"electron-store": "^8.1.0",
"execa": "^8.0.1",
"electron-log": "^5.1.5",
"electron-store": "^9.0.0",
"execa": "^9.1.0",
"fs-extra": "^11.2.0",
"inversify": "^6.0.2",
"lint-staged": "^15.2.2",
"lint-staged": "^15.2.5",
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"nanoid": "^5.0.7",
"node-fetch": "^3.3.2",
"node-machine-id": "^1.1.12",
"node-pty": "^1.0.0",
"reflect-metadata": "^0.2.1",
"reflect-metadata": "^0.2.2",
"strip-ansi": "^7.1.0",
"typeorm": "^0.3.19"
"typeorm": "0.3.20"
}
}
-59
View File
@@ -1,59 +0,0 @@
import * as esbuild from "esbuild";
import {
mainResolve,
loadDotEnvDefined,
copyResource,
removeResource,
} from "./utils";
import { external } from "./config";
const mainDefined = loadDotEnvDefined();
removeResource([mainResolve("app/build")]);
removeResource([mainResolve("app/bin")]);
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"),
},
]);
esbuild.build({
entryPoints: [mainResolve("src/index.ts")],
bundle: true,
platform: "node",
sourcemap: false,
target: ["node16.13"],
external,
define: {
"process.env.NODE_ENV": '"production"',
...mainDefined,
},
outdir: mainResolve("app/build/main"),
loader: { ".png": "file" },
minify: true,
plugins: [],
});
esbuild.build({
entryPoints: [mainResolve("src/preload.ts")],
bundle: true,
platform: "browser",
target: ["chrome89"],
sourcemap: false,
external,
define: {
"process.env.NODE_ENV": '"production"',
...mainDefined,
},
outdir: mainResolve("app/build/main"),
loader: { ".png": "file" },
minify: true,
});
+131 -1
View File
@@ -1,4 +1,8 @@
export const external = [
import { Configuration } from "electron-builder";
import { Env, isDev, mainResolve } from "./utils";
import esbuild from "esbuild";
const external = [
"electron",
"nock",
"aws-sdk",
@@ -6,3 +10,129 @@ export const external = [
"@cliqz/adblocker-electron-preload",
"node-pty",
];
function getConfig(): esbuild.BuildOptions {
const getDefine = (): Record<string, string> => {
if (isDev) {
return {
__bin__: `"${mainResolve("app/bin").replace(/\\/g, "\\\\")}"`,
};
}
return {
...Env.getInstance().loadDotEnvDefined(),
"process.env.NODE_ENV": '"production"',
};
};
return {
bundle: true,
sourcemap: process.env.NODE_ENV === "development",
external,
define: getDefine(),
outdir: mainResolve("app/build/main"),
loader: { ".png": "file" },
minify: process.env.NODE_ENV === "production",
};
}
function buildOptions(
entry: string,
platform: esbuild.Platform,
target: string
): esbuild.BuildOptions {
return {
...getConfig(),
entryPoints: [mainResolve(entry)],
platform: platform,
target: [target],
};
}
export function browserOptions(entry: string): esbuild.BuildOptions {
return buildOptions(entry, "browser", "chrome89");
}
export function nodeOptions(entry: string): esbuild.BuildOptions {
return buildOptions(entry, "node", "node16.13");
}
export function getReleaseConfig(): Configuration {
return {
productName: process.env.APP_NAME,
buildVersion: process.env.APP_VERSION,
appId: process.env.APP_ID,
copyright: process.env.APP_COPYRIGHT,
artifactName: "${productName}-setup-${arch}-${buildVersion}.${ext}",
npmRebuild: true,
directories: {
output: "./release",
},
files: [
{
from: "./build",
to: "./",
},
"./package.json",
],
extraResources: [
{
from: "./app/plugin",
to: "plugin",
},
{
from: "./app/bin/",
to: "bin",
},
],
win: {
icon: "../assets/icon.ico",
target: [
{
target: "nsis",
arch: ["x64"],
},
],
},
dmg: {
contents: [
{
x: 410,
y: 150,
type: "link",
path: "/Applications",
},
{
x: 130,
y: 150,
type: "file",
},
],
},
mac: {
icon: "../assets/icon.icns",
target: [
{
target: "dmg",
arch: ["x64", "arm64"],
},
],
},
linux: {
category: "Utility",
icon: "../assets/icon.icns",
maintainer: "caorushizi <84996057@qq.com>",
target: {
target: "deb",
arch: ["x64"],
},
},
nsis: {
oneClick: false,
allowElevation: true,
allowToChangeInstallationDirectory: true,
createDesktopShortcut: true,
createStartMenuShortcut: true,
},
};
}
-101
View File
@@ -1,101 +0,0 @@
import { ChildProcessWithoutNullStreams, spawn } from "child_process";
import electron from "electron";
import * as esbuild from "esbuild";
import chokidar from "chokidar";
import { loadDotEnvRuntime, mainResolve, copyResource } from "./utils";
import { external } from "./config";
import consola from "consola";
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) => {
consola.log(String(data));
});
electronProcess.stderr.on("data", (data) => {
consola.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();
consola.log("watch build succeed.");
restartElectron();
});
try {
await mainContext.rebuild();
await preloadContext.rebuild();
await copySource();
await startElectron();
} catch (e) {
console.error(e);
process.exit();
}
}
start();
+98
View File
@@ -0,0 +1,98 @@
import { deleteSync } from "del";
import { ElectronApp, mainResolve, Env, isWin } from "./utils";
import gulp from "gulp";
import * as esbuild from "esbuild";
import consola from "consola";
import { browserOptions, nodeOptions, getReleaseConfig } from "./config";
import semver from "semver";
import pkg from "../app/package.json";
import * as builder from "electron-builder";
import { globSync } from "glob";
import fs from "fs";
const env = Env.getInstance();
env.loadDotEnvRuntime();
async function clean() {
return deleteSync([
mainResolve("app/build"),
mainResolve("app/bin"),
mainResolve("release"),
]);
}
async function copySqlite() {
const path = "build/Release/better_sqlite3.node";
const source = mainResolve("node_modules/better-sqlite3", path);
const target = mainResolve("app", path);
fs.cpSync(source, target, { recursive: true });
}
async function copyBin() {
const source = mainResolve("bin", process.platform);
const target = mainResolve("app/bin");
fs.cpSync(source, target, { recursive: true });
}
async function chmodBin() {
// 遍历文件夹下的所有文件,将文件的权限设置为 777
if (isWin) return;
const files = globSync(mainResolve("app/bin/*"));
for (const file of files) {
fs.chmodSync(file, 0o777);
}
}
const copy = gulp.parallel(copyBin, copySqlite);
async function watchTask() {
const app = new ElectronApp();
const main = await esbuild.context(nodeOptions("src/index.ts"));
const preload = await esbuild.context(browserOptions("src/preload.ts"));
const watcher = gulp.watch(["./src"]);
watcher
.on("ready", async () => {
await main.rebuild();
await preload.rebuild();
app.start();
})
.on("change", async () => {
await main.rebuild();
await preload.rebuild();
app.restart();
})
.on("error", (error) => {
consola.error(error);
});
return Promise.resolve();
}
async function buildTask() {
await esbuild.build(nodeOptions("src/index.ts"));
await esbuild.build(browserOptions("src/preload.ts"));
}
async function pack() {
if (semver.neq(process.env.APP_VERSION || "", pkg.version)) {
throw new Error("请先同步构建版本和发布版本");
}
const config = getReleaseConfig();
if (process.env.GH_TOKEN) {
config.publish = {
provider: "github",
repo: "mediago",
owner: "caorushizi",
releaseType: "draft",
};
}
await builder.build({ config });
}
// 开发环境
export const dev = gulp.series(clean, copy, chmodBin, watchTask);
// 构建打包
export const build = gulp.series(clean, copy, chmodBin, buildTask);
// release
export const release = gulp.series(pack);
-115
View File
@@ -1,115 +0,0 @@
import { loadDotEnvRuntime, mainResolve, removeResource } from "./utils";
import * as builder from "electron-builder";
import semver from "semver";
import pkg from "../app/package.json";
import consola from "consola";
removeResource([mainResolve("release")]);
loadDotEnvRuntime();
if (semver.neq(process.env.APP_VERSION || "", pkg.version)) {
consola.log("请先同步构建版本和发布版本");
process.exit(0);
}
const options: builder.Configuration = {
productName: process.env.APP_NAME,
buildVersion: process.env.APP_VERSION,
appId: process.env.APP_ID,
copyright: process.env.APP_COPYRIGHT,
artifactName: "${productName}-setup-${buildVersion}.${ext}",
// FIXME: 这里屏蔽 node-pty 自动重构,因为会导致打包失败
npmRebuild: false,
directories: {
output: "./release",
},
files: [
{
from: "./build",
to: "./",
},
"./package.json",
],
extraResources: [
{
from: "./app/plugin",
to: "plugin",
},
{
from: "./app/bin/${platform}/",
to: "bin",
},
],
win: {
icon: "../assets/icon.ico",
target: [
{
target: "nsis",
},
],
},
dmg: {
contents: [
{
x: 410,
y: 150,
type: "link",
path: "/Applications",
},
{
x: 130,
y: 150,
type: "file",
},
],
},
mac: {
icon: "../assets/icon.icns",
target: {
target: "default",
arch: ["x64", "arm64"],
},
},
linux: {},
nsis: {
oneClick: true,
allowElevation: true,
allowToChangeInstallationDirectory: false,
installerIcon: "",
uninstallerIcon: "",
installerHeaderIcon: "",
createDesktopShortcut: true,
createStartMenuShortcut: true,
shortcutName: "",
include: "",
script: "",
},
publish: {
provider: "github",
repo: "mediago",
owner: "caorushizi",
releaseType: "release",
},
};
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) {
consola.log(e);
}
}
start();
+93 -57
View File
@@ -1,72 +1,108 @@
import { existsSync, cpSync, rmSync } from "fs";
import { ChildProcessWithoutNullStreams, spawn } from "child_process";
import dotenv from "dotenv";
import { resolve } from "path";
import consola from "consola";
import fs from "fs-extra";
import electron from "electron";
export const mainResolve = (...r: string[]) => resolve(__dirname, "..", ...r);
export const rootResolve = (...r: string[]) =>
resolve(__dirname, "../../..", ...r);
const nodeEnv = process.env.NODE_ENV;
consola.log("当前的环境是: ", nodeEnv);
const baseResolve = (...r) => resolve(__dirname, ...r);
export const mainResolve = (...r: string[]) => baseResolve("..", ...r);
export const rootResolve = (...r: string[]) => baseResolve("../../..", ...r);
function loadEnv(path: string) {
const result: Record<string, string> = {};
export const isDev = process.env.NODE_ENV === "development";
if (!existsSync(path)) {
return null;
}
export const isLinux = process.platform === "linux";
export const isMac = process.platform === "darwin";
export const isWin = process.platform === "win32";
const parsed = dotenv.parse(fs.readFileSync(path));
if (!parsed) {
return null;
}
export class ElectronApp {
process: ChildProcessWithoutNullStreams | null = null;
Object.keys(parsed).forEach((key) => {
result[key] = parsed[key];
});
start() {
const args = ["--inspect=5858", mainResolve("app/build/main/index.js")];
return result;
}
this.process = spawn(String(electron), args);
function loadDotEnv() {
const env = loadEnv(rootResolve(".env"));
const envMode = loadEnv(rootResolve(`.env.${nodeEnv}`));
const envModeLocal = loadEnv(rootResolve(`.env.${nodeEnv}.local`));
return { ...env, ...envMode, ...envModeLocal };
}
export function loadDotEnvRuntime() {
const env = loadDotEnv();
Object.keys(env).forEach((key) => {
if (process.env[key] != null || !env[key]) return;
process.env[key] = env[key];
});
}
export function loadDotEnvDefined() {
const env = loadDotEnv();
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: { from: string; to: string }[]) {
resource.forEach((r) => {
const { from, to } = r;
cpSync(from, to, {
recursive: true,
this.process.stdout.on("data", (data) => {
consola.log(String(data));
});
});
this.process.stderr.on("data", (data) => {
consola.log(String(data));
});
}
restart() {
this.kill();
this.start();
}
kill() {
if (this.process && this.process.pid) {
if (isMac) {
spawn("kill", ["-9", String(this.process.pid)]);
} else {
process.kill(this.process.pid);
}
this.process = null;
}
}
}
export function removeResource(resource: string[]) {
resource.forEach((r) => {
rmSync(r, { recursive: true, force: true });
});
export class Env {
env: Record<string, string> = {};
nodeEnv = "";
static instance: Env;
private constructor(nodeEnv = process.env.NODE_ENV) {
const env = this.parseEnv(rootResolve(".env"));
const modeEnv = this.parseEnv(rootResolve(`.env.${nodeEnv}`));
const localEnv = this.parseEnv(rootResolve(`.env.${nodeEnv}.local`));
this.nodeEnv = nodeEnv || "development";
this.env = { ...env, ...modeEnv, ...localEnv };
}
static getInstance() {
if (!Env.instance) {
Env.instance = new Env();
}
return Env.instance;
}
private parseEnv(path: string) {
if (!fs.existsSync(path)) {
return null;
}
const parsed = dotenv.parse(fs.readFileSync(path));
if (!parsed) {
return null;
}
return Object.keys(parsed).reduce((prev, curr) => {
prev[curr] = parsed[curr];
return prev;
}, {});
}
loadDotEnvRuntime() {
Object.keys(this.env).forEach((key) => {
if (process.env[key] != null || !this.env[key]) return;
process.env[key] = this.env[key];
});
}
loadDotEnvDefined() {
return Object.keys(this.env).reduce<Record<string, string>>((prev, cur) => {
if (!cur.startsWith("APP_")) return prev;
prev[`process.env.${[cur]}`] = JSON.stringify(this.env[cur]);
return prev;
}, {});
}
get isDev() {
return this.nodeEnv === "development";
}
}
+13 -14
View File
@@ -1,18 +1,18 @@
import { inject, injectable } from "inversify";
import { DownloadStatus } from "./interfaces";
import { TYPES } from "./types";
import { DownloadStatus } from "./interfaces.ts";
import { TYPES } from "./types.ts";
import { Menu, Tray, app, nativeImage, nativeTheme } from "electron";
import TrayIcon from "./tray-icon.png";
import path from "path";
import MainWindow from "./windows/MainWindow";
import WebviewService from "./services/WebviewService";
import VideoRepository from "./repository/VideoRepository";
import ElectronDevtools from "./vendor/ElectronDevtools";
import ElectronStore from "./vendor/ElectronStore";
import ElectronUpdater from "./vendor/ElectronUpdater";
import TypeORM from "./vendor/TypeORM";
import ProtocolService from "./core/protocol";
import IpcHandlerService from "./core/ipc";
import MainWindow from "./windows/MainWindow.ts";
import WebviewService from "./services/WebviewService.ts";
import VideoRepository from "./repository/VideoRepository.ts";
import ElectronDevtools from "./vendor/ElectronDevtools.ts";
import ElectronStore from "./vendor/ElectronStore.ts";
import ElectronUpdater from "./vendor/ElectronUpdater.ts";
import TypeORM from "./vendor/TypeORM.ts";
import ProtocolService from "./core/protocol.ts";
import IpcHandlerService from "./core/ipc.ts";
@injectable()
export default class ElectronApp {
@@ -34,12 +34,11 @@ export default class ElectronApp {
@inject(TYPES.ElectronDevtools)
private readonly devTools: ElectronDevtools,
@inject(TYPES.ElectronStore)
private readonly store: ElectronStore,
private readonly store: ElectronStore
) {}
private async serviceInit(): Promise<void> {
this.mainWindow.init();
this.webview.init();
}
private async vendorInit() {
@@ -94,7 +93,7 @@ export default class ElectronApp {
const videoIds = videos.map((video) => video.id);
await this.videoRepository.changeVideoStatus(
videoIds,
DownloadStatus.Failed,
DownloadStatus.Failed
);
}
}
@@ -0,0 +1,30 @@
import { inject, injectable } from "inversify";
import { ConversionPagination, type Controller } from "../interfaces.ts";
import { TYPES } from "../types.ts";
import ConversionRepository from "../repository/ConversionRepository.ts";
import { handle } from "../helper/decorator.ts";
import { Conversion } from "../entity/Conversion.ts";
import { IpcMainEvent } from "electron/main";
@injectable()
export default class ConversionController implements Controller {
constructor(
@inject(TYPES.ConversionRepository)
private readonly conversionRepository: ConversionRepository
) {}
@handle("get-conversions")
async getConversions(e: IpcMainEvent, pagination: ConversionPagination) {
return await this.conversionRepository.getConversions(pagination);
}
@handle("add-conversion")
async addConversion(e: IpcMainEvent, conversion: Conversion) {
return await this.conversionRepository.addConversion(conversion);
}
@handle("delete-conversion")
async deleteConversion(e: IpcMainEvent, id: number) {
return await this.conversionRepository.deleteConversion(id);
}
}
@@ -1,19 +1,19 @@
import { IpcMainEvent } from "electron/main";
import { inject, injectable } from "inversify";
import { handle } from "../helper";
import { handle } from "../helper/index.ts";
import {
type Controller,
DownloadItem,
DownloadItemPagination,
Task,
DownloadStatus,
} from "../interfaces";
import { TYPES } from "../types";
import MainWindow from "../windows/MainWindow";
import ElectronStore from "../vendor/ElectronStore";
import DownloadService from "../services/DownloadService";
import VideoRepository from "../repository/VideoRepository";
import WebviewService from "../services/WebviewService";
} from "../interfaces.ts";
import { TYPES } from "../types.ts";
import MainWindow from "../windows/MainWindow.ts";
import ElectronStore from "../vendor/ElectronStore.ts";
import DownloadService from "../services/DownloadService.ts";
import VideoRepository from "../repository/VideoRepository.ts";
import WebviewService from "../services/WebviewService.ts";
@injectable()
export default class DownloadController implements Controller {
@@ -27,7 +27,7 @@ export default class DownloadController implements Controller {
@inject(TYPES.MainWindow)
private readonly mainWindow: MainWindow,
@inject(TYPES.WebviewService)
private readonly webviewService: WebviewService,
private readonly webviewService: WebviewService
) {}
@handle("show-download-dialog")
@@ -36,12 +36,12 @@ export default class DownloadController implements Controller {
this.webviewService.sendToWindow(
"show-download-dialog",
data,
image.toDataURL(),
image.toDataURL()
);
}
@handle("add-download-item")
async addDownloadItem(e: IpcMainEvent, video: DownloadItem) {
async addDownloadItem(e: IpcMainEvent, video: Omit<DownloadItem, "id">) {
const item = await this.videoRepository.addVideo(video);
// 这里向页面发送消息,通知页面更新
this.mainWindow.send("download-item-notifier", item);
@@ -49,7 +49,7 @@ export default class DownloadController implements Controller {
}
@handle("add-download-items")
async addDownloadItems(e: IpcMainEvent, videos: DownloadItem[]) {
async addDownloadItems(e: IpcMainEvent, videos: Omit<DownloadItem, "id">[]) {
const items = await this.videoRepository.addVideos(videos);
// 这里向页面发送消息,通知页面更新
this.mainWindow.send("download-item-notifier", items);
@@ -62,8 +62,15 @@ export default class DownloadController implements Controller {
return item;
}
@handle("edit-download-now")
async editDownloadNow(e: IpcMainEvent, video: DownloadItem) {
const item = await this.editDownloadItem(e, video);
await this.startDownload(e, item.id);
return item;
}
@handle("download-now")
async downloadNow(e: IpcMainEvent, video: DownloadItem) {
async downloadNow(e: IpcMainEvent, video: Omit<DownloadItem, "id">) {
// 添加下载项
const item = await this.addDownloadItem(e, video);
// 开始下载
@@ -71,6 +78,15 @@ export default class DownloadController implements Controller {
return item;
}
@handle("download-items-now")
async downloadItemsNow(e: IpcMainEvent, videos: Omit<DownloadItem, "id">[]) {
// 添加下载项
const items = await this.addDownloadItems(e, videos);
// 开始下载
items.forEach((item) => this.startDownload(e, item.id));
return items;
}
@handle("get-download-items")
async getDownloadItems(e: IpcMainEvent, pagination: DownloadItemPagination) {
const videos = await this.videoRepository.findVideos(pagination);
+58 -16
View File
@@ -8,25 +8,28 @@ import {
shell,
clipboard,
} from "electron";
import { Favorite } from "../entity/Favorite";
import { convertToAudio, db, workspace } from "../helper";
import { Favorite } from "../entity/Favorite.ts";
import { convertToAudio, db, workspace } from "../helper/index.ts";
import { inject, injectable } from "inversify";
import { AppStore, EnvPath } from "../main";
import { AppStore, EnvPath } from "../main.ts";
import path from "path";
import { handle, getLocalIP } from "../helper";
import { type Controller } from "../interfaces";
import { TYPES } from "../types";
import { handle, getLocalIP } from "../helper/index.ts";
import { type Controller } from "../interfaces.ts";
import { TYPES } from "../types.ts";
import fs from "fs-extra";
import MainWindow from "../windows/MainWindow";
import BrowserWindow from "../windows/BrowserWindow";
import ElectronStore from "../vendor/ElectronStore";
import WebviewService from "../services/WebviewService";
import FavoriteRepository from "../repository/FavoriteRepository";
import VideoRepository from "../repository/VideoRepository";
import MainWindow from "../windows/MainWindow.ts";
import BrowserWindow from "../windows/BrowserWindow.ts";
import ElectronStore from "../vendor/ElectronStore.ts";
import WebviewService from "../services/WebviewService.ts";
import FavoriteRepository from "../repository/FavoriteRepository.ts";
import VideoRepository from "../repository/VideoRepository.ts";
import ConversionRepository from "../repository/ConversionRepository.ts";
import { machineId } from "node-machine-id";
import { nanoid } from "nanoid";
@injectable()
export default class HomeController implements Controller {
private sharedState: Record<string, any> = {};
private sharedState: Record<string, unknown> = {};
constructor(
@inject(TYPES.ElectronStore)
@@ -41,6 +44,8 @@ export default class HomeController implements Controller {
private readonly browserWindow: BrowserWindow,
@inject(TYPES.WebviewService)
private readonly webviewService: WebviewService,
@inject(TYPES.ConversionRepository)
private readonly conversionRepository: ConversionRepository
) {}
@handle("get-env-path")
@@ -143,6 +148,10 @@ export default class HomeController implements Controller {
if (key === "isMobile") {
this.webviewService.setUserAgent(val);
}
// privacy
if (key === "privacy") {
this.webviewService.setDefaultSession(val);
}
this.store.set(key, val);
}
@@ -206,10 +215,11 @@ export default class HomeController implements Controller {
@handle("convert-to-audio")
async convertToAudio(e: IpcMainEvent, id: number) {
const video = await this.videoRepository.findVideo(id);
const conversion = await this.conversionRepository.findConversion(id);
const local = this.store.get("local");
const input = path.join(local, `${video.name}.mp4`);
const output = path.join(local, `${video.name}.mp3`);
const input = conversion.path;
const outName = `${path.basename(input, path.extname(input))}.mp3`;
const output = path.join(local, outName);
const exist = await fs.exists(input);
if (exist) {
@@ -252,4 +262,36 @@ export default class HomeController implements Controller {
const video = await this.videoRepository.findVideo(id);
return video.log || "";
}
@handle("select-file")
async selectFile() {
const window = this.mainWindow.window;
if (!window) return Promise.reject("未找到主窗口");
const result = await dialog.showOpenDialog(window, {
properties: ["openFile"],
});
if (!result.canceled) {
return result.filePaths[0];
}
return "";
}
@handle("get-machine-id")
async getMachineId() {
try {
const id = this.store.get("machineId");
if (id) return id;
const newId = await machineId();
this.store.set("machineId", newId);
return newId;
} catch (e) {
const id = this.store.get("machineId");
if (id) return id;
const newId = nanoid();
this.store.set("machineId", newId);
return newId;
}
}
}
@@ -5,12 +5,12 @@ import {
MenuItemConstructorOptions,
} from "electron";
import { inject, injectable } from "inversify";
import { handle } from "../helper";
import { type Controller } from "../interfaces";
import { TYPES } from "../types";
import WebviewService from "../services/WebviewService";
import ElectronStore from "../vendor/ElectronStore";
import { SniffingHelper } from "../services/SniffingHelperService";
import { handle } from "../helper/index.ts";
import { type Controller } from "../interfaces.ts";
import { TYPES } from "../types.ts";
import WebviewService from "../services/WebviewService.ts";
import ElectronStore from "../vendor/ElectronStore.ts";
import { SniffingHelper } from "../services/SniffingHelperService.ts";
@injectable()
export default class WebviewController implements Controller {
@@ -20,7 +20,7 @@ export default class WebviewController implements Controller {
@inject(TYPES.ElectronStore)
private readonly store: ElectronStore,
@inject(TYPES.SniffingHelper)
private readonly sniffingHelper: SniffingHelper,
private readonly sniffingHelper: SniffingHelper
) {}
@handle("set-webview-bounds")
@@ -29,7 +29,7 @@ export default class WebviewController implements Controller {
}
@handle("webview-load-url")
async browserViewLoadUrl(e: IpcMainEvent, url?: string): Promise<void> {
async browserViewLoadUrl(e: IpcMainEvent, url: string): Promise<void> {
await this.webview.loadURL(url);
}
@@ -85,4 +85,9 @@ export default class WebviewController implements Controller {
async pluginReady() {
this.sniffingHelper.pluginReady();
}
@handle("clear-webview-cache")
async clearWebviewCache() {
return this.webview.clearCache();
}
}
+4 -4
View File
@@ -1,9 +1,9 @@
import { ipcMain } from "electron";
import { inject, injectable, multiInject } from "inversify";
import { Controller } from "../interfaces";
import { TYPES } from "../types";
import ElectronLogger from "../vendor/ElectronLogger";
import { error, success } from "../helper/utils";
import { Controller } from "../interfaces.ts";
import { TYPES } from "../types.ts";
import ElectronLogger from "../vendor/ElectronLogger.ts";
import { error, success } from "../helper/utils.ts";
@injectable()
export default class IpcHandlerService {
+1 -1
View File
@@ -4,7 +4,7 @@ import { pathExists, readFile } from "fs-extra";
import { injectable } from "inversify";
import { join } from "path";
import { URL } from "url";
import { defaultScheme } from "../helper";
import { defaultScheme } from "../helper/index.ts";
import mime from "mime-types";
@injectable()
+34
View File
@@ -0,0 +1,34 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from "typeorm";
@Entity({
name: "conversion",
})
export class Conversion {
@PrimaryGeneratedColumn()
id: number;
@Column({
type: "text",
nullable: true,
})
name: string;
@Column({
type: "text",
nullable: false,
default: "",
})
path: string;
@CreateDateColumn()
createdDate?: Date;
@UpdateDateColumn()
updatedDate?: Date;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { DownloadStatus, DownloadType } from "../interfaces";
import { DownloadStatus, DownloadType } from "../interfaces.ts";
import {
Column,
CreateDateColumn,
+1 -1
View File
@@ -1,5 +1,5 @@
import { spawn } from "child_process";
import { ffmpegPath } from "./variables";
import { ffmpegPath } from "./variables.ts";
export const convertToAudio = async (
input: string,
+4 -4
View File
@@ -34,8 +34,8 @@ function fetchWrapper(url: string) {
return fetch(url, options);
}
export { sleep, formatHeaders } from "./utils";
export * from "./variables";
export { on, handle } from "./decorator";
export { convertToAudio } from "./ffmpeg";
export { sleep, formatHeaders } from "./utils.ts";
export * from "./variables.ts";
export { on, handle } from "./decorator.ts";
export { convertToAudio } from "./ffmpeg.ts";
export { fetchWrapper as fetch };
+2 -3
View File
@@ -4,12 +4,11 @@ export async function sleep(second = 1): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, second * 1000));
}
export function formatHeaders(headersStr: string): string {
const headers: Record<string, any> | null = JSON.parse(headersStr);
export function formatHeaders(headers: Record<string, string>): string {
if (!headers) return "";
const formatted = Object.entries(headers)
.map(([key, value]) => `${key}:${value}`)
.join("|");
.join("\n");
return formatted;
}
+11 -5
View File
@@ -6,8 +6,15 @@ 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";
export enum Platform {
Windows = "win32",
MacOS = "darwin",
Linux = "linux",
}
export const isMac = process.platform === Platform.MacOS;
export const isWin = process.platform === Platform.Windows;
export const isLinux = process.platform === Platform.Linux;
if (!isDev) {
global.__bin__ = resolve(appPath, "../bin");
@@ -29,14 +36,13 @@ export const workspace = resolve(appData, appName);
export const defaultScheme = "mediago";
export const PERSIST_MEDIAGO = "persist:mediago";
export const PERSIST_WEBVIEW = "persist:webview";
export const PRIVACY_WEBVIEW = "webview";
export const db = resolve(workspace, "app.db");
// bin path
const downloaderBinName = isWin ? "N_m3u8DL-CLI" : "N_m3u8DL-RE";
export const ffmpegPath = resolveBin("ffmpeg");
export const biliDownloaderBin = resolveBin("BBDown");
export const m3u8DownloaderBin = resolveBin(downloaderBinName);
export const videoServerBin = resolveBin("server");
export const m3u8DownloaderBin = resolveBin("N_m3u8DL-RE");
// plugin path
export const pluginPath = resolveStatic("plugin/index.js");
+4 -4
View File
@@ -1,9 +1,9 @@
import "reflect-metadata";
import { app, protocol } from "electron";
import { defaultScheme } from "./helper";
import { container } from "./inversify.config";
import { TYPES } from "./types";
import ElectronApp from "./app";
import { defaultScheme } from "./helper/index.ts";
import { container } from "./inversify.config.ts";
import { TYPES } from "./types.ts";
import ElectronApp from "./app.ts";
const gotTheLock = app.requestSingleInstanceLock();
const start = async (): Promise<void> => {
+12
View File
@@ -1,3 +1,5 @@
import { Conversion } from "./entity/Conversion.ts";
export type Controller = Record<string | symbol, any>;
export interface DownloadItem {
@@ -21,11 +23,21 @@ export interface DownloadItemPagination {
filter?: DownloadFilter;
}
export interface ConversionPagination {
current?: number;
pageSize?: number;
}
export interface VideoResponse {
total: number;
list: DownloadItem[];
}
export interface ConversionResponse {
total: number;
list: Conversion[];
}
export enum DownloadStatus {
Ready = "ready",
Watting = "watting",
+26 -20
View File
@@ -1,24 +1,26 @@
import DownloadController from "./controller/DownloadController";
import HomeController from "./controller/HomeController";
import WebviewController from "./controller/WebviewController";
import DownloadController from "./controller/DownloadController.ts";
import HomeController from "./controller/HomeController.ts";
import WebviewController from "./controller/WebviewController.ts";
import { Container } from "inversify";
import FavoriteRepository from "./repository/FavoriteRepository";
import VideoRepository from "./repository/VideoRepository";
import WebviewService from "./services/WebviewService";
import ElectronApp from "./app";
import { Controller } from "./interfaces";
import { TYPES } from "./types";
import MainWindow from "./windows/MainWindow";
import BrowserWindow from "./windows/BrowserWindow";
import { SniffingHelper } from "./services/SniffingHelperService";
import DownloadService from "./services/DownloadService";
import ElectronLogger from "./vendor/ElectronLogger";
import ElectronUpdater from "./vendor/ElectronUpdater";
import TypeORM from "./vendor/TypeORM";
import ElectronDevtools from "./vendor/ElectronDevtools";
import ElectronStore from "./vendor/ElectronStore";
import IpcHandler from "./core/ipc";
import ProtocolService from "./core/protocol";
import FavoriteRepository from "./repository/FavoriteRepository.ts";
import VideoRepository from "./repository/VideoRepository.ts";
import WebviewService from "./services/WebviewService.ts";
import ElectronApp from "./app.ts";
import { Controller } from "./interfaces.ts";
import { TYPES } from "./types.ts";
import MainWindow from "./windows/MainWindow.ts";
import BrowserWindow from "./windows/BrowserWindow.ts";
import { SniffingHelper } from "./services/SniffingHelperService.ts";
import DownloadService from "./services/DownloadService.ts";
import ElectronLogger from "./vendor/ElectronLogger.ts";
import ElectronUpdater from "./vendor/ElectronUpdater.ts";
import TypeORM from "./vendor/TypeORM.ts";
import ElectronDevtools from "./vendor/ElectronDevtools.ts";
import ElectronStore from "./vendor/ElectronStore.ts";
import IpcHandler from "./core/ipc.ts";
import ProtocolService from "./core/protocol.ts";
import ConversionController from "./controller/ConversionController.ts";
import ConversionRepository from "./repository/ConversionRepository.ts";
const container = new Container({
skipBaseClassChecks: true,
@@ -41,12 +43,16 @@ container.bind<BrowserWindow>(TYPES.BrowserWindow).to(BrowserWindow);
container.bind<Controller>(TYPES.Controller).to(HomeController);
container.bind<Controller>(TYPES.Controller).to(WebviewController);
container.bind<Controller>(TYPES.Controller).to(DownloadController);
container.bind<Controller>(TYPES.Controller).to(ConversionController);
// repository
container.bind<VideoRepository>(TYPES.VideoRepository).to(VideoRepository);
container
.bind<FavoriteRepository>(TYPES.FavoriteRepository)
.to(FavoriteRepository);
container
.bind<ConversionRepository>(TYPES.ConversionRepository)
.to(ConversionRepository);
// vendor
container.bind<ElectronDevtools>(TYPES.ElectronDevtools).to(ElectronDevtools);
+6
View File
@@ -52,6 +52,12 @@ declare interface AppStore {
showTerminal: boolean;
// 隐私模式
privacy: boolean;
// 机器id
machineId: string;
// 下载代理设置
downloadProxySwitch: boolean;
// 自动更新
autoUpgrade: boolean;
}
declare interface BrowserStore {
+38 -11
View File
@@ -1,13 +1,16 @@
import { contextBridge, ipcRenderer } from "electron/renderer";
import { shell } from "electron/common";
import { AppStore, BrowserStore, EnvPath } from "./main";
import { type Favorite } from "./entity/Favorite";
import { AppStore, BrowserStore, EnvPath } from "./main.ts";
import { type Favorite } from "./entity/Favorite.ts";
import {
ConversionPagination,
ConversionResponse,
VideoResponse,
type DownloadItem,
type DownloadItemPagination,
} from "./interfaces";
import { Video } from "./entity/Video";
} from "./interfaces.ts";
import { Video } from "./entity/Video.ts";
import { Conversion } from "./entity/Conversion.ts";
const apiFunctions: Record<string, any> = {};
@@ -23,7 +26,7 @@ const electronApi = {
return ipcRenderer.invoke("get-favorites");
},
addFavorite(
favorite: Omit<Favorite, "id" | "createdDate" | "updatedDate">,
favorite: Omit<Favorite, "id" | "createdDate" | "updatedDate">
): Promise<Favorite> {
return ipcRenderer.invoke("add-favorite", favorite);
},
@@ -53,17 +56,18 @@ const electronApi = {
},
setAppStore(
key: keyof AppStore,
val: AppStore[keyof AppStore],
val: AppStore[keyof AppStore]
): Promise<void> {
return ipcRenderer.invoke("set-app-store", key, val);
},
openDir(dir: string): Promise<void> {
async openDir(dir?: string): Promise<void> {
if (!dir) return;
return ipcRenderer.invoke("open-dir", dir);
},
addDownloadItem(video: Omit<DownloadItem, "id">): Promise<Video> {
return ipcRenderer.invoke("add-download-item", video);
},
addDownloadItems(videos: DownloadItem[]): Promise<Video[]> {
addDownloadItems(videos: Omit<DownloadItem, "id">[]): Promise<Video[]> {
return ipcRenderer.invoke("add-download-items", videos);
},
getDownloadItems(p: DownloadItemPagination): Promise<VideoResponse> {
@@ -116,6 +120,12 @@ const electronApi = {
downloadNow(video: Omit<DownloadItem, "id">): Promise<void> {
return ipcRenderer.invoke("download-now", video);
},
downloadItemsNow(videos: Omit<DownloadItem, "id">[]): Promise<void> {
return ipcRenderer.invoke("download-items-now", videos);
},
editDownloadNow(video: DownloadItem): Promise<void> {
return ipcRenderer.invoke("edit-download-now", video);
},
combineToHomePage(store: BrowserStore): Promise<void> {
return ipcRenderer.invoke("combine-to-home-page", store);
},
@@ -128,6 +138,9 @@ const electronApi = {
openBrowser(url: string): Promise<void> {
return shell.openExternal(url);
},
selectFile(): Promise<string> {
return ipcRenderer.invoke("select-file");
},
getSharedState(): Promise<any> {
return ipcRenderer.invoke("get-shared-state");
},
@@ -137,9 +150,6 @@ const electronApi = {
setUserAgent(isMobile: boolean): Promise<void> {
return ipcRenderer.invoke("webview-change-user-agent", isMobile);
},
downloadItem(data: Omit<DownloadItem, "id">) {
return ipcRenderer.invoke("add-download-item", data);
},
getDownloadLog(id: number): Promise<string> {
return ipcRenderer.invoke("get-download-log", id);
},
@@ -149,6 +159,23 @@ const electronApi = {
pluginReady() {
return ipcRenderer.invoke("plugin-ready");
},
getConversions(
pagination: ConversionPagination
): Promise<ConversionResponse> {
return ipcRenderer.invoke("get-conversions", pagination);
},
addConversion(conversion: Omit<Conversion, "id">): Promise<Conversion> {
return ipcRenderer.invoke("add-conversion", conversion);
},
deleteConversion(id: number): Promise<void> {
return ipcRenderer.invoke("delete-conversion", id);
},
getMachineId(): Promise<string> {
return ipcRenderer.invoke("get-machine-id");
},
clearWebviewCache(): Promise<void> {
return ipcRenderer.invoke("clear-webview-cache");
},
};
contextBridge.exposeInMainWorld(apiKey, electronApi);
@@ -0,0 +1,50 @@
import { inject, injectable } from "inversify";
import { ConversionPagination } from "../interfaces.ts";
import { TYPES } from "../types.ts";
import TypeORM from "../vendor/TypeORM.ts";
import { Conversion } from "../entity/Conversion.ts";
@injectable()
export default class ConversionRepository {
constructor(
@inject(TYPES.TypeORM)
private readonly db: TypeORM
) {}
async findConversion(id: number) {
const repository = this.db.appDataSource.getRepository(Conversion);
const conversion = await repository.findOneBy({ id });
if (!conversion) {
throw new Error("没有找到该转换任务");
}
return conversion;
}
async getConversions(pagination: ConversionPagination) {
const { current = 0, pageSize = 50 } = pagination;
const [items, count] = await this.db.appDataSource
.getRepository(Conversion)
.findAndCount({
order: {
createdDate: "ASC",
},
skip: (current - 1) * pageSize,
take: pageSize,
});
return {
total: count,
list: items,
};
}
async addConversion(conversion: Conversion) {
await this.db.appDataSource.getRepository(Conversion).save(conversion);
}
async deleteConversion(id: number) {
await this.db.appDataSource.getRepository(Conversion).delete({ id });
}
}
@@ -1,7 +1,7 @@
import { inject, injectable } from "inversify";
import { TYPES } from "../types";
import { Favorite } from "../entity/Favorite";
import TypeORM from "../vendor/TypeORM";
import { TYPES } from "../types.ts";
import { Favorite } from "../entity/Favorite.ts";
import TypeORM from "../vendor/TypeORM.ts";
@injectable()
export default class FavoriteRepository {
+12 -12
View File
@@ -1,20 +1,20 @@
import { Video } from "../entity/Video";
import { Video } from "../entity/Video.ts";
import { inject, injectable } from "inversify";
import {
DownloadFilter,
DownloadItem,
DownloadItemPagination,
DownloadStatus,
} from "../interfaces";
import { TYPES } from "../types";
} from "../interfaces.ts";
import { TYPES } from "../types.ts";
import { In, Not } from "typeorm";
import TypeORM from "../vendor/TypeORM";
import TypeORM from "../vendor/TypeORM.ts";
@injectable()
export default class VideoRepository {
constructor(
@inject(TYPES.TypeORM)
private readonly db: TypeORM,
private readonly db: TypeORM
) {}
async addVideo(video: Omit<DownloadItem, "id">) {
@@ -31,7 +31,7 @@ export default class VideoRepository {
return await this.db.manager.save(item);
}
async addVideos(videos: DownloadItem[]) {
async addVideos(videos: Omit<DownloadItem, "id">[]) {
const items = videos.map((video) => {
const item = new Video();
item.name = video.name;
@@ -114,12 +114,12 @@ export default class VideoRepository {
async changeVideoStatus(id: number | number[], status: DownloadStatus) {
const ids = !Array.isArray(id) ? [id] : id;
return this.db.appDataSource
.createQueryBuilder()
.update(Video)
.set({ status })
.where({ id: In(ids) })
.execute();
const videoRepository = this.db.appDataSource.getRepository(Video);
const videos = await videoRepository.findBy({ id: In(ids) });
for (const video of videos) {
video.status = status;
}
await videoRepository.save(videos);
}
async changeVideoIsLive(id: number) {
+23 -51
View File
@@ -5,12 +5,16 @@ import {
DownloadProgress,
DownloadStatus,
Task,
} from "../interfaces";
import { TYPES } from "../types";
import ElectronLogger from "../vendor/ElectronLogger";
import ElectronStore from "../vendor/ElectronStore";
import VideoRepository from "../repository/VideoRepository";
import { biliDownloaderBin, m3u8DownloaderBin } from "../helper";
} from "../interfaces.ts";
import { TYPES } from "../types.ts";
import ElectronLogger from "../vendor/ElectronLogger.ts";
import ElectronStore from "../vendor/ElectronStore.ts";
import VideoRepository from "../repository/VideoRepository.ts";
import {
Platform,
biliDownloaderBin,
m3u8DownloaderBin,
} from "../helper/index.ts";
import * as pty from "node-pty";
import stripAnsi from "strip-ansi";
@@ -49,39 +53,7 @@ interface Schema {
const processList: Schema[] = [
{
type: "m3u8",
platform: ["win32"],
bin: m3u8DownloaderBin,
args: {
url: {
argsName: null,
},
localDir: {
argsName: ["--workDir"],
},
name: {
argsName: ["--saveName"],
},
// headers: {
// argsName: ["--headers"],
// },
deleteSegments: {
argsName: ["--enableDelAfterDone"],
},
proxy: {
argsName: ["--proxyAddress"],
},
},
consoleReg: {
percent: "([\\d.]+)%",
speed: "([\\d.]+\\s[GMK]B/s)",
error: "ERROR",
start: "开始下载文件|开始录制",
isLive: "识别为直播流, 开始录制",
},
},
{
type: "m3u8",
platform: ["darwin"],
platform: [Platform.MacOS, Platform.Linux, Platform.Windows],
bin: m3u8DownloaderBin,
args: {
url: {
@@ -113,7 +85,7 @@ const processList: Schema[] = [
},
{
type: "bilibili",
platform: ["win32", "darwin"],
platform: [Platform.Linux, Platform.MacOS, Platform.Windows],
bin: biliDownloaderBin,
args: {
url: {
@@ -149,7 +121,7 @@ export default class DownloadService extends EventEmitter {
@inject(TYPES.VideoRepository)
private readonly videoRepository: VideoRepository,
@inject(TYPES.ElectronStore)
private readonly storeService: ElectronStore,
private readonly storeService: ElectronStore
) {
super();
@@ -168,7 +140,6 @@ export default class DownloadService extends EventEmitter {
async stopTask(id: number) {
if (this.signal[id]) {
this.logger.info(`taskId: ${id} stop`);
this.signal[id].abort();
}
}
@@ -177,7 +148,7 @@ export default class DownloadService extends EventEmitter {
try {
await this.videoRepository.changeVideoStatus(
task.id,
DownloadStatus.Downloading,
DownloadStatus.Downloading
);
this.emit("download-start", task.id);
@@ -203,8 +174,8 @@ export default class DownloadService extends EventEmitter {
callback,
};
const { proxy, useProxy } = this.storeService.store;
if (useProxy) {
const { proxy, downloadProxySwitch } = this.storeService.store;
if (downloadProxySwitch && proxy) {
params.proxy = proxy;
}
@@ -214,23 +185,24 @@ export default class DownloadService extends EventEmitter {
await this.videoRepository.changeVideoStatus(
task.id,
DownloadStatus.Success,
DownloadStatus.Success
);
this.emit("download-success", task.id);
} catch (err: any) {
this.logger.info(`taskId: ${task.id} failed`);
if (err.message === "AbortError") {
this.logger.info(`taskId: ${task.id} stopped`);
// 下载暂停
await this.videoRepository.changeVideoStatus(
task.id,
DownloadStatus.Stopped,
DownloadStatus.Stopped
);
this.emit("download-stop", task.id);
} else {
this.logger.info(`taskId: ${task.id} failed`);
// 下载失败
await this.videoRepository.changeVideoStatus(
task.id,
DownloadStatus.Failed,
DownloadStatus.Failed
);
this.emit("download-failed", task.id, err);
}
@@ -267,7 +239,7 @@ export default class DownloadService extends EventEmitter {
private _execa(
binPath: string,
args: string[],
params: DownloadOptions,
params: DownloadOptions
): Promise<void> {
const { abortSignal, onMessage, id } = params;
@@ -406,7 +378,7 @@ export default class DownloadService extends EventEmitter {
}
};
this.logger.debug("download params: ", spawnParams);
this.logger.debug("download params: ", spawnParams.join(" "));
await this._execa(schema.bin, spawnParams, {
id,
abortSignal,
@@ -1,20 +1,21 @@
import { inject, injectable } from "inversify";
import { DownloadType } from "../interfaces";
import { TYPES } from "../types";
import ElectronLogger from "../vendor/ElectronLogger";
import { DownloadType } from "../interfaces.ts";
import { TYPES } from "../types.ts";
import ElectronLogger from "../vendor/ElectronLogger.ts";
import EventEmitter from "events";
import { session } from "electron";
import { PERSIST_WEBVIEW } from "../helper";
import { OnSendHeadersListenerDetails, session } from "electron";
import {
CallbackResponse,
OnBeforeRequestListenerDetails,
} from "electron/main";
PERSIST_WEBVIEW,
PRIVACY_WEBVIEW,
formatHeaders,
} from "../helper/index.ts";
export interface SourceParams {
url: string;
documentURL: string;
name: string;
type: DownloadType;
headers?: string;
}
export interface SourceFilter {
@@ -52,7 +53,7 @@ export class SniffingHelper extends EventEmitter {
constructor(
@inject(TYPES.ElectronLogger)
private readonly logger: ElectronLogger,
private readonly logger: ElectronLogger
) {
super();
}
@@ -64,6 +65,10 @@ export class SniffingHelper extends EventEmitter {
});
}
update(pageInfo: PageInfo) {
this.pageInfo = pageInfo;
}
reset(pageInfo: PageInfo) {
this.pageInfo = pageInfo;
this.ready = false;
@@ -88,9 +93,10 @@ export class SniffingHelper extends EventEmitter {
}
}
start() {
const viewSession = session.fromPartition(PERSIST_WEBVIEW);
viewSession.webRequest.onBeforeRequest(this.requestWillBeSent.bind(this));
start(privacy: boolean = false) {
const partition = privacy ? PRIVACY_WEBVIEW : PERSIST_WEBVIEW;
const viewSession = session.fromPartition(partition);
viewSession.webRequest.onSendHeaders(this.onSendHeaders);
}
send = (item: SourceParams) => {
@@ -103,11 +109,8 @@ export class SniffingHelper extends EventEmitter {
}
};
private requestWillBeSent(
details: OnBeforeRequestListenerDetails,
callback: (response: CallbackResponse) => void,
): void {
const { url } = details;
private onSendHeaders = (details: OnSendHeadersListenerDetails): void => {
const { url, requestHeaders } = details;
const { title, url: documentURL } = this.pageInfo;
listLoop: for (const filter of filterList) {
@@ -123,12 +126,11 @@ export class SniffingHelper extends EventEmitter {
documentURL,
name: title,
type: filter.type,
headers: formatHeaders(requestHeaders),
});
break listLoop;
}
}
}
callback({});
}
};
}
+192 -100
View File
@@ -1,22 +1,30 @@
import { WebContentsView, session } from "electron";
import { Event, HandlerDetails, WebContentsView, session } from "electron";
import { inject, injectable } from "inversify";
import { TYPES } from "../types";
import { TYPES } from "../types.ts";
import isDev from "electron-is-dev";
import { PERSIST_WEBVIEW, fetch, mobileUA, pcUA, pluginPath } from "../helper";
import {
PERSIST_WEBVIEW,
PRIVACY_WEBVIEW,
fetch,
mobileUA,
pcUA,
pluginPath,
} from "../helper/index.ts";
import { ElectronBlocker } from "@cliqz/adblocker-electron";
import ElectronLogger from "../vendor/ElectronLogger";
import ElectronStore from "../vendor/ElectronStore";
import MainWindow from "../windows/MainWindow";
import BrowserWindow from "../windows/BrowserWindow";
import VideoRepository from "../repository/VideoRepository";
import { SniffingHelper } from "./SniffingHelperService";
import ElectronLogger from "../vendor/ElectronLogger.ts";
import ElectronStore from "../vendor/ElectronStore.ts";
import MainWindow from "../windows/MainWindow.ts";
import BrowserWindow from "../windows/BrowserWindow.ts";
import VideoRepository from "../repository/VideoRepository.ts";
import { SniffingHelper, SourceParams } from "./SniffingHelperService.ts";
import { resolve } from "path";
import { readFileSync } from "fs-extra";
@injectable()
export default class WebviewService {
public view: WebContentsView;
private view: WebContentsView | null = null;
private blocker?: ElectronBlocker;
private defaultSession: string;
constructor(
@inject(TYPES.MainWindow)
@@ -30,129 +38,182 @@ export default class WebviewService {
@inject(TYPES.VideoRepository)
private readonly videoRepository: VideoRepository,
@inject(TYPES.SniffingHelper)
private readonly sniffingHelper: SniffingHelper,
private readonly sniffingHelper: SniffingHelper
) {
// 初始化 blocker
this.initBlocker();
const { useProxy, proxy, privacy } = this.store.store;
this.sniffingHelper.start(privacy);
this.sniffingHelper.on("source", this.onSource);
this.setDefaultSession(privacy, true);
this.setProxy(useProxy, proxy);
}
async init(): Promise<void> {
this.view = new WebContentsView({
webPreferences: {
partition: PERSIST_WEBVIEW,
partition: this.defaultSession,
preload: resolve(__dirname, "./preload.js"),
},
});
this.view.setBackgroundColor("#fff");
this.webContents.setAudioMuted(true);
this.view.webContents.setAudioMuted(true);
const { useProxy, proxy, isMobile } = this.store.store;
this.setProxy(useProxy, proxy);
const { isMobile } = this.store.store;
this.setUserAgent(isMobile);
this.webContents.on("dom-ready", async () => {
const baseInfo = {
title: this.webContents.getTitle(),
url: this.webContents.getURL(),
};
this.sniffingHelper.reset(baseInfo);
this.window.webContents.send("webview-dom-ready", baseInfo);
this.view.webContents.on("dom-ready", this.onDomReady);
this.view.webContents.on("did-navigate", this.onDidNavigate);
this.view.webContents.on("did-fail-load", this.onDidFailLoad);
this.view.webContents.on("did-navigate-in-page", this.onDidNavigateInPage);
this.view.webContents.setWindowOpenHandler(this.onOpenNewWindow);
}
try {
if (isDev && process.env.DEBUG_PLUGINS === "true") {
const content =
'function addScript(src){const script=document.createElement("script");script.src=src;script.type="module";document.body.appendChild(script)}addScript("http://localhost:8080/src/main.ts");';
await this.webContents.executeJavaScript(content);
} else {
const content = readFileSync(pluginPath, "utf-8");
await this.webContents.executeJavaScript(content);
}
} catch (err) {
// empty
}
});
onDomReady = () => {
if (!this.view) return;
const pageInfo = this.getPageInfo();
this.sniffingHelper.update(pageInfo);
this.window.webContents.send("webview-dom-ready", pageInfo);
};
// 兼容网站在当前页面中打开
this.webContents.setWindowOpenHandler(({ url }) => {
this.loadURL(url, true);
onDidNavigate = async () => {
if (!this.view) return;
const pageInfo = this.getPageInfo();
this.sniffingHelper.reset(pageInfo);
this.window.webContents.send("webview-did-navigate", pageInfo);
return { action: "deny" };
});
this.sniffingHelper.start();
this.sniffingHelper.on("source", async (item) => {
// 这里需要判断是否使用浏览器插件
const useExtension = this.store.get("useExtension");
if (useExtension) {
this.webContents.send("webview-link-message", item);
try {
if (isDev && process.env.DEBUG_PLUGINS === "true") {
const content =
'function addScript(src){const script=document.createElement("script");script.src=src;script.type="module";document.body.appendChild(script)}addScript("http://localhost:8080/src/main.ts");';
await this.view.webContents.executeJavaScript(content);
} else {
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);
const content = readFileSync(pluginPath, "utf-8");
await this.view.webContents.executeJavaScript(content);
}
});
} catch (err) {
// empty
}
};
onDidFailLoad = (e: Event, code: number, desc: string) => {
this.window.webContents.send("webview-fail-load", { code, desc });
};
onDidNavigateInPage = () => {
if (!this.view) return;
const pageInfo = this.getPageInfo();
this.sniffingHelper.update(pageInfo);
this.window.webContents.send("webview-did-navigate-in-page", pageInfo);
};
onOpenNewWindow = ({ url }: HandlerDetails) => {
this.loadURL(url);
return { action: "deny" } as { action: "deny" };
};
onSource = async (item: SourceParams) => {
if (!this.view) return;
// 这里需要判断是否使用浏览器插件
const useExtension = this.store.get("useExtension");
if (useExtension) {
this.view.webContents.send("webview-link-message", item);
} else {
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);
}
};
getPageInfo() {
if (!this.view) {
throw new Error("未找到 view");
}
return {
title: this.view.webContents.getTitle(),
url: this.view.webContents.getURL(),
};
}
getBounds(): Electron.Rectangle {
if (!this.view) {
throw new Error("未找到 view");
}
return this.view.getBounds();
}
setBounds(bounds: Electron.Rectangle): void {
if (!this.view) return;
this.view.setBounds(bounds);
}
setBackgroundColor(color: string): void {
if (!this.view) return;
this.view.setBackgroundColor(color);
}
show() {
if (!this.view) return;
this.window.contentView.addChildView(this.view);
isDev && this.webContents.openDevTools();
}
hide() {
if (!this.view) return;
this.window.contentView.removeChildView(this.view);
isDev && this.webContents.closeDevTools();
}
setBounds(bounds: Electron.Rectangle): void {
this.view.setBounds(bounds);
}
async loadURL(url?: string, isNewWindow?: boolean) {
const canGoBack = this.webContents.canGoBack();
try {
this.webContents.stop();
await this.webContents.loadURL(url || "");
} catch (err: unknown) {
this.logger.error("加载 url 时出现错误: ", err);
throw err;
} finally {
if (!canGoBack && !isNewWindow) {
this.webContents.clearHistory();
}
loadURL(url: string) {
// 开始加载 url
if (!this.view) {
this.init();
}
if (!this.view) return;
// 1. 停止当前导航
this.view.webContents.stop();
// 2. 加载新的 url
this.view.webContents.loadURL(url);
}
async goBack() {
if (this.webContents.canGoBack()) {
this.webContents.goBack();
if (!this.view) {
throw new Error("未找到 view");
}
const { webContents } = this.view;
if (webContents.canGoBack()) {
webContents.goBack();
return true;
} else {
this.destroyView();
return false;
}
}
async reload() {
this.webContents.reload();
if (!this.view) {
throw new Error("未找到 view");
}
this.view.webContents.reload();
}
async goHome() {
this.webContents.stop();
this.webContents.clearHistory();
if (!this.view) {
throw new Error("未找到 view");
}
this.view.webContents.stop();
this.view.webContents.clearHistory();
this.destroyView();
}
get window() {
@@ -162,12 +223,12 @@ export default class WebviewService {
}
private get session() {
return session.fromPartition(PERSIST_WEBVIEW);
return session.fromPartition(this.defaultSession);
}
private enableProxy(proxy: string) {
if (!proxy) {
this.logger.error("[proxy] 代理地址不能为空");
this.logger.error("[Proxy] 代理地址不能为空");
return;
}
@@ -177,12 +238,12 @@ export default class WebviewService {
}
this.session.setProxy({ proxyRules: proxy });
this.logger.info(`[proxy] 代理开启成功,代理地址为${proxy}`);
this.logger.info(`[Proxy] 代理开启${proxy}`);
}
private disableProxy() {
this.session.setProxy({ proxyRules: "" });
this.logger.info("[proxy] 代理关闭成功");
this.logger.info("[Proxy] 代理关闭");
}
setProxy(useProxy: boolean, proxy: string): void {
@@ -210,47 +271,78 @@ export default class WebviewService {
private enableBlocking() {
if (!this.blocker) {
this.logger.error("开启 blocker 失败未初始化");
this.logger.error("[AdBlocker] 开启失败未初始化");
return;
}
this.blocker.enableBlockingInSession(this.session);
this.logger.info("开启 blocker 成功");
this.logger.info("[AdBlocker] 开启");
}
private disableBlocking() {
if (!this.blocker) {
this.logger.error("关闭 blocker 失败未初始化");
this.logger.error("[AdBlocker] 关闭失败未初始化");
return;
}
if (!this.blocker.isBlockingEnabled(this.session)) {
return;
}
this.blocker.disableBlockingInSession(this.session);
this.logger.info("关闭 blocker 成功");
this.logger.info("[AdBlocker] 关闭");
}
setUserAgent(isMobile?: boolean) {
if (!this.view) return;
if (isMobile) {
this.webContents.setUserAgent(mobileUA);
this.view.webContents.setUserAgent(mobileUA);
} else {
this.webContents.setUserAgent(pcUA);
this.view.webContents.setUserAgent(pcUA);
}
this.logger.info("设置 user-agent 成功", isMobile);
}
get debugger() {
return this.webContents.debugger;
}
get webContents() {
return this.view.webContents;
this.logger.info(`[UA] 设置为${isMobile ? "移动端" : " pc 端"}`);
}
captureView(): Promise<Electron.NativeImage> {
if (!this.view) {
throw new Error("未找到 view");
}
return this.view.webContents.capturePage();
}
sendToWindow(channel: string, ...args: unknown[]) {
this.window.webContents.send(channel, ...args);
}
destroyView() {
if (this.view && this.window) {
this.view.webContents.close();
this.window.contentView.removeChildView(this.view);
}
// FIXME: 为了避免内存泄漏,这里需要销毁 view
this.view = null;
}
async clearCache() {
await this.session.clearCache();
await this.session.clearStorageData();
}
setDefaultSession(isPrivacy = false, init = false) {
this.logger.info(`[Session] ${isPrivacy ? "隐私" : "正常"}模式`);
if (isPrivacy) {
this.defaultSession = PRIVACY_WEBVIEW;
} else {
this.defaultSession = PERSIST_WEBVIEW;
}
if (this.view) {
const { useProxy, proxy } = this.store.store;
this.destroyView();
this.init();
this.setProxy(useProxy, proxy);
this.sniffingHelper.start(isPrivacy);
}
if (!init) {
this.window.webContents.send("change-privacy");
}
}
}
+1
View File
@@ -5,6 +5,7 @@ export const TYPES = {
// repository
VideoRepository: Symbol.for("VideoRepository"),
FavoriteRepository: Symbol.for("FavoriteRepository"),
ConversionRepository: Symbol.for("ConversionRepository"),
// windows
BrowserWindow: Symbol.for("BrowserWindow"),
MainWindow: Symbol.for("MainWindow"),
+5 -5
View File
@@ -1,12 +1,12 @@
import { inject, injectable } from "inversify";
import { TYPES } from "../types";
import { TYPES } from "../types.ts";
import isDev from "electron-is-dev";
import installExtension, {
import install, {
REDUX_DEVTOOLS,
REACT_DEVELOPER_TOOLS,
} from "electron-devtools-installer";
import ElectronLogger from "./ElectronLogger";
import { Vendor } from "../core/vendor";
import ElectronLogger from "./ElectronLogger.ts";
import { Vendor } from "../core/vendor.ts";
@injectable()
export default class DevToolsService implements Vendor {
@@ -26,7 +26,7 @@ export default class DevToolsService implements Vendor {
try {
this.logger.debug("当前环境为开发环境,开始加载开发者工具");
await installExtension([REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS]);
await install([REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS]);
this.logger.debug("加载开发者工具成功");
} catch (err: unknown) {
this.logger.error("加载开发者工具失败", err);
+2 -2
View File
@@ -2,8 +2,8 @@ import logger, { Logger } from "electron-log";
import { injectable } from "inversify";
import dayjs from "dayjs";
import path from "path";
import { appName, workspace } from "../helper";
import { Vendor } from "../core/vendor";
import { appName, workspace } from "../helper/index.ts";
import { Vendor } from "../core/vendor.ts";
@injectable()
export default class ElectronLogger implements Vendor {
+7 -4
View File
@@ -1,9 +1,9 @@
import Store from "electron-store";
import { injectable } from "inversify";
import { AppStore } from "../main";
import { download, workspace } from "../helper";
import { AppLanguage, AppTheme } from "../types";
import { Vendor } from "../core/vendor";
import { AppStore } from "../main.ts";
import { download, workspace } from "../helper/index.ts";
import { AppLanguage, AppTheme } from "../types.ts";
import { Vendor } from "../core/vendor.ts";
@injectable()
export default class StoreService extends Store<AppStore> implements Vendor {
@@ -28,6 +28,9 @@ export default class StoreService extends Store<AppStore> implements Vendor {
language: AppLanguage.System,
showTerminal: false,
privacy: false,
machineId: "",
downloadProxySwitch: false,
autoUpgrade: true,
},
});
}
+11 -4
View File
@@ -1,19 +1,26 @@
import { autoUpdater } from "electron-updater";
import { inject, injectable } from "inversify";
import { TYPES } from "../types";
import { TYPES } from "../types.ts";
import isDev from "electron-is-dev";
import ElectronLogger from "./ElectronLogger";
import { Vendor } from "../core/vendor";
import ElectronLogger from "./ElectronLogger.ts";
import { Vendor } from "../core/vendor.ts";
import ElectronStore from "electron-store";
@injectable()
export default class UpdateService implements Vendor {
constructor(
@inject(TYPES.ElectronLogger) private readonly logger: ElectronLogger,
@inject(TYPES.ElectronLogger)
private readonly logger: ElectronLogger,
@inject(TYPES.ElectronStore)
private readonly store: ElectronStore
) {}
async init() {
if (isDev) return;
const { autoUpgrade } = this.store.store;
if (!autoUpgrade) return;
try {
autoUpdater.disableWebInstaller = true;
autoUpdater.logger = this.logger.logger;
+9 -8
View File
@@ -1,11 +1,12 @@
import { db } from "../helper";
import { db } from "../helper/index.ts";
import { inject, injectable } from "inversify";
import { DataSource, EntityManager } from "typeorm";
import { TYPES } from "../types";
import { Video } from "../entity/Video";
import { Favorite } from "../entity/Favorite";
import ElectronLogger from "./ElectronLogger";
import { Vendor } from "../core/vendor";
import { TYPES } from "../types.ts";
import { Video } from "../entity/Video.ts";
import { Favorite } from "../entity/Favorite.ts";
import ElectronLogger from "./ElectronLogger.ts";
import { Vendor } from "../core/vendor.ts";
import { Conversion } from "../entity/Conversion.ts";
@injectable()
export default class DatabaseService implements Vendor {
@@ -13,14 +14,14 @@ export default class DatabaseService implements Vendor {
constructor(
@inject(TYPES.ElectronLogger)
private readonly logger: ElectronLogger,
private readonly logger: ElectronLogger
) {
this.appDataSource = new DataSource({
type: "better-sqlite3",
database: db,
synchronize: true,
logging: false,
entities: [Video, Favorite],
entities: [Video, Favorite, Conversion],
migrations: [],
subscribers: [],
});
+6 -6
View File
@@ -1,10 +1,10 @@
import isDev from "electron-is-dev";
import { inject, injectable } from "inversify";
import { resolve } from "path";
import { TYPES } from "../types";
import { TYPES } from "../types.ts";
import _ from "lodash";
import Window from "../core/window";
import ElectronStore from "../vendor/ElectronStore";
import Window from "../core/window.ts";
import ElectronStore from "../vendor/ElectronStore.ts";
@injectable()
export default class BrowserWindow extends Window {
@@ -32,11 +32,11 @@ export default class BrowserWindow extends Window {
this.store.onDidAnyChange(this.storeChange);
}
storeChange = (store: any) => {
storeChange = (store: unknown) => {
this.send("store-change", store);
};
handleNewWindowsVal = (newValue: any) => {
handleNewWindowsVal = (newValue: unknown) => {
if (!this.window) return;
// 向所有窗口发送通知
@@ -75,7 +75,7 @@ export default class BrowserWindow extends Window {
this.window.close();
};
send(channel: string, ...args: any[]) {
send(channel: string, ...args: unknown[]) {
if (!this.window) return;
this.window.webContents.send(channel, ...args);
+11 -11
View File
@@ -2,15 +2,15 @@ import { Menu, Notification, app } from "electron";
import isDev from "electron-is-dev";
import { inject, injectable } from "inversify";
import { resolve } from "path";
import { TYPES } from "../types";
import { DownloadProgress } from "../interfaces";
import { TYPES } from "../types.ts";
import { DownloadProgress } from "../interfaces.ts";
import _ from "lodash";
import Window from "../core/window";
import ElectronLogger from "../vendor/ElectronLogger";
import DownloadService from "../services/DownloadService";
import ElectronStore from "../vendor/ElectronStore";
import VideoRepository from "../repository/VideoRepository";
import { isWin } from "../helper";
import Window from "../core/window.ts";
import ElectronLogger from "../vendor/ElectronLogger.ts";
import DownloadService from "../services/DownloadService.ts";
import ElectronStore from "../vendor/ElectronStore.ts";
import VideoRepository from "../repository/VideoRepository.ts";
import { isWin } from "../helper/index.ts";
@injectable()
export default class MainWindow extends Window {
@@ -98,7 +98,7 @@ export default class MainWindow extends Window {
}
};
storeChange = (store: any) => {
storeChange = (store: unknown) => {
// 向所有窗口发送通知
this.send("store-change", store);
};
@@ -121,7 +121,7 @@ export default class MainWindow extends Window {
this.send("download-success", id);
};
onDownloadFailed = async (id: number, err: any) => {
onDownloadFailed = async (id: number, err: unknown) => {
const promptTone = this.store.get("promptTone");
if (promptTone) {
const video = await this.videoRepository.findVideo(id);
@@ -152,7 +152,7 @@ export default class MainWindow extends Window {
}
};
send(channel: string, ...args: any[]) {
send(channel: string, ...args: unknown[]) {
if (!this.window) return;
this.window.webContents.send(channel, ...args);
+4 -3
View File
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ES2015",
"module": "CommonJS",
"module": "NodeNext",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
@@ -9,13 +9,14 @@
"resolveJsonModule": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"moduleResolution": "Node",
"moduleResolution": "NodeNext",
"strictPropertyInitialization": false,
"typeRoots": ["node_modules/@types"],
"declaration": true,
"emitDeclarationOnly": true,
"declarationDir": "./types",
"outDir": "app/output/build/main"
"outDir": "app/output/build/main",
"allowImportingTsExtensions": true
},
"include": ["src"],
"references": [
-22
View File
@@ -1,22 +0,0 @@
/* eslint-env node */
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
],
overrides: [],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
plugins: ["@typescript-eslint"],
rules: {
"@typescript-eslint/no-explicit-any": "warn",
},
};
+14
View File
@@ -0,0 +1,14 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
export default [
{ languageOptions: { globals: globals.browser } },
pluginJs.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
"@typescript-eslint/no-explicit-any": "warn",
},
},
];
+13 -15
View File
@@ -12,24 +12,22 @@
},
"dependencies": {
"jquery": "^3.7.1",
"lit": "^3.1.2",
"nanoid": "^5.0.4"
"lit": "^3.1.3",
"nanoid": "^5.0.7"
},
"devDependencies": {
"@types/jquery": "^3.5.29",
"@types/node": "^20.11.5",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"lint-staged": "^15.2.2",
"prettier": "3.2.5",
"stylelint": "^16.2.1",
"@eslint/js": "^9.3.0",
"@types/jquery": "^3.5.30",
"@types/node": "^20.12.12",
"eslint": "^9.3.0",
"globals": "^15.3.0",
"lint-staged": "^15.2.5",
"stylelint": "^16.6.0",
"stylelint-config-standard": "^36.0.0",
"stylelint-config-standard-scss": "^13.0.0",
"typescript": "^5.3.3",
"vite": "^5.0.12"
"stylelint-config-standard-scss": "^13.1.0",
"typescript": "^5.4.5",
"typescript-eslint": "^7.10.0",
"vite": "^5.2.11"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": "eslint",
@@ -2,6 +2,7 @@ import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
import { BILIBILI_DOWNLOAD_BUTTON, showDownloadDialog } from "../helper";
import $ from "jquery";
import { DownloadType } from "../types";
@customElement("bilibili-button")
export class BilibiliButton extends LitElement {
@@ -39,7 +40,7 @@ export class BilibiliButton extends LitElement {
const videoImage = $(BILIBILI_DOWNLOAD_BUTTON).eq(this.index);
const url = videoImage.attr("href") || "";
const name = videoImage.parent().find(".bili-video-card__info--tit").text();
showDownloadDialog([{ name, url, type: "bilibili" }]);
showDownloadDialog([{ name, url, type: DownloadType.bilibili }]);
}
render() {
@@ -19,6 +19,7 @@ interface SourceData {
documentURL: string;
name: string;
type: DownloadType;
headers?: string;
}
@customElement("float-button")
@@ -114,7 +115,7 @@ export class FloatButton extends LitElement {
};
receiveMessage = (_: unknown, data: SourceData) => {
this.data = [...this.data, data];
this.data = [...this.data, { ...data, name: document.title }];
};
handleMouseStart = (event: MouseEvent) => {
@@ -133,7 +134,7 @@ export class FloatButton extends LitElement {
getPosition = (
newLeft: number,
newTop: number,
newTop: number
): { left: number; top: number } => {
if (!this.button) return { left: 0, top: 0 };
@@ -210,6 +211,8 @@ export class FloatButton extends LitElement {
function init() {
const floatButton = document.createElement("float-button");
floatButton.style.position = "fixed";
floatButton.style.zIndex = "9999999999999";
document.body.appendChild(floatButton);
// 向主进程发送插件准备好的消息
+2 -5
View File
@@ -1,4 +1,5 @@
import { nanoid } from "nanoid";
import { DownloadItem } from "../../../main/types/interfaces";
const eventMap = new Map();
@@ -29,11 +30,7 @@ export interface Item {
type: any;
}
export function downloadItem(item: Item) {
window.electron.downloadItem(item);
}
export function showDownloadDialog(item: Item[]) {
export function showDownloadDialog(item: Omit<DownloadItem, "id">[]) {
window.electron.showDownloadDialog(item);
}
View File
-24
View File
@@ -1,24 +0,0 @@
/* eslint-env node */
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
],
overrides: [],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
plugins: ["react", "@typescript-eslint"],
rules: {
"prettier/prettier": "warn",
"@typescript-eslint/no-explicit-any": "warn",
},
};
+4 -1
View File
@@ -1,3 +1,6 @@
{
"extends": ["stylelint-config-standard", "stylelint-config-standard-scss"]
"extends": ["stylelint-config-standard", "stylelint-config-standard-scss"],
"rules": {
"no-descending-specificity": null
}
}
+17
View File
@@ -0,0 +1,17 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
import pluginReactConfig from "eslint-plugin-react/configs/recommended.js";
import { fixupConfigRules } from "@eslint/compat";
export default [
{ languageOptions: { globals: globals.browser } },
pluginJs.configs.recommended,
...tseslint.configs.recommended,
...fixupConfigRules(pluginReactConfig),
{
rules: {
"@typescript-eslint/no-explicit-any": "warn",
},
},
];
+37 -40
View File
@@ -8,57 +8,54 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"report": "vite-bundle-visualizer",
"lint-staged": "lint-staged"
},
"dependencies": {
"@ant-design/icons": "^5.2.6",
"@ant-design/pro-components": "^2.6.48",
"@reduxjs/toolkit": "^2.0.1",
"@ant-design/icons": "^5.3.7",
"@ant-design/pro-components": "^2.7.9",
"@reduxjs/toolkit": "^2.2.5",
"@xterm/addon-fit": "^0.10.0",
"ahooks": "^3.7.8",
"antd": "^5.13.2",
"antd-style": "^3.6.1",
"axios": "^1.6.5",
"@xterm/xterm": "^5.5.0",
"ahooks": "^3.8.0",
"antd": "^5.18.0",
"antd-style": "^3.6.2",
"axios": "^1.7.2",
"classnames": "^2.5.1",
"dayjs": "^1.11.10",
"i18next": "^23.8.2",
"i18next-browser-languagedetector": "^7.2.0",
"lint-staged": "^15.2.2",
"dayjs": "^1.11.11",
"i18next": "^23.11.5",
"i18next-browser-languagedetector": "^8.0.0",
"immer": "^10.1.1",
"lint-staged": "^15.2.5",
"localforage": "^1.10.0",
"match-sorter": "^6.3.3",
"nanoid": "^5.0.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^14.0.1",
"react-redux": "^9.1.0",
"react-router-dom": "^6.21.3",
"sort-by": "^1.2.0",
"@xterm/xterm": "^5.5.0"
"match-sorter": "^6.3.4",
"nanoid": "^5.0.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^14.1.2",
"react-redux": "^9.1.2",
"react-router-dom": "^6.23.1",
"sort-by": "^1.2.0"
},
"devDependencies": {
"@types/node": "^20.11.5",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@eslint/compat": "^1.0.1",
"@eslint/js": "^9.3.0",
"@types/node": "^20.12.12",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-redux": "^7.1.33",
"@types/sort-by": "^1.2.3",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "6.21.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.33.2",
"prettier": "3.2.5",
"sass": "^1.70.0",
"stylelint": "^16.2.1",
"@vitejs/plugin-react-swc": "^3.7.0",
"eslint": "^9.3.0",
"globals": "^15.3.0",
"sass": "^1.77.2",
"stylelint": "^16.6.0",
"stylelint-config-standard": "^36.0.0",
"stylelint-config-standard-scss": "^13.0.0",
"typescript": "~5.3.3",
"vite": "^5.0.12"
"stylelint-config-standard-scss": "^13.1.0",
"typescript": "~5.4.5",
"typescript-eslint": "^7.10.0",
"vite": "^5.2.11",
"vite-bundle-visualizer": "^1.2.1"
},
"license": "MIT",
"lint-staged": {
+77 -14
View File
@@ -1,17 +1,22 @@
import { ConfigProvider, theme } from "antd";
import "antd/dist/reset.css";
import React, { FC, useEffect } from "react";
import React, { FC, Suspense, lazy, 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 SourceExtract from "./nodes/SourceExtract";
import { setAppStore, increase } from "./store";
import { setAppStore, increase, setBrowserStore, PageMode } from "./store";
import "dayjs/locale/zh-cn";
import zhCN from "antd/locale/zh_CN";
import "./App.scss";
import useElectron from "./hooks/electron";
import Loading from "./components/Loading";
import { DownloadFilter } from "./types";
import { tdApp } from "./utils";
import { useAsyncEffect } from "ahooks";
const AppLayout = lazy(() => import("./layout/App"));
const HomePage = lazy(() => import("./pages/HomePage"));
const SourceExtract = lazy(() => import("./pages/SourceExtract"));
const SettingPage = lazy(() => import("./pages/SettingPage"));
const ConverterPage = lazy(() => import("./pages/Converter"));
function getAlgorithm(appTheme: "dark" | "light") {
return appTheme === "dark" ? theme.darkAlgorithm : theme.defaultAlgorithm;
@@ -20,7 +25,7 @@ function getAlgorithm(appTheme: "dark" | "light") {
const App: FC = () => {
const dispatch = useDispatch();
const [appTheme, setAppTheme] = React.useState<"dark" | "light">("light");
const { addIpcListener, removeIpcListener } = useElectron();
const { addIpcListener, removeIpcListener, getMachineId } = useElectron();
const themeChange = (event: MediaQueryListEvent) => {
if (event.matches) {
@@ -39,16 +44,27 @@ const App: FC = () => {
dispatch(increase());
};
const onChangePrivacy = () => {
dispatch(setBrowserStore({ url: "", title: "", mode: PageMode.Default }));
};
useEffect(() => {
addIpcListener("store-change", onAppStoreChange);
addIpcListener("download-item-notifier", onReceiveDownloadItem);
addIpcListener("change-privacy", onChangePrivacy);
return () => {
removeIpcListener("store-change", onAppStoreChange);
removeIpcListener("download-item-notifier", onReceiveDownloadItem);
removeIpcListener("change-privacy", onChangePrivacy);
};
}, []);
useAsyncEffect(async () => {
const deviceId = await getMachineId();
tdApp.onEvent("页面加载", { deviceId });
}, []);
useEffect(() => {
const isDarkTheme = matchMedia("(prefers-color-scheme: dark)");
isDarkTheme.addEventListener("change", themeChange);
@@ -72,17 +88,64 @@ const App: FC = () => {
>
<BrowserRouter>
<Routes>
<Route path="/" element={<AppLayout />}>
<Route index element={<HomePage />} />
<Route
path="/"
element={
<Suspense fallback={<Loading />}>
<AppLayout />
</Suspense>
}
>
<Route
index
element={
<Suspense fallback={<Loading />}>
<HomePage />
</Suspense>
}
/>
<Route
path="done"
element={<HomePage filter={DownloadFilter.done} />}
element={
<Suspense fallback={<Loading />}>
<HomePage filter={DownloadFilter.done} />
</Suspense>
}
/>
<Route
path="source"
element={
<Suspense fallback={<Loading />}>
<SourceExtract />
</Suspense>
}
/>
<Route
path="settings"
element={
<Suspense fallback={<Loading />}>
<SettingPage />
</Suspense>
}
/>
<Route
path="converter"
element={
<Suspense fallback={<Loading />}>
<ConverterPage />
</Suspense>
}
/>
<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="/browser"
element={
<Suspense fallback={<Loading />}>
<SourceExtract page={true} />
</Suspense>
}
/>
</Routes>
</BrowserRouter>
</ConfigProvider>
Binary file not shown.
Binary file not shown.
@@ -1,184 +1,403 @@
import {
ModalForm,
ProFormSelect,
ProFormSwitch,
ProFormText,
ProFormTextArea,
} from "@ant-design/pro-components";
import { Form } from "antd";
import React, { FC } from "react";
import { Button, Form, Input, InputNumber, Select, Space, message } from "antd";
import React, {
ChangeEvent,
FC,
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from "react";
import "./index.scss";
import { useTranslation } from "react-i18next";
import { DownloadOutlined, PlusOutlined } from "@ant-design/icons";
export interface DownloadFormProps {
onFinish: (values: DownloadItem) => Promise<boolean | void>;
trigger: JSX.Element;
trigger?: JSX.Element;
isEdit?: boolean;
item?: DownloadItem;
open?: boolean;
destroyOnClose?: boolean;
onOpenChange?: (open: boolean) => void;
onAddToList: (values: DownloadItem) => Promise<boolean | void>;
onDownloadNow: (values: DownloadItem) => Promise<boolean | void>;
}
const preProcessHeaders = (value: string | null) => {
if (!value) return;
export interface DownloadFormRef {
setFieldsValue: (value: Partial<DownloadItem>) => void;
getFieldsValue: () => DownloadItem;
}
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");
};
interface EpisodeNumberProps {
value?: string;
onChange?: (value: string) => void;
canChangeType: boolean;
}
const postProcessHeaders = (value: string | null) => {
if (!value) return "";
export interface DownloadItemForm extends DownloadItem {
batch?: boolean;
batchList?: string;
}
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 EpisodeNumber: FC<EpisodeNumberProps> = ({
value = "",
onChange = () => {},
canChangeType,
}) => {
const [form] = Form.useForm<DownloadItem>();
const { t } = useTranslation();
const [type, setType] = useState("teleplay");
const [name, setName] = useState("");
const [number, setNumber] = useState(1);
const isEpisode = useMemo(
() => type === "teleplay" && canChangeType,
[type, canChangeType]
);
// FIXME: localforage
// await localforage.setItem<NumberOfEpisodes>("numberOfEpisodes", {
// numberOfEpisodes,
// teleplay,
// });
const parseName = (value: string = "") => {
const res = {
name: "",
number: 1,
};
if (/_第(\d+)集$/.test(value)) {
const [, name, number] = value.match(/(.*?)_第(\d+)集$/);
res.name = name;
res.number = Number(number);
} else {
res.name = value;
}
return res;
};
useEffect(() => {
const { name, number } = parseName(value);
setName(name);
setNumber(number);
if (isEpisode) {
onChange(`${name}_第${number}`);
}
}, [value]);
useEffect(() => {
if (!name || !number) return;
if (isEpisode) {
onChange(`${name}_第${number}`);
} else {
onChange(name);
}
}, [type]);
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
if (isEpisode) {
onChange(`${e.target.value}_第${number}`);
} else {
onChange(e.target.value);
}
};
const onNumberChange = (val: number) => {
setNumber(val);
if (isEpisode) {
onChange(`${name}_第${val}`);
}
};
return (
<ModalForm<DownloadItem>
key={isEdit ? "edit" : "new"}
title={isEdit ? t("editDownload") : t("newDownload")}
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={t("batchDownload")} name={"batch"} />}
<Form.Item noStyle shouldUpdate>
{(form) => {
if (!isEdit && form.getFieldValue("batch")) {
return (
<ProFormTextArea
name="batchList"
fieldProps={{
rows: 6,
}}
label={t("videoLink")}
placeholder={t("videoLikeDescription")}
rules={[
{
required: true,
message: t("pleaseEnterVideoLink"),
},
]}
/>
);
} else {
return (
<ProFormText
name="url"
label={t("videoLink")}
placeholder={t("pleaseEnterOnlineVideoUrlOrDragM3U8Here")}
rules={[
{
required: true,
message: t("pleaseEnterOnlineVideoUrl"),
},
{
pattern: /^(file|https?):\/\/.+/,
message: t("pleaseEnterCorrectVideoLink"),
},
]}
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={t("videoName")}
placeholder={t("pleaseEnterVideoName")}
/>
);
}
}}
</Form.Item>
<Form.Item noStyle shouldUpdate>
{(form) => {
if (isEdit || !form.getFieldValue("batch")) {
return (
<ProFormText
name="key"
label={t("privateKey")}
placeholder={t("keyAndIv")}
/>
);
}
}}
</Form.Item>
<ProFormTextArea
name="headers"
label={t("additionalHeaders")}
fieldProps={{
rows: 4,
}}
placeholder={t("additionalHeadersDescription")}
<Space.Compact block>
{canChangeType && (
<Select
style={{ width: 100 }}
defaultValue="teleplay"
options={[
{
label: "剧集",
value: "teleplay",
},
{
label: "电影",
value: "movie",
},
]}
value={type}
onChange={setType}
/>
)}
<Input
value={name}
onChange={handleNameChange}
placeholder={t("pleaseEnterVideoName")}
/>
</ModalForm>
{canChangeType && isEpisode && (
<InputNumber
style={{ width: 300 }}
addonBefore="第"
addonAfter="集"
changeOnWheel
value={number}
min={1}
onChange={onNumberChange}
/>
)}
</Space.Compact>
);
};
export default DownloadFrom;
const DownloadForm = forwardRef<DownloadFormRef, DownloadFormProps>(
(props, ref) => {
const {
trigger,
isEdit,
open,
destroyOnClose,
onOpenChange,
onAddToList,
onDownloadNow,
item,
} = props;
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm<DownloadItem>();
const { t } = useTranslation();
const [messageApi, contextHolder] = message.useMessage();
useEffect(() => {
setModalOpen(open);
}, [open]);
useEffect(() => {
if (modalOpen) {
form.setFieldsValue(item);
}
}, [item, modalOpen]);
useImperativeHandle(
ref,
() => {
return {
setFieldsValue: (value) => {
form.setFieldsValue(value);
},
getFieldsValue: () => {
return form.getFieldsValue();
},
};
},
[]
);
const renderTrigger = () => {
if (!trigger) {
return null;
}
return React.cloneElement(trigger, {
onClick: () => {
setModalOpen(true);
},
});
};
return (
<>
{renderTrigger()}
<ModalForm<DownloadItemForm>
open={modalOpen}
key={isEdit ? "edit" : "new"}
title={isEdit ? t("editDownload") : t("newDownload")}
width={500}
form={form}
autoFocusFirstInput
submitter={{
render: () => {
return [
<Button
key="addToList"
onClick={async () => {
try {
await form.validateFields();
const values = form.getFieldsValue();
const close = await onAddToList(values);
if (close) {
setModalOpen(false);
}
} catch (e: any) {
messageApi.error(e?.message);
}
}}
icon={<PlusOutlined />}
>
{t("addToDownloadList")}
</Button>,
<Button
key="downloadNow"
type="primary"
onClick={async () => {
try {
await form.validateFields();
const values = form.getFieldsValue();
const close = await onDownloadNow(values);
if (close) {
setModalOpen(false);
}
} catch (e: any) {
messageApi.error(e?.message);
}
}}
icon={<DownloadOutlined />}
>
{t("downloadNow")}
</Button>,
];
},
}}
onOpenChange={(open) => {
if (onOpenChange) {
onOpenChange(open);
} else {
setModalOpen(open);
}
}}
modalProps={{
destroyOnClose,
styles: {
body: {
paddingTop: 5,
},
},
}}
submitTimeout={2000}
labelCol={{ span: 5 }}
layout="horizontal"
colon={false}
>
{contextHolder}
{!isEdit && (
<ProFormSwitch label={t("batchDownload")} name={"batch"} />
)}
<ProFormSelect
key="type"
width="xl"
disabled={isEdit}
name="type"
label={t("videoType")}
valueEnum={{
m3u8: "流媒体视频(m3u8",
bilibili: "哔哩哔哩视频",
}}
placeholder={t("pleaseSelectVideoType")}
rules={[
{
required: true,
},
]}
/>
<Form.Item noStyle shouldUpdate>
{(form) => {
const canChangeType =
!form.getFieldValue("batch") &&
form.getFieldValue("type") === "m3u8";
return (
<Form.Item
shouldUpdate
name="name"
label={t("videoName")}
rules={[
{
required: true,
message: t("pleaseEnterVideoName"),
},
]}
tooltip={canChangeType && t("canUseMouseWheelToAdjust")}
>
<EpisodeNumber canChangeType={canChangeType} />
</Form.Item>
);
}}
</Form.Item>
<Form.Item noStyle shouldUpdate>
{(form) => {
if (!isEdit && form.getFieldValue("batch")) {
return (
<ProFormTextArea
name="batchList"
fieldProps={{
rows: 4,
}}
label={t("videoLink")}
placeholder={t("videoLikeDescription")}
rules={[
{
required: true,
message: t("pleaseEnterVideoLink"),
},
]}
/>
);
} else {
return (
<ProFormText
name="url"
label={t("videoLink")}
placeholder={t("pleaseEnterOnlineVideoUrlOrDragM3U8Here")}
rules={[
{
required: true,
message: t("pleaseEnterOnlineVideoUrl"),
},
{
pattern: /^(file|https?):\/\/.+/,
message: t("pleaseEnterCorrectVideoLink"),
},
]}
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 (
form.getFieldValue("type") === "m3u8" ||
form.getFieldValue("batch")
) {
return (
<ProFormTextArea
name="headers"
label={t("additionalHeaders")}
fieldProps={{
rows: 4,
}}
placeholder={t("additionalHeadersDescription")}
/>
);
}
}}
</Form.Item>
</ModalForm>
</>
);
}
);
export default DownloadForm;
@@ -0,0 +1,14 @@
import React, { FC } from "react";
import { useStyles } from "./styles";
import { Spin } from "antd";
const Loading: FC = () => {
const { styles } = useStyles();
return (
<div className={styles.container}>
<Spin />
</div>
);
};
export default Loading;
@@ -0,0 +1,12 @@
import { createStyles } from "antd-style";
export const useStyles = createStyles(({ css, token }) => ({
container: css`
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: ${token.colorBgContainer};
`,
}));
@@ -1,5 +1,5 @@
import React, { FC, useEffect, useRef } from "react";
import { useStyles } from "./style";
import { useStyles } from "./styles";
import "@xterm/xterm/css/xterm.css";
import { Terminal as XTerminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
@@ -10,12 +10,15 @@ interface DivRect {
height: number;
}
const computeRect = ({ left, top, width, height }: DivRect) => ({
x: Math.floor(left),
y: Math.floor(top),
width: Math.floor(width),
height: Math.floor(height),
});
const computeRect = (rect: DivRect) => {
if (!rect) return { x: 0, y: 0, width: 0, height: 0 };
return {
x: Math.floor(rect.left),
y: Math.floor(rect.top),
width: Math.floor(rect.width),
height: Math.floor(rect.height),
};
};
interface WebViewProps {
className?: string;
+33 -20
View File
@@ -75,7 +75,8 @@ i18n
pleaseSelectLanguage: "Please select language",
downloadPrompt: "Download Prompt",
browserSetting: "Browser Setting",
pleaseEnterProxy: "Please enter proxy",
pleaseEnterProxy:
"You need to enter the full address, for example: http://127.0.0.1:8000",
proxySetting: "Proxy Setting",
pleaseEnterProxyFirst: "Please enter proxy first",
blockAds: "Block Ads",
@@ -84,11 +85,15 @@ i18n
deleteSegments: "Delete segments after download",
moreAction: "More Action",
configDir: "Config Directory",
clearCache: "Clear Cache",
clearCacheSuccess: "Clear Cache Success",
clearCacheFailed: "Clear Cache Failed",
binPath: "Executable Path",
localDir: "Local Directory",
currentVersion: "Current Version",
selectFolder: "Select Folder",
proxySwitch: "Proxy Switch",
downloadProxySwitch: "Download Proxy Switch",
proxyDescription:
"The proxy will take effect on the built-in browser and download",
useImmersiveSniffing: "Use immersive sniffing",
@@ -104,18 +109,14 @@ i18n
videoLink: "Video Link",
pleaseEnterVideoLink: "Please enter video link",
videoName: "Video Name",
pleaseEnterVideoName:
"Please enter video name, default is the current time",
privateKey: "Private Key",
keyAndIv: "Key and IV (HEX format)",
pleaseEnterVideoName: "Please enter video name",
additionalHeaders: "Additional Headers",
pleaseEnterOnlineVideoUrl: "Please enter online network video URL",
pleaseEnterCorrectVideoLink: "Please enter correct video link",
pleaseEnterOnlineVideoUrlOrDragM3U8Here:
"Please enter online network video URL, or drag M3U8 here",
videoLikeDescription: `Please enter the video link, make sure one line one, format: video link video name (optional), for example
https://www.bilibili.com/video/XXX Bilibili video
http://example.com/xxx.m3u8 m3u8 video
videoLikeDescription: `Please enter the video link, make sure one line one, for example
http://example.com/xxx.m3u8
http://example.com/xxx.m3u8`,
additionalHeadersDescription: `Please enter additional headers, make sure one line one header, for example:
Origin: http://www.example.com
@@ -125,9 +126,11 @@ Referer: http://www.example.com`,
close: "Close",
exportLog: "Export Log",
showTerminal: "Show Console",
autoUpgrade: "Auto Upgrade",
autoUpgradeTooltip: "Take effect next time you start",
consoleOutput: "Console",
downloadNow: "Download Now",
addToDownloadList: "Add to Download List",
downloadNow: "Download",
addToDownloadList: "Confirm",
videoType: "Video Type",
pleaseSelectVideoType: "Please select video type",
numberOfEpisodes: "Number of episodes",
@@ -135,6 +138,10 @@ Referer: http://www.example.com`,
canUseMouseWheelToAdjust: "Can use mouse wheel to adjust",
openBrowser: "Open Browser",
privacy: "Privacy Mode",
privacyTooltip:
"After turning on the switch, the browser will not save any data",
converter: "Converter",
addFile: "Add File",
},
},
zh: {
@@ -142,7 +149,7 @@ Referer: http://www.example.com`,
downloadList: "下载列表",
downloadComplete: "下载完成",
materialExtraction: "素材提取",
setting: "设置",
setting: "软件设置",
refresh: "刷新",
newDownload: "新建下载",
selectAll: "全选",
@@ -195,7 +202,7 @@ Referer: http://www.example.com`,
pleaseSelectLanguage: "请选择显示语言",
downloadPrompt: "下载完成提示",
browserSetting: "浏览器设置",
pleaseEnterProxy: "请填写代理地址",
pleaseEnterProxy: "需要填写完整的地址,例如:http://127.0.0.1:8000",
proxySetting: "代理设置",
pleaseEnterProxyFirst: "请先输入代理地址",
blockAds: "开启广告过滤",
@@ -204,11 +211,15 @@ Referer: http://www.example.com`,
deleteSegments: "下载完成删除分片",
moreAction: "更多操作",
configDir: "配置文件目录",
clearCache: "清除缓存",
clearCacheSuccess: "清除缓存成功",
clearCacheFailed: "清除缓存失败",
binPath: "可执行程序目录",
localDir: "本地存储目录",
currentVersion: "当前版本",
selectFolder: "选择文件夹",
proxySwitch: "代理开关",
downloadProxySwitch: "下载代理开关",
proxyDescription: "该代理会对软件自带浏览器以及下载时生效",
useImmersiveSniffing: "使用沉浸式嗅探",
immersiveSniffingDescription:
@@ -222,17 +233,14 @@ Referer: http://www.example.com`,
videoLink: "视频链接",
pleaseEnterVideoLink: "请输入站点视频链接",
videoName: "视频名称",
pleaseEnterVideoName: "请输入视频名称,默认以当前时间作为视频名称",
privateKey: "私有KEY",
keyAndIv: "KEY和IV值(HEX格式)",
pleaseEnterVideoName: "请输入视频名称",
additionalHeaders: "附加标头",
pleaseEnterOnlineVideoUrl: "请输入在线网络视频URL",
pleaseEnterCorrectVideoLink: "请输入正确的视频链接",
pleaseEnterOnlineVideoUrlOrDragM3U8Here:
"请输入在线网络视频URL,或者将M3U8拖拽至此",
videoLikeDescription: `请输入视频链接, 确保一行一个,格式: 视频链接 视频名称(非必填), 例如
https://www.bilibili.com/video/XXX 哔哩哔哩视频
http://example.com/xxx.m3u8 m3u8视频
videoLikeDescription: `请输入视频链接, 确保一行一个,例如
http://example.com/xxx.m3u8
http://example.com/xxx.m3u8`,
additionalHeadersDescription: `请输入附加标头,确保一行一个 Header, 例如:
Origin: http://www.example.com
@@ -247,9 +255,11 @@ Referer: http://www.example.com`,
close: "关闭",
exportLog: "导出日志",
showTerminal: "显示控制台",
autoUpgrade: "自动更新",
autoUpgradeTooltip: "下次启动时生效",
consoleOutput: "控制台",
downloadNow: "立即下载",
addToDownloadList: "添加到下载列表",
downloadNow: "下载",
addToDownloadList: "确认",
videoType: "视频类型",
pleaseSelectVideoType: "请选择视频类型",
numberOfEpisodes: "集数",
@@ -257,6 +267,9 @@ Referer: http://www.example.com`,
canUseMouseWheelToAdjust: "可以使用鼠标滚轮调整",
openBrowser: "打开浏览器",
privacy: "隐私模式",
privacyTooltip: "打开开关后浏览器将不保存任何数据",
converter: "格式转换",
addFile: "添加文件",
},
},
},
+44 -29
View File
@@ -1,13 +1,15 @@
import React, { FC, useState } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { Badge, Button, Layout, Menu, MenuProps, Flex } from "antd";
import { Badge, Button, Layout, Menu, MenuProps, Flex, Typography } from "antd";
import {
CheckCircleOutlined,
DownloadOutlined,
ExportOutlined,
EyeInvisibleOutlined,
ProfileOutlined,
QuestionCircleOutlined,
SettingOutlined,
SyncOutlined,
} from "@ant-design/icons";
import useElectron from "../../hooks/electron";
import { useDispatch, useSelector } from "react-redux";
@@ -20,8 +22,10 @@ import {
import { useAsyncEffect } from "ahooks";
import { useTranslation } from "react-i18next";
import { useStyles } from "./style";
import classNames from "classnames";
const { Footer, Sider, Content } = Layout;
const { Text } = Typography;
type MenuItem = Required<MenuProps>["items"][number];
@@ -44,7 +48,6 @@ const App: FC = () => {
const location = useLocation();
const navigate = useNavigate();
const dispatch = useDispatch();
const [showExport, setShowExport] = useState(false);
const count = useSelector(selectCount);
const appStore = useSelector(selectAppStore);
const { styles } = useStyles();
@@ -79,38 +82,42 @@ const App: FC = () => {
},
{
label: (
<Link to="/source" className={styles.linkItem}>
<Link to="/converter" className={styles.linkItem}>
<SyncOutlined />
<span>{t("converter")}</span>
</Link>
),
key: "converter",
},
{
label: (
<Link
to="/source"
className={classNames([styles.linkItem, styles.extract])}
>
<ProfileOutlined />
<span>{t("materialExtraction")}</span>
{showExport && (
<Button
title={t("openInNewWindow")}
type="text"
style={{ marginLeft: "auto" }}
icon={<ExportOutlined />}
onClick={async (e) => {
e.stopPropagation();
e.preventDefault();
<Button
title={t("openInNewWindow")}
type="text"
className={styles.hoverButton}
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();
}}
/>
)}
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: (
@@ -124,7 +131,7 @@ const App: FC = () => {
];
const finalItems = items.filter((item) =>
appStore.openInNewWindow ? item?.key !== "source" : true,
appStore.openInNewWindow ? item?.key !== "source" : true
);
const openHelpUrl = () => {
@@ -159,6 +166,14 @@ const App: FC = () => {
<Outlet />
</Content>
<Footer className={styles.containerFooter}>
<Text>
{appStore.privacy && (
<>
<EyeInvisibleOutlined />
</>
)}
</Text>
<Button
type={"link"}
onClick={openHelpUrl}
+40 -24
View File
@@ -1,26 +1,42 @@
import { createStyles } from "antd-style";
export const useStyles = createStyles(({ css, token }) => ({
container: css`
height: 100vh;
width: 100vw;
overflow: hidden;
`,
containerSider: css`
border-right: ${token.colorBorderSecondary} solid 1px;
`,
containerInner: css`
height: 100%;
`,
linkItem: css`
display: flex;
flex-direction: row;
align-items: center;
`,
containerFooter: css`
padding: 5px;
background: ${token.colorBgContainer};
text-align: right;
border-top: ${token.colorBorderSecondary} solid 1px;
`,
}));
export const useStyles = createStyles(({ css, token, cx }) => {
const hoverButton = cx(css`
margin-left: auto;
display: none;
`);
return {
container: css`
height: 100vh;
width: 100vw;
overflow: hidden;
`,
containerSider: css`
border-right: ${token.colorBorderSecondary} solid 1px;
`,
containerInner: css`
height: 100%;
`,
linkItem: css`
display: flex;
flex-direction: row;
align-items: center;
`,
extract: css`
&:hover {
.${hoverButton} {
display: block;
}
}
`,
containerFooter: css`
padding: 5px 5px 5px 15px;
background: ${token.colorBgContainer};
text-align: right;
border-top: ${token.colorBorderSecondary} solid 1px;
justify-content: space-between;
display: flex;
`,
hoverButton,
};
});
+2 -3
View File
@@ -5,18 +5,17 @@ import { Provider } from "react-redux";
import store from "./store";
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
import { tdApp, initClarity } from "./utils";
import { tdApp } from "./utils";
import App from "./App";
import "./i18n";
dayjs.locale("zh-cn");
tdApp.init();
initClarity();
createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode>
<Provider store={store}>
<App />
</Provider>
</StrictMode>,
</StrictMode>
);
@@ -0,0 +1,133 @@
import React, { ReactNode, useState } from "react";
import PageContainer from "../../components/PageContainer";
import { useTranslation } from "react-i18next";
import { Button, Space, message } from "antd";
import useElectron from "../../hooks/electron";
import { ProList } from "@ant-design/pro-components";
import { usePagination } from "ahooks";
import { getFileName } from "../../utils";
import { Conversion } from "../../../../main/types/entity/Conversion";
import { DeleteOutlined, SyncOutlined } from "@ant-design/icons";
import { produce } from "immer";
const Converter = () => {
const { t } = useTranslation();
const {
selectFile,
getConversions,
addConversion,
convertToAudio,
deleteConversion,
} = useElectron();
const [messageApi, contextHolder] = message.useMessage();
const [converting, setConverting] = useState<Record<number, boolean>>({});
const {
data = { total: 0, list: [] },
loading,
pagination,
refresh,
} = usePagination(
({ current, pageSize }) => {
return getConversions({
current,
pageSize,
});
},
{
defaultPageSize: 50,
refreshDeps: [],
}
);
const onClickConvertToAudio = async (item: Conversion) => {
const nextState = produce((draft) => {
draft[item.id] = true;
});
setConverting(nextState);
try {
await convertToAudio(item.id);
messageApi.success(t("convertSuccess"));
} catch (e: any) {
messageApi.error(e.message);
} finally {
const nextState = produce((draft) => {
draft[item.id] = false;
});
setConverting(nextState);
}
};
const onDeleteConversion = async (id: number) => {
await deleteConversion(id);
refresh();
};
const renderActionButtons = (dom: ReactNode, item: Conversion): ReactNode => {
// 下载成功
return [
<Button
type="text"
key="restart"
icon={<SyncOutlined spin={converting[item.id]} />}
title={t("convertToAudio")}
onClick={() => onClickConvertToAudio(item)}
/>,
<Button
type="text"
key="restart"
icon={<DeleteOutlined />}
title={t("delete")}
onClick={() => onDeleteConversion(item.id)}
/>,
];
};
return (
<PageContainer
title={t("converter")}
rightExtra={
<Space>
<Button
onClick={async () => {
const file = await selectFile();
await addConversion({
name: getFileName(file),
path: file,
});
refresh();
}}
>
{t("addFile")}
</Button>
</Space>
}
>
{contextHolder}
<ProList<Conversion>
loading={loading}
className="download-list"
pagination={pagination}
metas={{
title: {
render: (text, record) => {
return record.name;
},
},
description: {
render: (text, record) => {
return record.path;
},
},
actions: {
render: renderActionButtons,
},
}}
rowKey="id"
dataSource={data.list}
/>
</PageContainer>
);
};
export default Converter;
@@ -0,0 +1 @@
// empty
@@ -1,42 +1,29 @@
import React, { FC, ReactNode, useEffect, useState } from "react";
import {
Button,
message,
Progress,
Space,
Tag,
Dropdown,
Typography,
} from "antd";
import { Button, message, Progress, Space, Tag, Typography } from "antd";
import "./index.scss";
import PageContainer from "../../components/PageContainer";
import { usePagination } from "ahooks";
import useElectron from "../../hooks/electron";
import { DownloadStatus, DownloadType } from "../../types";
import { DownloadFilter, DownloadStatus } from "../../types";
import { ProList } from "@ant-design/pro-components";
import {
DownloadOutlined,
EditOutlined,
PauseCircleOutlined,
SyncOutlined,
MoreOutlined,
CodeOutlined,
FileAddOutlined,
} from "@ant-design/icons";
import { useSelector } from "react-redux";
import { selectAppStore } from "../../store";
import DownloadFrom from "../../components/DownloadForm";
import dayjs from "dayjs";
import DownloadForm, { DownloadItemForm } from "../../components/DownloadForm";
import { Trans, useTranslation } from "react-i18next";
import Terminal from "../../components/Terminal";
import { moment } from "../../utils";
import { produce } from "immer";
const { Text } = Typography;
export enum DownloadFilter {
list = "list",
done = "done",
}
interface Props {
filter?: DownloadFilter;
}
@@ -51,16 +38,23 @@ const HomePage: FC<Props> = ({ filter = DownloadFilter.list }) => {
stopDownload,
onDownloadListContextMenu,
deleteDownloadItem,
convertToAudio,
showBrowserWindow,
addDownloadItem,
addDownloadItems,
editDownloadItem,
downloadItemsNow,
downloadNow,
editDownloadNow,
getDownloadLog,
} = useElectron();
const appStore = useSelector(selectAppStore);
const { t } = useTranslation();
const { data, loading, pagination, refresh } = usePagination(
const {
data = { total: 0, list: [] },
loading,
pagination,
refresh,
} = usePagination(
({ current, pageSize }) => {
return getDownloadItems({
current,
@@ -71,11 +65,10 @@ const HomePage: FC<Props> = ({ filter = DownloadFilter.list }) => {
{
defaultPageSize: 50,
refreshDeps: [filter],
},
}
);
const [converting, setConverting] = useState<Record<number, boolean>>({});
const [progress, setProgress] = useState<Record<number, DownloadProgress>>(
{},
{}
);
const [messageApi, contextHolder] = message.useMessage();
const [terminal, setTerminal] = useState({
@@ -84,11 +77,11 @@ const HomePage: FC<Props> = ({ filter = DownloadFilter.list }) => {
log: "",
});
const onDownloadProgress = (e: any, progress: DownloadProgress) => {
setProgress((curProgress) => ({
...curProgress,
[progress.id]: progress,
}));
const onDownloadProgress = (e: unknown, currProgress: DownloadProgress) => {
const nextState = produce((draft) => {
draft[currProgress.id] = currProgress;
});
setProgress(nextState);
};
const onDownloadSuccess = () => {
@@ -105,7 +98,7 @@ const HomePage: FC<Props> = ({ filter = DownloadFilter.list }) => {
const onDownloadMenuEvent = async (
e: any,
params: { action: string; payload: number },
params: { action: string; payload: number }
) => {
const { action, payload } = params;
@@ -160,48 +153,18 @@ const HomePage: FC<Props> = ({ filter = DownloadFilter.list }) => {
refresh();
};
const onClickConvertToAudio = async (item: DownloadItem) => {
setConverting((curConverting) => ({
...curConverting,
[item.id]: true,
}));
try {
await convertToAudio(item.id);
messageApi.success(t("convertSuccess"));
} catch (e: any) {
messageApi.error(e.message);
} finally {
setConverting((curConverting) => ({
...curConverting,
[item.id]: false,
}));
}
};
// 编辑表单
const renderEditForm = (item: DownloadItem) => {
return (
<DownloadFrom
<DownloadForm
key={"edit"}
isEdit
item={item}
trigger={
<Button type="text" title={t("edit")} icon={<EditOutlined />} />
}
onFinish={async (values) => {
try {
await editDownloadItem({
id: item.id,
name: values.name,
url: values.url,
headers: values.headers,
type: DownloadType.m3u8,
});
refresh();
return true;
} catch (err: any) {
messageApi.error(err.message);
}
}}
onAddToList={(values) => confirmAddItem(values)}
onDownloadNow={(values) => confirmAddItem(values, true)}
/>
);
};
@@ -235,7 +198,7 @@ const HomePage: FC<Props> = ({ filter = DownloadFilter.list }) => {
const renderActionButtons = (
dom: ReactNode,
item: DownloadItem,
item: DownloadItem
): ReactNode => {
if (item.status === DownloadStatus.Ready) {
return [
@@ -293,29 +256,7 @@ const HomePage: FC<Props> = ({ filter = DownloadFilter.list }) => {
}
// 下载成功
const curConverting = converting[item.id];
return [
<Dropdown
key="more"
menu={{
items: [
{
label: t("convertToAudio"),
key: "convert",
icon: <SyncOutlined />,
disabled: curConverting,
},
],
onClick: ({ key }) => {
if (key === "convert") {
onClickConvertToAudio(item);
}
},
}}
>
<Button type="text" title={t("more")} icon={<MoreOutlined />} />
</Dropdown>,
];
return [];
};
const renderTitle = (dom: ReactNode, item: DownloadItem): ReactNode => {
@@ -346,7 +287,7 @@ const HomePage: FC<Props> = ({ filter = DownloadFilter.list }) => {
};
const renderDescription = (dom: ReactNode, item: DownloadItem): ReactNode => {
if (progress[item.id] && filter === DownloadFilter.list) {
if (progress[item.id] && filter === DownloadFilter.list && !item.isLive) {
const curProgress = progress[item.id];
const { percent, speed } = curProgress;
@@ -380,6 +321,61 @@ const HomePage: FC<Props> = ({ filter = DownloadFilter.list }) => {
setSelectedRowKeys([]);
};
const confirmAddItem = async (values: any, now?: boolean) => {
const item = {
id: values.id,
name: values.name || moment(),
url: values.url,
headers: values.headers,
type: values.type,
};
if (now) {
await editDownloadNow(item);
} else {
await editDownloadItem(item);
}
refresh();
return true;
};
const confirmAddItems = async (values: DownloadItemForm, now?: boolean) => {
const { batch, batchList = "", name, headers, type, url } = values;
if (batch) {
const items: Omit<DownloadItem, "id">[] = batchList
.split("\n")
.map((url: string, i: number) => {
return {
url: url.trim(),
name: `${name}_${i}`,
headers,
type,
};
});
if (now) {
await downloadItemsNow(items);
} else {
await addDownloadItems(items);
}
} else {
const item: Omit<DownloadItem, "id"> = {
name,
url,
headers,
type,
};
if (now) {
await downloadNow(item);
} else {
await addDownloadItem(item);
}
}
refresh();
return true;
};
return (
<PageContainer
title={
@@ -394,45 +390,17 @@ const HomePage: FC<Props> = ({ filter = DownloadFilter.list }) => {
{t("openBrowser")}
</Button>
)}
{filter === DownloadFilter.list && (
<Button onClick={() => refresh()}>{t("refresh")}</Button>
)}
<Button onClick={() => openDir(appStore.local)}>
{t("openFolder")}
</Button>
{filter === DownloadFilter.list && (
<DownloadFrom
<DownloadForm
destroyOnClose
trigger={
<Button icon={<FileAddOutlined />}>{t("newDownload")}</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;
}}
onAddToList={async (values) => confirmAddItems(values)}
onDownloadNow={async (values) => confirmAddItems(values, true)}
/>
)}
</Space>
@@ -464,7 +432,7 @@ const HomePage: FC<Props> = ({ filter = DownloadFilter.list }) => {
}}
rowKey="id"
rowSelection={rowSelection}
dataSource={data?.list || []}
dataSource={data.list || []}
tableAlertOptionRender={({ selectedRowKeys, onCleanSelected }) => {
if (selectedRowKeys.length === 0) {
return null;
@@ -496,11 +464,11 @@ const HomePage: FC<Props> = ({ filter = DownloadFilter.list }) => {
tableAlertRender={({ selectedRows }) => {
return (
<>
{data?.list.length !== 0 && (
{data.list.length !== 0 && (
<Button
type="link"
onClick={() => {
const list = data?.list || [];
const list = data.list || [];
const ids = list.map((item) => item.id || 0);
if (ids) {
setSelectedRowKeys(ids);
@@ -10,7 +10,11 @@ import {
} from "@ant-design/pro-components";
import "./index.scss";
import { Button, FormInstance, message, Space, Tooltip } from "antd";
import { FolderOpenOutlined, QuestionCircleOutlined } from "@ant-design/icons";
import {
ClearOutlined,
FolderOpenOutlined,
QuestionCircleOutlined,
} from "@ant-design/icons";
import { selectAppStore, setAppStore } from "../../store";
import { useDispatch, useSelector } from "react-redux";
import useElectron from "../../hooks/electron";
@@ -26,6 +30,7 @@ const SettingPage: React.FC = () => {
setAppStore: ipcSetAppStore,
getEnvPath,
openDir,
clearWebviewCache,
} = useElectron();
const dispatch = useDispatch();
const { t } = useTranslation();
@@ -115,6 +120,7 @@ const SettingPage: React.FC = () => {
name="language"
label={t("displayLanguage")}
valueEnum={{
[AppLanguage.System]: t("followSystem"),
[AppLanguage.ZH]: t("chinese"),
[AppLanguage.EN]: t("english"),
}}
@@ -124,7 +130,17 @@ const SettingPage: React.FC = () => {
<ProFormSwitch label={t("openInNewWindow")} name="openInNewWindow" />
<ProFormSwitch label={t("downloadPrompt")} name="promptTone" />
<ProFormSwitch label={t("showTerminal")} name="showTerminal" />
{/* <ProFormSwitch label={t("privacy")} name="privacy" /> */}
<ProFormSwitch
label={renderTooltipLabel(
t("autoUpgrade"),
t("autoUpgradeTooltip")
)}
name="autoUpgrade"
/>
<ProFormSwitch
label={renderTooltipLabel(t("privacy"), t("privacyTooltip"))}
name="privacy"
/>
</ProFormGroup>
<ProFormGroup title={t("browserSetting")} direction={"vertical"}>
<ProFormText
@@ -135,17 +151,16 @@ const SettingPage: React.FC = () => {
/>
<ProFormSwitch
name="useProxy"
label={renderTooltipLabel(t("proxySwitch"), t("proxyDescription"))}
label={t("proxySwitch")}
rules={[
({ getFieldValue, setFieldValue }) => ({
validator() {
if (getFieldValue("proxy") === "") {
setFieldValue("useProxy", false);
{
validator(rules, value) {
if (value && formRef.current.getFieldValue("proxy") === "") {
return Promise.reject(t("pleaseEnterProxyFirst"));
}
return Promise.resolve();
},
}),
},
]}
/>
<ProFormSwitch label={t("blockAds")} name="blockAds" />
@@ -153,17 +168,48 @@ const SettingPage: React.FC = () => {
<ProFormSwitch
label={renderTooltipLabel(
t("useImmersiveSniffing"),
t("immersiveSniffingDescription"),
t("immersiveSniffingDescription")
)}
name="useExtension"
/>
<ProFormText label={t("moreAction")}>
<Space>
<Button
onClick={async () => {
try {
await clearWebviewCache();
messageApi.success(t("clearCacheSuccess"));
} catch (err) {
messageApi.error(t("clearCacheFailed"));
}
}}
icon={<ClearOutlined />}
>
{t("clearCache")}
</Button>
</Space>
</ProFormText>
</ProFormGroup>
<ProFormGroup title={t("downloadSetting")} direction={"vertical"}>
<ProFormSwitch
name="downloadProxySwitch"
label={t("downloadProxySwitch")}
rules={[
{
validator(rules, value) {
if (value && formRef.current.getFieldValue("proxy") === "") {
return Promise.reject(t("pleaseEnterProxyFirst"));
}
return Promise.resolve();
},
},
]}
/>
<ProFormSwitch label={t("deleteSegments")} name="deleteSegments" />
<ProFormDigit
label={renderTooltipLabel(
t("maxRunner"),
t("maxRunnerDescription"),
t("maxRunnerDescription")
)}
name="maxRunner"
min={1}
@@ -173,13 +219,13 @@ const SettingPage: React.FC = () => {
<ProFormText label={t("moreAction")}>
<Space>
<Button
onClick={() => envPath?.workspace && openDir(envPath.workspace)}
onClick={() => openDir(envPath.workspace)}
icon={<FolderOpenOutlined />}
>
{t("configDir")}
</Button>
<Button
onClick={() => envPath?.binPath && openDir(envPath.binPath)}
onClick={() => openDir(envPath.binPath)}
icon={<FolderOpenOutlined />}
>
{t("binPath")}
@@ -105,9 +105,14 @@
align-items: center;
justify-content: center;
border-radius: 5px;
transition: background 0.3s ease;
&:hover {
background: #f2f6fc;
.favorite-remove-btn {
display: block;
}
}
@media (prefers-color-scheme: dark) {
@@ -116,6 +121,14 @@
}
}
.favorite-remove-btn {
position: absolute;
top: 2px;
right: -2px;
color: gray;
display: none;
}
.card-text {
width: 100%;
margin-top: 6px;
@@ -23,21 +23,14 @@ import {
message,
Space,
Spin,
Tabs,
} from "antd";
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import PageContainer from "../../components/PageContainer";
import useElectron from "../../hooks/electron";
import { generateUrl, getFavIcon } from "../../utils";
import "./index.scss";
import {
ModalForm,
ProFormDigit,
ProFormSelect,
ProFormSwitch,
ProFormText,
} from "@ant-design/pro-components";
import { ModalForm, ProFormText } from "@ant-design/pro-components";
import {
BrowserStatus,
PageMode,
@@ -48,29 +41,12 @@ import {
import WebView from "../../components/WebView";
import { selectAppStore } from "../../store";
import { useTranslation } from "react-i18next";
import { nanoid } from "nanoid";
import localforage from "localforage";
import { DownloadType } from "../../types";
import DownloadForm, { DownloadFormRef } from "../../components/DownloadForm";
interface SourceExtractProps {
page?: boolean;
}
// 下载表单
interface DownloadForm {
teleplay: boolean;
type: DownloadType;
name: string;
url: string;
numberOfEpisodes: number;
}
// 集数
interface NumberOfEpisodes {
teleplay: boolean;
numberOfEpisodes: number;
}
const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
const {
getFavorites,
@@ -95,58 +71,36 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
const { data: favoriteList = [], refresh } = useRequest(getFavorites);
const [favoriteAddForm] = Form.useForm<Favorite>();
const [messageApi, contextHolder] = message.useMessage();
const [hoverId, setHoverId] = useState<number>(-1);
const store = useSelector(selectBrowserStore);
const appStore = useSelector(selectAppStore);
const [form] = Form.useForm<DownloadForm>();
const [modalShow, setModalShow] = useState(false);
const [placeHolder, setPlaceHolder] = useState<string>("");
const [modalReadyShow, setModalReadyShow] = useState(false);
const [downloadItems, setDownloadItems] = useState<DownloadForm[]>([]);
const sessionId = useRef("");
const downloadForm = useRef<DownloadFormRef>(null);
const originTitle = useRef(document.title);
const curIsFavorite = favoriteList.find((item) => item.url === store.url);
const curIsFavorite = useMemo(() => {
return favoriteList.find((item) => item.url === store.url);
}, [favoriteList, store.url]);
useAsyncEffect(async () => {
const store = await ipcGetAppStore();
dispatch(setAppStore(store));
}, []);
const loadUrl = async (url: string) => {
const id = nanoid();
sessionId.current = id;
try {
dispatch(
setBrowserStore({
url: "",
mode: PageMode.Browser,
status: BrowserStatus.Loading,
}),
);
await webviewLoadURL(url);
if (sessionId.current === id) {
dispatch(
setBrowserStore({
url: url,
status: BrowserStatus.Loaded,
}),
);
}
} catch (err) {
if (sessionId.current === id) {
dispatch(
setBrowserStore({
status: BrowserStatus.Failed,
errMsg: (err as any).message,
}),
);
}
}
const loadUrl = (url: string) => {
dispatch(
setBrowserStore({
url,
mode: PageMode.Browser,
status: BrowserStatus.Loading,
})
);
webviewLoadURL(url);
};
const goto = async () => {
const goto = () => {
const link = generateUrl(store.url);
await loadUrl(link);
loadUrl(link);
};
const onInputKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -180,14 +134,9 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
const onClickGoBack = async () => {
const back = await webviewGoBack();
if (!back) {
dispatch(
setBrowserStore({
url: "",
mode: PageMode.Default,
}),
);
document.title = originTitle.current;
dispatch(setBrowserStore({ url: "", title: "", mode: PageMode.Default }));
}
};
@@ -196,8 +145,9 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
dispatch(
setBrowserStore({
url: "",
title: "",
mode: PageMode.Default,
}),
})
);
};
@@ -209,20 +159,8 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
await goto();
};
const onClickLoadItem = async (item: Favorite) => {
await loadUrl(item.url);
};
const onDomReady = (e: unknown, data: UrlDetail) => {
if (data.url) {
document.title = data.title;
dispatch(
setBrowserStore({
url: data.url,
title: data.title,
}),
);
}
const onClickLoadItem = (item: Favorite) => {
loadUrl(item.url);
};
const onFavoriteEvent = async (
@@ -233,7 +171,7 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
}: {
action: string;
payload: number;
},
}
) => {
if (action === "open") {
const item = favoriteList.find((item) => item.id === payload);
@@ -252,51 +190,80 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
}, []);
// 设置当前的下载表单
const setCurrentDownloadForm = async (data: DownloadForm) => {
const { type, url, name } = data;
const setCurrentDownloadForm = async (data: DownloadItem) => {
const { type, url, name, headers } = data;
const noe = await localforage.getItem<NumberOfEpisodes>("numberOfEpisodes");
const formData = form.getFieldsValue();
const teleplay = formData.teleplay || noe?.teleplay || false;
const numberOfEpisodes =
formData.numberOfEpisodes || noe?.numberOfEpisodes || 1;
form.setFieldsValue({
downloadForm.current.setFieldsValue({
type,
url,
name,
teleplay,
numberOfEpisodes,
headers,
});
};
const onShowDownloadDialog = async (
e: unknown,
data: DownloadForm[],
image: string,
data: DownloadItem[],
image: string
) => {
setDownloadItems(data);
setCurrentDownloadForm(data[0]);
// FIXME: 选择
setCurrentDownloadForm(data[data.length - 1]);
setPlaceHolder(image);
setModalReadyShow(true);
setTimeout(() => {
setModalShow(true);
}, 0);
setModalShow(true);
};
useEffect(() => {
const prevTitle = document.title;
addIpcListener("webview-dom-ready", onDomReady);
addIpcListener("webview-fail-load", onFailLoad);
addIpcListener("webview-did-navigate", onDidNavigate);
addIpcListener("webview-did-navigate-in-page", onDidNavigateInPage);
addIpcListener("favorite-item-event", onFavoriteEvent);
addIpcListener("show-download-dialog", onShowDownloadDialog);
return () => {
document.title = prevTitle;
removeIpcListener("webview-dom-ready", onDomReady);
removeIpcListener("webview-fail-load", onFailLoad);
removeIpcListener("webview-did-navigate", onDidNavigate);
removeIpcListener("webview-did-navigate-in-page", onDidNavigateInPage);
removeIpcListener("favorite-item-event", onFavoriteEvent);
removeIpcListener("show-download-dialog", onShowDownloadDialog);
};
}, [store.status]);
const setPageInfo = ({ url, title }: UrlDetail) => {
document.title = title;
dispatch(setBrowserStore({ url, title }));
};
const onDomReady = (e: unknown, info: UrlDetail) => {
setPageInfo(info);
};
const onFailLoad = (e: unknown, data: { code: number; desc: string }) => {
dispatch(
setBrowserStore({
status: BrowserStatus.Failed,
errCode: data.code,
errMsg: data.desc,
})
);
};
const onDidNavigate = (e: unknown, info: UrlDetail) => {
setPageInfo(info);
dispatch(setBrowserStore({ status: BrowserStatus.Loaded }));
};
const onDidNavigateInPage = (e: unknown, info: UrlDetail) => {
setPageInfo(info);
};
useEffect(() => {
document.title = store.title || document.title;
return () => {
document.title = originTitle.current;
};
}, []);
// 合并到主页
@@ -311,7 +278,7 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
dispatch(
setAppStore({
isMobile: nextMode,
}),
})
);
};
@@ -407,7 +374,7 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
let content = <div></div>;
if (store.status === BrowserStatus.Loading) {
content = <Spin />;
} else if (modalReadyShow || modalShow) {
} else if (modalShow) {
content = (
<img
src={placeHolder}
@@ -420,7 +387,9 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
);
} else if (store.status === BrowserStatus.Failed) {
content = (
<Empty description={store.errMsg || t("loadFailed")}>
<Empty
description={`${store.errMsg || t("loadFailed")} (${store.errCode})`}
>
<Space>
<Button type="primary" onClick={onClickGoHome}>
{t("backToHome")}
@@ -514,35 +483,19 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
onFavoriteItemContextMenu(item.id);
}}
>
<div
className="list-tem-card"
onClick={() => onClickLoadItem(item)}
onMouseLeave={() => {
setHoverId(-1);
}}
onMouseOver={() => {
setHoverId(item.id);
}}
>
{hoverId === item.id && (
<Button
style={{
position: "absolute",
right: -1,
top: 2,
color: "gray",
}}
type="link"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
removeFavorite(item.id);
refresh();
}}
>
<CloseOutlined />
</Button>
)}
<div className="list-tem-card" onClick={() => onClickLoadItem(item)}>
<Button
className="favorite-remove-btn"
type="link"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
removeFavorite(item.id);
refresh();
}}
>
<CloseOutlined />
</Button>
{item.icon ? (
<Avatar shape="square" src={item.icon} icon={<LinkOutlined />} />
) : (
@@ -570,22 +523,12 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
const confirmDownload = async (now?: boolean) => {
try {
const data = form.getFieldsValue();
const { numberOfEpisodes, teleplay, ...item } = data;
await localforage.setItem<NumberOfEpisodes>("numberOfEpisodes", {
numberOfEpisodes,
teleplay,
});
if (teleplay) {
item.name = `${item.name}——第${numberOfEpisodes}`;
}
const data = downloadForm.current.getFieldsValue();
if (now) {
await downloadNow(item);
await downloadNow(data);
} else {
await addDownloadItem(item);
await addDownloadItem(data);
}
// 提交成功后关闭弹窗
@@ -601,114 +544,14 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
// 渲染表单
const renderModalForm = () => {
return (
<ModalForm<DownloadForm>
<DownloadForm
isEdit
ref={downloadForm}
open={modalShow}
title={t("newDownload")}
onOpenChange={setModalShow}
form={form}
modalProps={{
destroyOnClose: true,
afterOpenChange: setModalReadyShow,
okText: t("downloadNow"),
}}
submitter={{
render: (props, [, okButton]) => {
return [
<Button key="addToList" onClick={() => confirmDownload()}>
{t("addToDownloadList")}
</Button>,
okButton,
];
},
}}
submitTimeout={2000}
onFinish={() => confirmDownload(true)}
labelCol={{ style: { width: "120px" } }}
layout="horizontal"
width={550}
labelAlign={"left"}
colon={false}
>
<Tabs
onChange={(key) => {
const item = downloadItems.find((item) => item.url === key);
if (item) {
setCurrentDownloadForm(item);
}
}}
items={downloadItems.map((item) => ({
key: item.url,
label: item.name,
}))}
/>
<ProFormSelect
width="xl"
name="type"
label={t("videoType")}
disabled
placeholder={t("pleaseSelectVideoType")}
/>
<Form.Item noStyle shouldUpdate>
{(form) => {
if (form.getFieldValue("type") !== "bilibili") {
return (
<ProFormSwitch
label={t("showNumberOfEpisodes")}
name="teleplay"
/>
);
}
}}
</Form.Item>
<Form.Item noStyle shouldUpdate>
{(form) => {
if (
form.getFieldValue("type") !== "bilibili" &&
form.getFieldValue("teleplay")
) {
return (
<ProFormDigit
label={t("numberOfEpisodes")}
tooltip={t("canUseMouseWheelToAdjust")}
name="numberOfEpisodes"
width="xl"
min={1}
max={10000}
fieldProps={{
changeOnWheel: true,
}}
/>
);
}
}}
</Form.Item>
<ProFormText
width="xl"
name="name"
label={t("videoName")}
placeholder={t("pleaseEnterVideoName")}
rules={[
{
required: true,
message: t("pleaseEnterVideoName"),
},
]}
/>
<ProFormText
width="xl"
name="url"
allowClear={false}
label={t("videoLink")}
placeholder={t("pleaseEnterVideoLink")}
rules={[
{
required: true,
message: t("pleaseEnterVideoLink"),
},
]}
/>
</ModalForm>
onDownloadNow={() => confirmDownload(true)}
onAddToList={() => confirmDownload()}
/>
);
};
+9
View File
@@ -72,6 +72,14 @@ declare interface AppStore {
language?: AppLanguage;
// 是否显示终端
showTerminal?: boolean;
// 隐私模式
privacy?: boolean;
// 机器id
machineId?: string;
// 下载代理设置
downloadProxySwitch?: boolean;
// 自动更新
autoUpgrade?: boolean;
}
declare interface BrowserStore {
@@ -79,6 +87,7 @@ declare interface BrowserStore {
url: string;
title: string;
status: BrowserStatus;
errCode?: number;
errMsg?: string;
}
+6 -2
View File
@@ -1,6 +1,6 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { RootState } from ".";
import { AppTheme } from "../types";
import { AppLanguage, AppTheme } from "../types";
import i18n from "../i18n";
const initialState: AppStore = {
@@ -14,8 +14,12 @@ const initialState: AppStore = {
useExtension: false,
isMobile: false,
maxRunner: 2,
language: "system",
language: AppLanguage.System,
showTerminal: false,
privacy: false,
machineId: "",
downloadProxySwitch: false,
autoUpgrade: true,
};
export const appSlice = createSlice({
+5
View File
@@ -23,3 +23,8 @@ export enum DownloadType {
m3u8 = "m3u8",
bilibili = "bilibili",
}
export enum DownloadFilter {
list = "list",
done = "done",
}
-14
View File
@@ -1,14 +0,0 @@
/* eslint-disable */
// @ts-nocheck
// prettier-ignore
export function initClarity() {
try {
(function(c,l,a,r,i,t,y){
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", import.meta.env.APP_CLARITY_APPID);
} catch (e) {
// empty
}
}
+11 -1
View File
@@ -1,8 +1,8 @@
import dayjs from "dayjs";
import { isUrl } from "./url";
export { http } from "./http";
export { tdApp } from "./tdapp";
export { initClarity } from "./clarity";
export const requestImage = (url: string, timeout = 1000): Promise<void> => {
return new Promise((resolve, reject) => {
@@ -45,3 +45,13 @@ export const generateUrl = (url: string) => {
}
return result;
};
export function moment() {
return dayjs().format("YYYY-MM-DDTHH:mm:ssZ");
}
export function getFileName(url: string) {
const urlObject = new URL(url);
const name = urlObject.pathname.split("/").pop() || "";
return decodeURIComponent(name);
}
+1 -1
View File
@@ -17,7 +17,7 @@ class TDEvent {
headElement.appendChild(script);
}
private onEvent(eventId: string, mapKv: Record<string, string> = {}) {
onEvent(eventId: string, mapKv: Record<string, string> = {}) {
try {
window.TDAPP?.onEvent(eventId, "", mapKv);
} catch (e) {
+1 -2
View File
@@ -8,10 +8,9 @@ declare global {
onEvent: (
eventId: string,
label: "",
mapKv: Record<string, string>,
mapKv: Record<string, string>
) => void;
};
clarity?: any;
}
}
-17
View File
@@ -14,22 +14,5 @@ export default defineConfig({
build: {
outDir: path.resolve(__dirname, "../main/app/build/renderer"),
emptyOutDir: true,
rollupOptions: {
output: {
manualChunks: {
react: [
"react",
"react-dom",
"react-router-dom",
"@reduxjs/toolkit",
"react-redux",
"sort-by",
],
antd: ["antd"],
icons: ["@ant-design/icons"],
"pro-components": ["@ant-design/pro-components"],
},
},
},
},
});
+1994 -2782
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -4,4 +4,6 @@ import { echo, $ } from "zx";
echo("开始构建 development ...");
echo("当前所在的目录是:", process.cwd());
await $`npm run types`;
await $`npm run build:plugin`;
+1 -1
View File
@@ -2,7 +2,7 @@ import { ProcessOutput } from "zx/core";
export function printObject(
object: Record<string, unknown> | ProcessOutput,
method: "log" | "warn" | "error" = "log",
method: "log" | "warn" | "error" = "log"
) {
for (const [key, value] of Object.entries(object)) {
// eslint-disable-next-line no-console