Compare commits

...

59 Commits

Author SHA1 Message Date
士子☀️ 7bc3f83c88 update electron. 2024-01-21 16:56:59 +08:00
士子☀️ 1af2803c22 eslint 2024-01-21 01:20:02 +08:00
士子☀️ fd6a81abfd package version 2024-01-21 00:01:09 +08:00
士子☀️ a383093914 Merge pull request #71 from caorushizi/dependabot/npm_and_yarn/typescript-eslint/parser-6.19.0
build(deps-dev): bump @typescript-eslint/parser from 5.60.0 to 6.19.0
2024-01-20 23:29:33 +08:00
士子☀️ 13656429af Merge pull request #70 from caorushizi/dependabot/npm_and_yarn/better-sqlite3-9.3.0
build(deps): bump better-sqlite3 from 8.7.0 to 9.3.0
2024-01-20 23:29:11 +08:00
dependabot[bot] bc34881b38 build(deps-dev): bump @typescript-eslint/parser from 5.60.0 to 6.19.0
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 5.60.0 to 6.19.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.19.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-20 15:11:08 +00:00
dependabot[bot] e3b11cb087 build(deps): bump better-sqlite3 from 8.7.0 to 9.3.0
Bumps [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) from 8.7.0 to 9.3.0.
- [Release notes](https://github.com/WiseLibs/better-sqlite3/releases)
- [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v8.7.0...v9.3.0)

---
updated-dependencies:
- dependency-name: better-sqlite3
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-20 15:11:00 +00:00
士子☀️ 8df78ca377 Merge pull request #61 from caorushizi/dependabot/npm_and_yarn/typescript-5.3.3
build(deps-dev): bump typescript from 5.1.6 to 5.3.3
2024-01-20 23:08:49 +08:00
士子☀️ f12919c6bc Update README.md 2024-01-16 23:02:29 +08:00
士子☀️ 00df324ae7 Merge pull request #66 from caorushizi/readme
readme
2024-01-15 22:25:23 +08:00
士子☀️ f14f9ffc1b readme 2024-01-15 22:23:40 +08:00
dependabot[bot] a388b725bc build(deps-dev): bump typescript from 5.1.6 to 5.3.3
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.1.6 to 5.3.3.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v5.1.6...v5.3.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-14 07:37:58 +00:00
caorushizi e64eaeefef macos 2024-01-14 15:35:15 +08:00
士子☀️ 6830d69b62 build script. 2024-01-14 12:09:08 +08:00
士子☀️ 8f609a8e93 checkout 2024-01-14 10:42:54 +08:00
士子☀️ 99c23f47c5 setup go 2024-01-14 10:36:14 +08:00
士子☀️ 95f9e20f39 go build 2024-01-14 01:34:08 +08:00
士子☀️ df1e664bc9 Merge pull request #60 from caorushizi/dev0114
add plugin
2024-01-14 01:25:05 +08:00
士子☀️ fec30a6453 add plugin 2024-01-14 01:24:24 +08:00
士子☀️ a048211486 add plugin 2024-01-14 01:14:02 +08:00
士子☀️ 465e9b2988 add plugin 2024-01-13 23:19:19 +08:00
士子☀️ af1ff19cb2 add web plugin 2024-01-13 20:28:58 +08:00
士子☀️ eae470f2ec video server 2024-01-13 12:24:52 +08:00
士子☀️ 1933799e32 feat: video list. 2024-01-13 01:03:46 +08:00
士子☀️ c7977bf7b4 Merge pull request #55 from caorushizi/dependabot/npm_and_yarn/electron-25.8.4
build(deps-dev): bump electron from 25.2.0 to 25.8.4
2023-12-29 16:40:43 +08:00
士子☀️ ce8407ce46 Merge pull request #58 from caorushizi/dependabot/npm_and_yarn/axios-1.6.0
build(deps): bump axios from 1.4.0 to 1.6.0
2023-12-29 16:40:01 +08:00
dependabot[bot] 5d313df190 build(deps): bump axios from 1.4.0 to 1.6.0
Bumps [axios](https://github.com/axios/axios) from 1.4.0 to 1.6.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.4.0...v1.6.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-11 13:41:05 +00:00
dependabot[bot] c560a09904 build(deps-dev): bump electron from 25.2.0 to 25.8.4
Bumps [electron](https://github.com/electron/electron) from 25.2.0 to 25.8.4.
- [Release notes](https://github.com/electron/electron/releases)
- [Changelog](https://github.com/electron/electron/blob/main/docs/breaking-changes.md)
- [Commits](https://github.com/electron/electron/compare/v25.2.0...v25.8.4)

---
updated-dependencies:
- dependency-name: electron
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-05 17:37:26 +00:00
士子☀️ df66a92989 Merge pull request #46 from caorushizi/feat/v2.1.0
m3u8
2023-08-22 14:49:01 +08:00
士子☀️ 52c9611e49 m3u8 2023-08-07 23:54:19 +08:00
士子☀️ 4018112a1d 优化二进制调用逻辑 2023-08-02 21:26:46 +08:00
士子☀️ 633a257e65 优化表单 2023-08-01 23:49:22 +08:00
caorushizi 0666d41226 工程优化 2023-07-29 14:14:37 +08:00
士子☀️ c2cfe55e24 Merge pull request #43 from caorushizi/dev/2.1.0 2023-07-29 13:43:37 +08:00
士子☀️ cb7fbddd7a 工程优化 2023-07-29 13:41:16 +08:00
士子☀️ 135d9fed1b 工程优化 2023-07-29 10:39:27 +08:00
士子☀️ 267aedc594 工程优化 2023-07-29 09:24:11 +08:00
士子☀️ 325d0da930 工程优化 2023-07-29 00:28:01 +08:00
士子☀️ 615bc74d15 Merge pull request #42 from caorushizi/dev/2.1.0 2023-07-27 17:59:32 +08:00
士子☀️ 1ca6935d36 download form. 2023-07-24 23:49:10 +08:00
士子☀️ 3bfb7139d3 download form. 2023-07-22 12:56:56 +08:00
士子☀️ 14d44765e5 form name 2023-07-22 00:03:58 +08:00
士子☀️ 807dd2d920 request 2023-07-21 23:39:02 +08:00
士子☀️ c9a0a7f7c2 request 2023-07-21 23:31:07 +08:00
士子☀️ a3eea6d501 v2.1.0 2023-07-20 23:25:33 +08:00
士子☀️ 4ce7dcac1b Merge pull request #38 from caorushizi/dev 2023-07-18 23:51:35 +08:00
士子☀️ 096762f24c some thing 2023-07-18 23:49:03 +08:00
士子☀️ baf358d223 something 2023-07-18 23:44:22 +08:00
士子☀️ e72973346b add: get web request. 2023-07-12 00:30:15 +08:00
士子☀️ 41258ecee8 add: get web request response. 2023-07-11 22:48:14 +08:00
士子☀️ b6a5be152a Merge pull request #37 from caorushizi/dev0709 2023-07-09 15:39:55 +08:00
士子☀️ d5d45c4935 fix: download use proxy. 2023-07-09 15:38:48 +08:00
士子☀️ 81eb4ecf09 Merge pull request #36 from caorushizi/dev0709 2023-07-09 15:09:23 +08:00
士子☀️ 5a4cc8c449 bin dir 2023-07-09 15:07:57 +08:00
士子☀️ e7ab664310 release: v2.0.2 2023-07-09 14:54:21 +08:00
士子☀️ 3cb2fa27b9 release: v2.0.2 2023-07-09 14:18:41 +08:00
士子☀️ e29e41aa4c release: v2.0.2 2023-07-09 14:01:15 +08:00
士子☀️ cc62613640 Merge pull request #35 from caorushizi/release/v2.0.2 2023-07-09 13:53:19 +08:00
士子☀️ 2720bcc685 release: v2.0.2 2023-07-09 13:52:40 +08:00
113 changed files with 6399 additions and 5071 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
APP_NAME=media-downloader
APP_ID=mediago.ziying.site
APP_COPYRIGHT=caorushizi
APP_VERSION=2.0.2
APP_VERSION=2.0.3-beta.2
APP_SERVER_PORT=8433
+1
View File
@@ -1,4 +1,5 @@
APP_TD_APPID=
APP_CLARITY_APPID=
APP_DOWNLOAD_DEBUG=true
GH_TOKEN=
+1
View File
@@ -1,4 +1,5 @@
APP_TD_APPID=
APP_CLARITY_APPID=
APP_DOWNLOAD_DEBUG=
GH_TOKEN=
+1 -1
View File
@@ -5,7 +5,7 @@
version: 2
updates:
- package-ecosystem: "" # See documentation for possible values
- package-ecosystem: "npm" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
+10 -1
View File
@@ -29,7 +29,9 @@ jobs:
steps:
# step1: check out repository
- name: Check out git repository
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
submodules: true
# step2: install node env
- name: Install Node.js
@@ -37,6 +39,13 @@ jobs:
with:
node-version: "16.x"
# step3: install go env
- name: Install Go
uses: actions/setup-go@v4
with:
go-version: "1.21.5"
# step4: install pnpm env
- uses: pnpm/action-setup@v2
with:
version: latest
+1
View File
@@ -3,3 +3,4 @@ node_modules
*.local
.idea
.parcel-cache
.vitepress
+3
View File
@@ -0,0 +1,3 @@
[submodule "internal"]
path = internal
url = https://github.com/caorushizi/mediago
+48 -14
View File
@@ -1,25 +1,59 @@
# media-downloader
<img src="https://socialify.git.ci/caorushizi/m3u8-downloader/image?font=Inter&forks=1&issues=1&language=1&name=1&owner=1&pattern=Circuit%20Board&pulls=1&stargazers=1&theme=Auto" alt="MediaDownloader"/>
m3u8 视频在线提取工具 流媒体下载 m3u8 下载 桌面客户端 windows mac。
可以直接在线获取 m3u8 链接地址,无需使用使用网络抓包,无需安装浏览器插件,可以直接带出请求标头……详情请参考[使用帮助](https://downloader.caorushizi.cn/guides.html?form=github)
<div align="center">
<h1>Media downloader</h1>
<a href="https://downloader.caorushizi.cn/guides.html?form=github">快速开始</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://downloader.caorushizi.cn?form=github">官网</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://downloader.caorushizi.cn/documents.html?form=github">文档</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://github.com/caorushizi/m3u8-downloader/discussions">Discussions</a>
<br>
<br>
## 上手指南
<img alt="GitHub Downloads (all assets, all releases)" src="https://img.shields.io/github/downloads/caorushizi/m3u8-downloader/total">
<img alt="GitHub Downloads (all assets, latest release)" src="https://img.shields.io/github/downloads/caorushizi/m3u8-downloader/latest/total">
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/caorushizi/m3u8-downloader">
<img alt="GitHub forks" src="https://img.shields.io/github/forks/caorushizi/m3u8-downloader">
以下指南将帮助你在本地机器上安装和运行该项目,进行开发和测试。关于如何将该项目部署到在线环境,请参考部署小节。
<hr />
</div>
## 安装要求
## Intro
运行代码需要 node 和 pnpm,node 需要在官网下载安装,pnpm 可以通过`npm i -g pnpm`安装
本项目支持 m3u8 视频在线提取工具 流媒体下载 m3u8 下载
- **✅&nbsp; 无需抓包**: 使用软件自带浏览器可以轻松嗅探网页中的视频资源,通过嗅探到的资源列表选择自己想要下载的资源,简单快速。
- **📱&nbsp; 移动播放**: 可以轻松无缝的在PC和移动设备之前切换,下载完成后即可使用手机观看视频。
- **⚡️&nbsp; 批量下载**: 支持同时下载多个视频和直播资源,高速带宽不闲置。
## Quickstart
运行代码需要 node 、 pnpm 和 go,node、go 需要在官网下载安装,pnpm 可以通过`npm i -g pnpm`安装。
## 运行代码
安装依赖 `pnpm i`
```shell
# 代码下载
git clone --recursive https://github.com/caorushizi/m3u8-downloader.git
开发环境 `pnpm run dev`
# 安装依赖
pnpm i
打包运行 `pnpm run release`
# 开发环境
pnpm dev
## 下载链接
# 打包运行
pnpm release
```
## Releases
**v2.0.12023.7.9 发布)**
- [windows media-downloader v2.0.2](https://github.com/caorushizi/m3u8-downloader/releases/download/v2.0.2/media-downloader-setup-2.0.2.exe)
- [macos media-downloader v2.0.2](https://github.com/caorushizi/m3u8-downloader/releases/download/v2.0.2/media-downloader-setup-2.0.2.dmg)
**v2.0.12023.7.1 发布)**
@@ -45,11 +79,11 @@ m3u8 视频在线提取工具 流媒体下载 m3u8 下载 桌面客户端 window
## 软件截图
![首页](https://raw.githubusercontent.com/caorushizi/m3u8-downloader/master/screenshot/home-page.png)
![首页](https://static.ziying.site/v2.0.3-beta-home.png)
![设置页面](https://raw.githubusercontent.com/caorushizi/m3u8-downloader/master/screenshot/setting-page.png)
![设置页面](https://static.ziying.site/v2.0.3-beta-settings.png)
![资源提取](https://raw.githubusercontent.com/caorushizi/m3u8-downloader/master/screenshot/browser-page.png)
![资源提取](https://static.ziying.site/v2.0.3-beta-extract.png)
## 技术栈
+4 -2
View File
@@ -20,11 +20,13 @@
--vp-button-brand-text: #f2f7fd;
--vp-button-brand-bg: #2a82f6;
--vp-button-brand-active-border: #5e9ef3;
--vp-button-brand-active-text: #fff;
--vp-button-brand-active-bg: #409eff;
--vp-button-brand-hover-border: #5e9ef3;
--vp-button-brand-hover-text: #fff;
--vp-button-brand-hover-bg: #2a82f6;
--vp-button-brand-active-border: #f6ceec;
--vp-c-brand: #2a82f6;
}
+1 -1
View File
@@ -26,5 +26,5 @@ features:
details: 可以轻松无缝的在PC和移动设备之前切换,下载完成后即可使用手机观看视频。
- icon: ⚡️
title: 支持批量下载
details: 支持同时最多下载10个视频和直播资源,高速带宽不闲置。
details: 支持同时下载多个视频和直播资源,高速带宽不闲置。
---
Submodule
+1
Submodule internal added at ca43a37335
+12 -7
View File
@@ -4,28 +4,33 @@
"description": "electron-template",
"main": "index.js",
"scripts": {
"dev": "pnpm run build:mobile && pnpm --parallel -F \"./packages/*\" run dev",
"build": "pnpm run build:mobile && pnpm run build:main && pnpm run build:renderer",
"dev": "zx scripts/dev.mjs && pnpm --parallel -F \"./packages/*\" run dev",
"build": "zx scripts/build.mjs",
"build:mobile": "pnpm -F mobile run build",
"build:plugin": "pnpm -F plugin run build",
"build:main": "pnpm -F main run build",
"build:renderer": "pnpm -F renderer run build",
"build:server": "zx scripts/build-server.mjs",
"pack": "pnpm -F media-downloader run pack",
"release": "pnpm run build && pnpm -F main run release",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"
"docs:preview": "vitepress preview docs",
"types": "pnpm -F main run types"
},
"keywords": [],
"author": "",
"license": "MIT",
"pnpm": {
"patchedDependencies": {
"@cliqz/adblocker@1.26.6": "patches/@cliqz__adblocker@1.26.6.patch"
"@cliqz/adblocker@1.26.15": "patches/@cliqz__adblocker@1.26.15.patch"
}
},
"devDependencies": {
"@waline/client": "^2.15.5",
"vitepress": "1.0.0-beta.3",
"vue": "^3.3.4"
"@waline/client": "3.0.0-alpha.10",
"cross-env": "^7.0.3",
"vitepress": "1.0.0-rc.39",
"vue": "^3.4.15",
"zx": "^7.2.3"
}
}
+2
View File
@@ -9,6 +9,7 @@ module.exports = {
"prettier",
],
overrides: [],
ignorePatterns: ["app"],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: "latest",
@@ -17,5 +18,6 @@ module.exports = {
plugins: ["@typescript-eslint", "prettier"],
rules: {
"prettier/prettier": "warn",
"@typescript-eslint/no-explicit-any": "warn",
},
};
+1
View File
@@ -4,3 +4,4 @@ build
dist
bin/Logs
release
types
View File
+1 -1
View File
@@ -1 +1 @@
{}
{}
+1
View File
@@ -2,3 +2,4 @@ node_modules
output
bin
mobile
plugin
+6 -4
View File
@@ -1,13 +1,15 @@
{
"name": "media-downloader",
"version": "2.0.2",
"version": "2.0.3-beta.2",
"description": "在线视频下载器",
"main": "main/index.js",
"scripts": {},
"author": "",
"license": "MIT",
"dependencies": {
"@cliqz/adblocker-electron": "^1.26.6",
"@cliqz/adblocker-electron-preload": "^1.26.6"
"@cliqz/adblocker-electron": "^1.26.15",
"@cliqz/adblocker-electron-preload": "^1.26.15"
},
"devDependencies": {
"electron": "^28.1.4"
}
}
+44 -52
View File
@@ -4,71 +4,63 @@
"description": "在线视频下载器",
"main": "main/index.js",
"scripts": {
"rebuild": "electron-rebuild -f -w better-sqlite3",
"dev": "cross-env NODE_ENV=development node script/dev.mjs",
"build": "cross-env NODE_ENV=production node script/build.mjs",
"pack": "cross-env NODE_ENV=development node script/release.mjs",
"release": "cross-env NODE_ENV=production node script/release.mjs"
"postinstall": "electron-rebuild -f -w better-sqlite3",
"dev": "cross-env NODE_ENV=development VITE_CJS_IGNORE_WARNING=true ts-node scripts/dev.ts",
"build": "cross-env NODE_ENV=production ts-node scripts/build.ts",
"pack": "cross-env NODE_ENV=development ts-node scripts/release.ts",
"types": "tsc --emitDeclarationOnly",
"release": "cross-env NODE_ENV=production ts-node scripts/release.ts"
},
"author": "",
"license": "MIT",
"devDependencies": {
"@electron/rebuild": "^3.2.13",
"@rushstack/eslint-patch": "^1.3.2",
"@types/fs-extra": "^11.0.1",
"@types/koa": "^2.13.6",
"@types/koa-range": "^0.3.2",
"@types/koa-send": "^4.1.3",
"@types/koa__cors": "^4.0.0",
"@types/koa__router": "^12.0.0",
"@types/lodash": "^4.14.195",
"@types/node": "^20.3.1",
"@typescript-eslint/eslint-plugin": "5.60.0",
"@typescript-eslint/parser": "5.60.0",
"@electron/rebuild": "^3.5.0",
"@rushstack/eslint-patch": "^1.7.0",
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.14.202",
"@types/node": "^20.11.5",
"@typescript-eslint/eslint-plugin": "6.19.0",
"@typescript-eslint/parser": "6.19.0",
"chokidar": "^3.5.3",
"cross-env": "^7.0.3",
"dotenv": "^16.3.1",
"electron": "^25.2.0",
"electron-builder": "^24.4.0",
"electron-updater": "^6.1.1",
"esbuild": "^0.18.6",
"eslint": "^8.43.0",
"eslint-config-prettier": "^8.8.0",
"eslint-config-standard-with-typescript": "^35.0.0",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-n": "15.0.0",
"eslint-plugin-prettier": "^4.2.1",
"dotenv": "^16.3.2",
"electron": "^28.1.4",
"electron-builder": "^24.9.1",
"electron-updater": "^6.1.7",
"esbuild": "^0.19.11",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-standard-with-typescript": "^43.0.1",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-n": "16.6.2",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-promise": "6.1.1",
"prebuild-install": "^7.1.1",
"prettier": "2.8.8",
"semver": "^7.5.2",
"typescript": "^5.1.3"
"prettier": "3.2.4",
"semver": "^7.5.4",
"typescript": "^5.3.3",
"vite": "^5.0.12"
},
"dependencies": {
"@cliqz/adblocker-electron": "^1.26.6",
"@cliqz/adblocker-electron-preload": "^1.26.6",
"@koa/cors": "^4.0.0",
"@koa/router": "^12.0.0",
"@types/koa-static": "^4.0.2",
"axios": "^1.4.0",
"better-sqlite3": "^8.4.0",
"cross-fetch": "^3.1.6",
"dayjs": "^1.11.8",
"@cliqz/adblocker-electron": "^1.26.15",
"@cliqz/adblocker-electron-preload": "^1.26.15",
"@types/semver": "^7.5.6",
"axios": "^1.6.5",
"better-sqlite3": "^9.3.0",
"cheerio": "1.0.0-rc.12",
"cross-fetch": "^4.0.0",
"dayjs": "^1.11.10",
"electron-devtools-installer": "^3.2.0",
"electron-is-dev": "^2.0.0",
"electron-log": "^4.4.8",
"electron-is-dev": "^3.0.1",
"electron-log": "^5.0.3",
"electron-store": "^8.1.0",
"execa": "^7.1.1",
"fs-extra": "^11.1.1",
"glob": "^10.3.0",
"execa": "^8.0.1",
"fs-extra": "^11.2.0",
"iconv-lite": "^0.6.3",
"inversify": "^6.0.1",
"koa": "^2.14.2",
"koa-range": "^0.3.0",
"koa-send": "^5.0.1",
"koa-static": "^5.0.0",
"inversify": "^6.0.2",
"lodash": "^4.17.21",
"reflect-metadata": "^0.1.13",
"typeorm": "^0.3.17"
"reflect-metadata": "^0.2.1",
"ts-node": "^10.9.2",
"typeorm": "^0.3.19"
}
}
+18
View File
@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+30
View File
@@ -0,0 +1,30 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
+30
View File
@@ -0,0 +1,30 @@
{
"name": "plugin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"antd": "^5.13.2",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/node": "^20.11.5",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"@vitejs/plugin-react": "^4.2.1",
"electron": "^28.1.4",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.3.3",
"vite": "^5.0.12"
}
}
+19
View File
@@ -0,0 +1,19 @@
#MediagoRoot {
position: absolute;
z-index: 99999;
.app {
z-index: 99999;
.app-float-button {
left: 30px;
bottom: 30px;
}
.app-logo {
display: block;
box-sizing: border-box;
height: 100%;
width: 100%;
padding: 3px;
transform: scale(3);
}
}
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useState } from "react";
import { FloatButton, Button, Dropdown, Space } from "antd";
import "./App.scss";
import logo from "./assets/logo.png";
import { WebSource } from "./types";
import { ipcRenderer } from "electron/renderer";
function App() {
const [items, setItems] = useState<WebSource[]>([]);
const [count, setCount] = useState(0);
const receiveMessage = (_: unknown, data: WebSource) => {
setItems((item) => [...item, data]);
setCount((c) => c + 1);
};
useEffect(() => {
ipcRenderer.on("webview-link-message", receiveMessage);
return () => {
ipcRenderer.removeListener("webview-link-message", receiveMessage);
};
}, []);
if (items.length === 0) {
return null;
}
return (
<div className="app">
<Dropdown
placement="topLeft"
menu={{
items: items.map((item) => ({
key: item.name,
label: (
<Space
onClick={() => {
ipcRenderer.invoke("add-download-item", {
name: item.name,
url: item.url,
type: item.type,
});
}}
>
<div>{item.name}</div>
<Button type="link" style={{ padding: 0 }}>
</Button>
</Space>
),
})),
}}
trigger={["click"]}
>
<FloatButton
className="app-float-button"
icon={<img className="app-logo" src={logo} alt="" />}
shape="square"
badge={{
count,
offset: [10, 0],
}}
onClick={() => {
setCount(0);
}}
/>
</Dropdown>
</div>
);
}
export default App;
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

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

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