Compare commits
210 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8ac4182fc | |||
| 55b70d3e6d | |||
| a19765b0d3 | |||
| 5c7b65385a | |||
| b6b84269a3 | |||
| e44cb249d0 | |||
| 8bea071865 | |||
| a782652fca | |||
| 3a02803d3c | |||
| 263a08ab4c | |||
| ecdbd985ce | |||
| dd403564cc | |||
| 69d741b39a | |||
| 4c9c2e2d68 | |||
| 8660701340 | |||
| 341c5570af | |||
| ee12753818 | |||
| 055638278c | |||
| bf06ff6482 | |||
| 459a5bc5b7 | |||
| a93f18037c | |||
| 67b5218002 | |||
| d3c745dda4 | |||
| e694818f8f | |||
| 729d7645e1 | |||
| 795e21df9c | |||
| bdc15e6c50 | |||
| c3398ec572 | |||
| 6b38b348fc | |||
| d650529842 | |||
| 2094408f52 | |||
| 89cb8b3a3a | |||
| 7a2d546972 | |||
| 69e801ba2b | |||
| 14edc27ed1 | |||
| 6f7fa7f067 | |||
| a5a1c02e10 | |||
| ce08cbce4b | |||
| e03a5d38bb | |||
| 720db5a476 | |||
| e97783b08e | |||
| 12566f8668 | |||
| d686819417 | |||
| ea7b4a77af | |||
| 6427a06879 | |||
| f2f7a50e95 | |||
| fbc57ebfca | |||
| 26a5681dab | |||
| 4640ff51e8 | |||
| b3b58680e0 | |||
| d1b75df87d | |||
| 4b4e1b9f54 | |||
| 8ebb550270 | |||
| 6b93954246 | |||
| 550d7ebd4b | |||
| 3f331e5d3d | |||
| 020f921af8 | |||
| efa72ce12b | |||
| 38ead633a5 | |||
| 3eb1d0ae99 | |||
| 7bed4ed335 | |||
| b357d41f23 | |||
| 3834603b0a | |||
| f38ba108a3 | |||
| 7ceda3d73f | |||
| aee57b6f98 | |||
| f9d49bd55f | |||
| 3bb082473a | |||
| 31b6726fe5 | |||
| 348b41b0dd | |||
| d4bbfa0516 | |||
| b2af9b3d25 | |||
| ded93a4f0b | |||
| d43f57565b | |||
| 34cf661fb2 | |||
| 91cfb883b6 | |||
| 1cb7deb376 | |||
| 70933c63bb | |||
| 5723b9edd0 | |||
| 65f37a9c66 | |||
| 57516793ab | |||
| afecc5ac12 | |||
| 3331564155 | |||
| 19ce7b9906 | |||
| 40f39340f0 | |||
| f085bcacf7 | |||
| dbd4e73b6e | |||
| b35e6b3315 | |||
| 3c603a67dd | |||
| 8ff5abf5d8 | |||
| 0fdbd39393 | |||
| df93770128 | |||
| b61a935068 | |||
| 95e96b1057 | |||
| 3dd325a280 | |||
| bbe922df74 | |||
| e31eddfd2e | |||
| bbb9f5798b | |||
| b634cf056d | |||
| 2ede22b50a | |||
| a4efd83f7a | |||
| c3ebced763 | |||
| 6cb6621df1 | |||
| 4e5febba8b | |||
| 1fb787d4ea | |||
| f24ddaa55e | |||
| 66542a0003 | |||
| f8a8e1d3e6 | |||
| c1873b8c6d | |||
| 07dceb2704 | |||
| ceec042ffd | |||
| 2a47f255ee | |||
| 1f899e6c6d | |||
| f0686bb18c | |||
| b321dc06e9 | |||
| 5f7c6e846b | |||
| 2d71475a48 | |||
| cfdeb4a1a8 | |||
| 5d3abdefdc | |||
| abcff22f3e | |||
| 7d4ca4d758 | |||
| 9b44b2f31f | |||
| 55bd8da894 | |||
| e8c7abaa2f | |||
| 1702b9beac | |||
| de25052980 | |||
| 03700ed4bb | |||
| e0147a4a6e | |||
| 281fd07e23 | |||
| 1d7a0e62e5 | |||
| e4dcdb9422 | |||
| f035bb09b4 | |||
| 869c6497e9 | |||
| 151ed1ce0d | |||
| bab349da9b | |||
| ac8f9d2955 | |||
| 0f2670069f | |||
| 0af3d32337 | |||
| f5a22f8393 | |||
| 769a82e7ed | |||
| 3985af0042 | |||
| dca0e0a84b | |||
| 233296ba3e | |||
| 2bc4fe0592 | |||
| c22245e296 | |||
| 29de2fc81c | |||
| bd27aefe8a | |||
| 722cabba57 | |||
| 3468c8d5e6 | |||
| b3cc83f8cd | |||
| f6a1ca03ac | |||
| 516ac72c3e | |||
| c068bde0a8 | |||
| ca3ed53a59 | |||
| 04c05ee15e | |||
| 60d27be739 | |||
| 259fe5f447 | |||
| 8d9b7fc9dc | |||
| d5d282147d | |||
| 08f7f66154 | |||
| f702f847e6 | |||
| b9b87cf354 | |||
| a81b9a37ab | |||
| 64174ef374 | |||
| a8b3a9ee1a | |||
| 7be1a7907d | |||
| 05d207f34d | |||
| 26be46b038 | |||
| 47826f1f72 | |||
| f7428f811a | |||
| 3aa8577a38 | |||
| 8b7965dbcb | |||
| 006cb4b13c | |||
| ecb8d6b387 | |||
| b57b05fe1d | |||
| 1e9969857d | |||
| af89dd2694 | |||
| e418e05ab1 | |||
| a8d8a7aca7 | |||
| 4c7226cda1 | |||
| 91b5ee4423 | |||
| f8d7148b9e | |||
| 0f0687b5c9 | |||
| 1384cd4c6e | |||
| 4c5c78d0e4 | |||
| 551abb4496 | |||
| 8af8824c3e | |||
| c01c085ac1 | |||
| 8668acdd6d | |||
| 03567ed36d | |||
| dcfea2cfb3 | |||
| 3c4e6c8240 | |||
| 9e93426c38 | |||
| 5c698e2e9b | |||
| b174e14fbd | |||
| 19e84cd415 | |||
| 73a9b3fd72 | |||
| 64a938879d | |||
| 66b9a9677b | |||
| 39616244d9 | |||
| 990846515f | |||
| 94777aead3 | |||
| 5275d3bfd3 | |||
| 73ae0ad083 | |||
| 5ed6e84a14 | |||
| ed45cd8d73 | |||
| f393b99cb5 | |||
| 6638600ffa | |||
| 4d5cae3b0c | |||
| e846390d04 |
@@ -0,0 +1,15 @@
|
||||
# http://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
max_line_length = 80
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
max_line_length = 0
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,6 @@
|
||||
APP_NAME=media-downloader
|
||||
APP_ID=mediago.ziying.site
|
||||
APP_COPYRIGHT=caorushizi
|
||||
APP_VERSION=2.0.2
|
||||
|
||||
APP_SERVER_PORT=8433
|
||||
@@ -1 +1,5 @@
|
||||
NODE_ENV=development
|
||||
APP_TD_APPID=
|
||||
APP_DOWNLOAD_DEBUG=true
|
||||
|
||||
GH_TOKEN=
|
||||
LOAD_DEVTOOLS=
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
NODE_ENV=production
|
||||
APP_TD_APPID=
|
||||
APP_DOWNLOAD_DEBUG=
|
||||
|
||||
GH_TOKEN=
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
NODE_ENV=staging
|
||||
@@ -1,6 +0,0 @@
|
||||
.eslintrc.js
|
||||
esbuild.config.js
|
||||
babel.config.js
|
||||
webpack.config.js
|
||||
script/
|
||||
vite.config.ts
|
||||
@@ -1,19 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es6: true,
|
||||
node: true,
|
||||
},
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["@typescript-eslint", "react"],
|
||||
extends: [
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"react-app",
|
||||
"plugin:prettier/recommended",
|
||||
],
|
||||
rules: {
|
||||
"@typescript-eslint/ban-ts-comment": "warn",
|
||||
"prettier/prettier": "error",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -0,0 +1,54 @@
|
||||
# build.yml
|
||||
|
||||
# Workflow's name
|
||||
name: Build Downloader App.
|
||||
|
||||
# Workflow's trigger
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
# Workflow's jobs
|
||||
jobs:
|
||||
# job's id
|
||||
release:
|
||||
# job's name
|
||||
name: build and release electron app
|
||||
|
||||
# the type of machine to run the job on
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
# create a build matrix for jobs
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, macos-latest]
|
||||
|
||||
# create steps
|
||||
steps:
|
||||
# step1: check out repository
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# step2: install node env
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "16.x"
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: latest
|
||||
run_install: true
|
||||
|
||||
- name: Build & release app
|
||||
run: pnpm run release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
APP_TD_APPID: ${{ secrets.APP_TD_APPID }}
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: artifact
|
||||
path: packages/main/release/media-downloader-setup-*
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Deploy Downloader Docs.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["master"]
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
BUILD_PATH: "."
|
||||
BUCKET: downloader-docs
|
||||
ENDPOINT: oss-cn-beijing.aliyuncs.com
|
||||
ACCESS_KEY: LTAI5tLckcUrBtj7bCiUYwWz
|
||||
ACCESS_KEY_SECRET: ${{ secrets.ACCESS_KEY_SECRET }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "16"
|
||||
- name: Install PNPM
|
||||
run: npm i -g pnpm
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
working-directory: ${{ env.BUILD_PATH }}
|
||||
- name: Build with Vitepress
|
||||
run: pnpm run docs:build
|
||||
working-directory: ${{ env.BUILD_PATH }}
|
||||
- name: Install Alibaba Cloud OSSUTIL
|
||||
run: wget http://gosspublic.alicdn.com/ossutil/1.6.10/ossutil64 && chmod +x ossutil64
|
||||
- name: Configure Alibaba Cloud OSSUTIL
|
||||
run: ./ossutil64 config -i ${ACCESS_KEY} -k ${ACCESS_KEY_SECRET} -e ${ENDPOINT} -c .ossutilconfig
|
||||
- name: Upload the web folder to the chosen OSS bucket
|
||||
run: ./ossutil64 --config-file .ossutilconfig cp ${{ github.workspace }}/docs/.vitepress/dist oss://${BUCKET} -r -f
|
||||
@@ -1,13 +1,5 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
.vscode
|
||||
*.local
|
||||
.idea
|
||||
.bin
|
||||
.vscode
|
||||
|
||||
yarn.lock
|
||||
yarn-error.log
|
||||
build
|
||||
devtools/*
|
||||
.parcel-cache
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": [
|
||||
"development"
|
||||
],
|
||||
"hints": {
|
||||
"axe/text-alternatives": [
|
||||
"default",
|
||||
{
|
||||
"image-alt": "off"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
package-manager=pnpm
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 士子☀️
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,32 +1,64 @@
|
||||
# media-downloader
|
||||
m3u8 视频在线提取工具 流媒体下载 m3u8下载 桌面客户端 windows mac
|
||||
|
||||
# 功能简介
|
||||
m3u8 视频在线提取工具 流媒体下载 m3u8 下载 桌面客户端 windows mac。
|
||||
可以直接在线获取 m3u8 链接地址,无需使用使用网络抓包,无需安装浏览器插件,可以直接带出请求标头……详情请参考[使用帮助](https://downloader.caorushizi.cn/guides.html?form=github)
|
||||
|
||||
可以直接在线获取 m3u8 链接地址,无需使用使用网络抓包,无需安装浏览器插件,可以直接带出请求标头……
|
||||
## 上手指南
|
||||
|
||||
# 下载链接
|
||||
以下指南将帮助你在本地机器上安装和运行该项目,进行开发和测试。关于如何将该项目部署到在线环境,请参考部署小节。
|
||||
|
||||
- [v1.0.3 windows](http://static.ziying.site/media-downloader-1.0.3%20Setup.exe)
|
||||
- [v1.0.2 windows](http://static.ziying.site/media-downloader-1.0.2%20Setup.exe)
|
||||
- [v1.0.1 windows](http://static.ziying.site/media-downloader-1.0.1%20Setup.exe)
|
||||
- [v1.0.0 windows](http://static.ziying.site/media-downloader-1.0.0%20Setup.exe)
|
||||
## 安装要求
|
||||
|
||||
# 更新日志 2021.04.18
|
||||
1. 新增视频批量下载功能;
|
||||
2. 添加收藏功能;
|
||||
3. 更新了界面样式;
|
||||
运行代码需要 node 和 pnpm,node 需要在官网下载安装,pnpm 可以通过`npm i -g pnpm`安装。
|
||||
|
||||
# 软件截图
|
||||
## 运行代码
|
||||
|
||||

|
||||
安装依赖 `pnpm i`
|
||||
|
||||

|
||||
开发环境 `pnpm run dev`
|
||||
|
||||

|
||||
打包运行 `pnpm run release`
|
||||
|
||||

|
||||
## 下载链接
|
||||
|
||||
# 使用 Vite & esBuild 构建
|
||||
**v2.0.1(2023.7.1 发布)**
|
||||
|
||||

|
||||
- [windows media-downloader v2.0.1](https://github.com/caorushizi/m3u8-downloader/releases/download/v2.0.1/media-downloader-setup-2.0.1.exe)
|
||||
- [macos media-downloader v2.0.1](https://github.com/caorushizi/m3u8-downloader/releases/download/v2.0.1/media-downloader-setup-2.0.1.dmg)
|
||||
|
||||
**更新日志**
|
||||
|
||||
- 暗黑模式
|
||||
- 更多下载配置
|
||||
- 支持请求标头自动带入
|
||||
- 支持开启广告过滤
|
||||
- 支持开启沉浸式嗅探
|
||||
- 支持切换手机和 PC 模式
|
||||
- 支持修改同时下载数量
|
||||
- 修复了一些 bug
|
||||
|
||||
**v1.1.5(2022.2.5 发布)**
|
||||
|
||||
- [windows media-downloader v1.1.5](https://github.com/caorushizi/m3u8-downloader/releases/download/1.1.5/media-downloader-setup-1.1.4.exe)
|
||||
|
||||
**更新日志**
|
||||
|
||||
## 软件截图
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 技术栈
|
||||
|
||||
- vite <https://cn.vitejs.dev>
|
||||
- antd <https://ant.design>
|
||||
- electron <https://www.electronjs.org>
|
||||
|
||||
## 鸣谢
|
||||
|
||||
N_m3u8DL-CLI 来自于 <https://github.com/nilaoda/N_m3u8DL-CLI>
|
||||
|
||||
mediago 来自于 <https://github.com/caorushizi/mediago>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
.vitepress/dist
|
||||
.vitepress/cache
|
||||
@@ -0,0 +1,63 @@
|
||||
import { HeadConfig } from "vitepress";
|
||||
import { defineConfig } from "vitepress";
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
const head: HeadConfig[] = [
|
||||
["link", { rel: "shortcut icon", href: "/favicon.svg" }],
|
||||
];
|
||||
if (!isDev) {
|
||||
head.push([
|
||||
"script",
|
||||
{},
|
||||
`var _hmt = _hmt || [];
|
||||
(function() {
|
||||
var hm = document.createElement("script");
|
||||
hm.src = "https://hm.baidu.com/hm.js?eefcbd14f0323044aa0ca678cd278381";
|
||||
var s = document.getElementsByTagName("script")[0];
|
||||
s.parentNode.insertBefore(hm, s);
|
||||
})();`,
|
||||
]);
|
||||
}
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
title: "media-downloader",
|
||||
description: "简单易用,快速下载",
|
||||
lastUpdated: true,
|
||||
head,
|
||||
themeConfig: {
|
||||
// https://vitepress.dev/reference/default-theme-config
|
||||
nav: [
|
||||
{ text: "Home", link: "/" },
|
||||
{ text: "教程", link: "/guides" },
|
||||
],
|
||||
|
||||
sidebar: [
|
||||
{
|
||||
text: "开始",
|
||||
items: [
|
||||
{ text: "快速开始", link: "/guides" },
|
||||
{ text: "使用说明", link: "/documents" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "其他",
|
||||
items: [
|
||||
{ text: "旧版本", link: "/history" },
|
||||
{ text: "意见收集", link: "/proposal" },
|
||||
{ text: "支持列表", link: "/list" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
socialLinks: [
|
||||
{ icon: "github", link: "https://github.com/caorushizi/m3u8-downloader" },
|
||||
],
|
||||
|
||||
footer: {
|
||||
message: '<a href="https://beian.miit.gov.cn">豫ICP备20012967号-2</a>',
|
||||
copyright: "Copyright © 2019-present caorushizi",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, watch, ref, watchEffect } from "vue";
|
||||
import { init, WalineInstance } from "@waline/client";
|
||||
import "@waline/client/waline.css";
|
||||
import { useData, useRoute } from "vitepress";
|
||||
|
||||
const route = useRoute();
|
||||
const { isDark } = useData();
|
||||
|
||||
const commentsRef = ref<WalineInstance | null>(null);
|
||||
|
||||
watchEffect(() => {
|
||||
commentsRef.value?.update({
|
||||
dark: isDark.value,
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
commentsRef.value?.destroy();
|
||||
initWaline();
|
||||
}
|
||||
);
|
||||
|
||||
function initWaline() {
|
||||
commentsRef.value = init({
|
||||
el: "#waline",
|
||||
serverURL: "https://comments.ziying.site",
|
||||
dark: isDark.value,
|
||||
lang: "zh-CN",
|
||||
reaction: true,
|
||||
pageview: true,
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initWaline();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="waline">hello world</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#waline {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { h } from "vue";
|
||||
import Theme from "vitepress/theme";
|
||||
import "./style/var.css";
|
||||
import Comments from "./components/Comments.vue";
|
||||
|
||||
export default {
|
||||
...Theme,
|
||||
Layout() {
|
||||
return h(Theme.Layout, null, {
|
||||
"doc-after": () => h(Comments),
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
:root {
|
||||
/* 标题 */
|
||||
--vp-home-hero-name-color: transparent;
|
||||
--vp-home-hero-name-background: linear-gradient(
|
||||
135deg,
|
||||
#5e9ef3 10%,
|
||||
#2a82f6 100%
|
||||
);
|
||||
|
||||
/* 图标背景 */
|
||||
--vp-home-hero-image-background-image: linear-gradient(
|
||||
135deg,
|
||||
#5e9ef3 10%,
|
||||
#2a82f6 100%
|
||||
);
|
||||
--vp-home-hero-image-filter: blur(150px);
|
||||
|
||||
/* brand按钮 */
|
||||
--vp-button-brand-border: #5e9ef3;
|
||||
--vp-button-brand-text: #f2f7fd;
|
||||
--vp-button-brand-bg: #2a82f6;
|
||||
|
||||
--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;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
layout: doc
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# 使用说明
|
||||
|
||||
这个页面主要讲解的是设置页面的参数的含义
|
||||
|
||||

|
||||
|
||||
## 基础设置
|
||||
|
||||
::: tip
|
||||
下载器全局设置
|
||||
:::
|
||||
|
||||
1. 选择文件夹: 视频下载的路径
|
||||
|
||||
2. 下载器主题: 支持浅色和深色模式
|
||||
|
||||
3. 在新窗口中打开浏览器: 开启了之后浏览器页面会独立出一个窗口。
|
||||
|
||||

|
||||
|
||||
4. 下载完成提示: 开启之后下载完成会出现系统提示
|
||||
|
||||
## 浏览器设置
|
||||
|
||||
::: tip
|
||||
浏览器窗口相关的设置
|
||||
:::
|
||||
|
||||
1. 代理设置: 填写自己的代理地址
|
||||
|
||||
2. 代理开关: 打开开关后浏览器默认会走填写的代理地址,如果代理地址这个开关是打不开的
|
||||
|
||||
3. 开启广告过滤: 打开开关后会过滤页面中的广告
|
||||
|
||||
4. 以手机模式进入: 打开后会将浏览器伪装成手机浏览器,请求移动端的站点(左侧是开启手机模式的截图)
|
||||
|
||||

|
||||
|
||||
5. 使用浏览器插件
|
||||
|
||||
- 打开状态: 从浏览器中嗅探到的资源不会自动添加到下载列表,需要手动点击页面下方的下载的链接来添加视频到下载列表 **(后面会有很强大的功能哦)**
|
||||
|
||||

|
||||
|
||||
- 关闭状态: 浏览器中嗅探到的资源会自动添加到下载列表
|
||||
|
||||
## 下载设置
|
||||
|
||||
::: tip
|
||||
和下载相关的设置
|
||||
:::
|
||||
|
||||
1. 下载完成删除分片: 开启后下载完成后会将分片文件删除
|
||||
2. 最大同时下载数量: 最多同时可以下载多少视频文件,目前最大是 10,最小是 1
|
||||
3. 更多操作
|
||||
- 配置文件目录: 软件数据库路径、日志等等路径
|
||||
- 可执行文件目录: 下载器二进制文件的路径
|
||||
- 本地存储路径: 下载视频的本地路径
|
||||
4. 当前版本: 当前软件版本信息
|
||||
|
||||
## 其他问题
|
||||
|
||||
**Q: 关于直播视频的下载**
|
||||
|
||||
A: 软件支持直播下载,软件目前没有好的办法区分直播流,所以将所有的下载控制台开启。用户自行分辨下载数据源
|
||||
|
||||
**Q: 关于 MacOS 版本**
|
||||
|
||||
A: 目前 MacOS 版本属于非稳定版本,2023 年 5 月发布。需要在 Mac 的安全设置中打开来自身份不明开发者的 Mac App
|
||||

|
||||
|
||||
**Q: 关于旧版本**
|
||||
|
||||
A: 1.1.5 版本确实发布了很长时间,经过大量用户的验证,比较稳定。所以想要使用旧版软件的话请访问[此链接](/history.html)
|
||||
|
||||
**Q: 关于 Win7 用户**
|
||||
|
||||
A: v2.0.0 之后的版本已经放弃不支持 Win7 如果需要在 win7 上面使用同样需要下载 1.1.5 版本
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
layout: doc
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# 快速开始
|
||||
|
||||
这篇文章是软件的简单说明,可以快速上手使用本本软件。
|
||||
|
||||
::: tip
|
||||
我建了一个 QQ 群,方便大家一起沟通交流: 574209001
|
||||
:::
|
||||
|
||||
## 下载安装
|
||||
|
||||
**v2.0.1(2023.7.1 发布)**
|
||||
|
||||
- [windows media-downloader v2.0.1](https://github.com/caorushizi/m3u8-downloader/releases/download/v2.0.1/media-downloader-setup-2.0.1.exe)
|
||||
- [macos media-downloader v2.0.1](https://github.com/caorushizi/m3u8-downloader/releases/download/v2.0.1/media-downloader-setup-2.0.1.dmg)
|
||||
|
||||
**更新日志**
|
||||
|
||||
- 暗黑模式
|
||||
- 更多下载配置
|
||||
- 支持请求标头自动带入
|
||||
- 支持开启广告过滤
|
||||
- 支持开启沉浸式嗅探
|
||||
- 支持切换手机和 PC 模式
|
||||
- 支持修改同时下载数量
|
||||
- 修复了一些 bug
|
||||
|
||||
**v1.1.5(2022.2.5 发布)**
|
||||
|
||||
- [windows media-downloader v1.1.5](https://github.com/caorushizi/m3u8-downloader/releases/download/1.1.5/media-downloader-setup-1.1.4.exe)
|
||||
|
||||
**更新日志**
|
||||
|
||||
- 支持视频下载
|
||||
|
||||
## 操作说明
|
||||
|
||||
### 视频自动嗅探
|
||||
|
||||
1. 选择【资源提取】
|
||||
|
||||

|
||||
|
||||
2. 进入视频的网址
|
||||
|
||||

|
||||
|
||||
3. 开始下载,即可下载视频
|
||||
|
||||

|
||||
|
||||
### 手动下载
|
||||
|
||||
1. 点击页面右上角的【新建下载】
|
||||
|
||||

|
||||
|
||||
2. 在新建下载弹窗中填入【视频名称】和【m3u8 地址】即可
|
||||
|
||||

|
||||
|
||||
3. 在列表中点击下载,即可下载视频
|
||||
|
||||

|
||||
|
||||
## 附加功能
|
||||
|
||||
1. 下载完成后可以在下载列表中点击转换音频按钮将视频转换为音频
|
||||
|
||||

|
||||
|
||||
2. 下载完成后播放视频
|
||||
|
||||
- PC 播放
|
||||
|
||||

|
||||
|
||||
- 移动端播放
|
||||
|
||||

|
||||
|
||||
3. 批量下载
|
||||
|
||||

|
||||
|
||||
## 开始你的视频下载吧
|
||||
|
||||
是不是很简单,快去下载你的视频吧
|
||||
|
||||
::: warning
|
||||
本软件仅可用于学习交流
|
||||
:::
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
layout: doc
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# 旧版本
|
||||
|
||||
::: info
|
||||
这里是旧版本的永久链接,如果有需求的话,可以在这里下载
|
||||
|
||||
**这个链接的软件不会自动升级, 可放心使用**
|
||||
:::
|
||||
|
||||
**v1.1.5(2022.2.5 发布)**
|
||||
|
||||
- [windows media-downloader v1.1.5](https://github.com/caorushizi/m3u8-downloader/releases/download/1.1.5/media-downloader-setup-1.1.4.exe)
|
||||
- ~~macos media-downloader v1.1.5 无~~
|
||||
|
||||
**更新日志**
|
||||
|
||||
- 支持视频下载
|
||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,30 @@
|
||||
---
|
||||
# https://vitepress.dev/reference/default-theme-home-page
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: "在线视频下载"
|
||||
text: "简单易用,快速下载"
|
||||
tagline: 简单易学,无需抓包,不需要安装插件
|
||||
image:
|
||||
src: /home.png
|
||||
alt: home
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 快速开始
|
||||
link: /guides
|
||||
- theme: alt
|
||||
text: 使用说明
|
||||
link: /documents
|
||||
|
||||
features:
|
||||
- icon: ⏩
|
||||
title: 无需抓包
|
||||
details: 使用软件自带浏览器可以轻松嗅探网页中的视频资源,通过嗅探到的资源列表选择自己想要下载的资源,简单快速。
|
||||
- icon: 📱
|
||||
title: 移动播放
|
||||
details: 可以轻松无缝的在PC和移动设备之前切换,下载完成后即可使用手机观看视频。
|
||||
- icon: ⚡️
|
||||
title: 支持批量下载
|
||||
details: 支持同时最多下载10个视频和直播资源,高速带宽不闲置。
|
||||
---
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
layout: doc
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# 支持列表
|
||||
|
||||
::: tip
|
||||
大家有什么网站想要支持的在这里提
|
||||
:::
|
||||
|
||||
**模板**
|
||||
|
||||
---
|
||||
|
||||
- 网站首页: xxx
|
||||
- 视频(直播)列表页: xxx
|
||||
- 视频(直播)页: xxx
|
||||
|
||||
---
|
||||
|
||||
支持完成后我会在评论区回复
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
layout: doc
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# 意见收集
|
||||
|
||||
::: info
|
||||
这里手机大家使用软件的问题,或者意见建议
|
||||
:::
|
||||
|
||||
如果软件报错,需要描述清楚在什么网站下载什么视频的时候报错
|
||||
|
||||
如果是建议请描述清楚你的需求
|
||||
|
||||
如果是意见请尽量提出来,我会尽量满足大家的需求
|
||||
@@ -0,0 +1,40 @@
|
||||
<svg t="1686326276609" class="icon" viewBox="0 0 1024 1024" version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg" p-id="6962" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="200" height="200">
|
||||
<path
|
||||
d="M110.933333 42.666667h716.8A68.266667 68.266667 0 0 1 896 110.933333v802.133334a68.266667 68.266667 0 0 1-68.266667 68.266666H110.933333A68.266667 68.266667 0 0 1 42.666667 913.066667V110.933333A68.266667 68.266667 0 0 1 110.933333 42.666667z"
|
||||
fill="#111314" fill-opacity=".72" p-id="6963"></path>
|
||||
<path
|
||||
d="M853.333333 42.666667h85.333334a68.266667 68.266667 0 0 1 68.266666 68.266666v802.133334A68.266667 68.266667 0 0 1 938.666667 981.333333h-85.333334a68.266667 68.266667 0 0 0 68.266667-68.266666V110.933333A68.266667 68.266667 0 0 0 853.333333 42.666667z"
|
||||
fill="#CACBCC" p-id="6964"></path>
|
||||
<path
|
||||
d="M162.133333 128h614.4a34.133333 34.133333 0 0 1 34.133334 34.133333v699.733334a34.133333 34.133333 0 0 1-34.133334 34.133333H162.133333a34.133333 34.133333 0 0 1-34.133333-34.133333V162.133333a34.133333 34.133333 0 0 1 34.133333-34.133333z"
|
||||
fill="#FFFFFF" p-id="6965"></path>
|
||||
<path
|
||||
d="M187.733333 170.666667h563.2c9.386667 0 17.066667 7.68 17.066667 17.066666v221.866667a17.066667 17.066667 0 0 1-17.066667 17.066667H187.733333a17.066667 17.066667 0 0 1-17.066666-17.066667V187.733333c0-9.386667 7.68-17.066667 17.066666-17.066666z"
|
||||
fill="#EDEEF0" p-id="6966"></path>
|
||||
<path
|
||||
d="M256 298.666667m17.066667 0l136.533333 0q17.066667 0 17.066667 17.066666l0 136.533334q0 17.066667-17.066667 17.066666l-136.533333 0q-17.066667 0-17.066667-17.066666l0-136.533334q0-17.066667 17.066667-17.066666Z"
|
||||
fill="#FFC13C" p-id="6967"></path>
|
||||
<path
|
||||
d="M341.333333 392.533333a34.133333 34.133333 0 1 1 0-68.266666 34.133333 34.133333 0 0 1 0 68.266666z m65.109334 56.192H276.224C276.224 426.666667 276.224 401.066667 341.333333 401.066667s65.109333 25.6 65.109334 47.658666z"
|
||||
fill="#FFFFFF" p-id="6968"></path>
|
||||
<path
|
||||
d="M469.333333 341.333333m17.066667 0l136.533333 0q17.066667 0 17.066667 17.066667l0 8.533333q0 17.066667-17.066667 17.066667l-136.533333 0q-17.066667 0-17.066667-17.066667l0-8.533333q0-17.066667 17.066667-17.066667Z"
|
||||
fill="#5BADFF" p-id="6969"></path>
|
||||
<path
|
||||
d="M512 554.666667m17.066667 0l179.2 0q17.066667 0 17.066666 17.066666l0 51.2q0 17.066667-17.066666 17.066667l-179.2 0q-17.066667 0-17.066667-17.066667l0-51.2q0-17.066667 17.066667-17.066666Z"
|
||||
fill="#EDEEF0" p-id="6970"></path>
|
||||
<path
|
||||
d="M512 682.666667m17.066667 0l179.2 0q17.066667 0 17.066666 17.066666l0 51.2q0 17.066667-17.066666 17.066667l-179.2 0q-17.066667 0-17.066667-17.066667l0-51.2q0-17.066667 17.066667-17.066666Z"
|
||||
fill="#EDEEF0" p-id="6971"></path>
|
||||
<path
|
||||
d="M213.333333 554.666667m17.066667 0l221.866667 0q17.066667 0 17.066666 17.066666l0 8.533334q0 17.066667-17.066666 17.066666l-221.866667 0q-17.066667 0-17.066667-17.066666l0-8.533334q0-17.066667 17.066667-17.066666Z"
|
||||
fill="#EDEEF0" p-id="6972"></path>
|
||||
<path
|
||||
d="M213.333333 640m17.066667 0l221.866667 0q17.066667 0 17.066666 17.066667l0 8.533333q0 17.066667-17.066666 17.066667l-221.866667 0q-17.066667 0-17.066667-17.066667l0-8.533333q0-17.066667 17.066667-17.066667Z"
|
||||
fill="#EDEEF0" p-id="6973"></path>
|
||||
<path
|
||||
d="M213.333333 725.333333m17.066667 0l221.866667 0q17.066667 0 17.066666 17.066667l0 8.533333q0 17.066667-17.066666 17.066667l-221.866667 0q-17.066667 0-17.066667-17.066667l0-8.533333q0-17.066667 17.066667-17.066667Z"
|
||||
fill="#EDEEF0" p-id="6974"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 120 KiB |
@@ -1,14 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="referrer" content="never">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>在线视频下载</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/renderer/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,144 +1,31 @@
|
||||
{
|
||||
"name": "media-downloader",
|
||||
"version": "1.1.2",
|
||||
"description": "在线视频下载",
|
||||
"author": "caorushizi",
|
||||
"main": "dist/main/index.js",
|
||||
"license": "MIT",
|
||||
"homepage": "./",
|
||||
"name": "electron-template",
|
||||
"version": "0.1.0",
|
||||
"description": "electron-template",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dist": "cross-env NODE_ENV=production node script/build.js",
|
||||
"watch": "cross-env NODE_ENV=development node script/watch.js",
|
||||
"electron": "cross-env NODE_ENV=production electron .",
|
||||
"pack": "electron-builder --dir",
|
||||
"build": "electron-builder",
|
||||
"publish": "npm run dist && npm run build",
|
||||
"build-debug": "npm run dist && npm run pack && ./build/win-unpacked/在线视频下载.exe"
|
||||
"dev": "pnpm run build:mobile && pnpm --parallel -F \"./packages/*\" run dev",
|
||||
"build": "pnpm run build:mobile && pnpm run build:main && pnpm run build:renderer",
|
||||
"build:mobile": "pnpm -F mobile run build",
|
||||
"build:main": "pnpm -F main run build",
|
||||
"build:renderer": "pnpm -F renderer run build",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^4.6.2",
|
||||
"@ant-design/pro-form": "^1.49.6",
|
||||
"@ant-design/pro-table": "^2.59.2",
|
||||
"@chakra-ui/react": "^1.7.2",
|
||||
"@emotion/react": "^11",
|
||||
"@emotion/styled": "^11",
|
||||
"@sentry/electron": "^2.5.4",
|
||||
"@sentry/react": "^6.16.1",
|
||||
"@sentry/tracing": "^6.16.1",
|
||||
"antd": "^4.17.2",
|
||||
"classnames": "^2.3.1",
|
||||
"connected-react-router": "^6.9.1",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-log": "^4.3.2",
|
||||
"electron-squirrel-startup": "^1.0.0",
|
||||
"electron-store": "^7.0.2",
|
||||
"framer-motion": "^4",
|
||||
"glob": "^7.1.6",
|
||||
"localforage": "^1.9.0",
|
||||
"m3u8-parser": "^4.7.0",
|
||||
"moment": "^2.29.1",
|
||||
"nanoid": "^3.1.30",
|
||||
"prop-types": "^15.7.2",
|
||||
"qs": "^6.10.1",
|
||||
"re-resizable": "^6.9.1",
|
||||
"react": "^17.0.0",
|
||||
"react-dom": "^17.0.0",
|
||||
"react-file-drop": "^3.1.2",
|
||||
"react-redux": "^7.2.4",
|
||||
"react-router": "^5.2.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.6",
|
||||
"react-window": "^1.8.6",
|
||||
"redux": "^4.1.1",
|
||||
"redux-devtools-extension": "^2.13.9",
|
||||
"redux-saga": "^1.1.3",
|
||||
"semver": "^7.3.4"
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@cliqz/adblocker@1.26.6": "patches/@cliqz__adblocker@1.26.6.patch"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-eslint": "^8.0.1",
|
||||
"@types/glob": "^7.1.3",
|
||||
"@types/node": "^14.14.35",
|
||||
"@types/qs": "^6.9.7",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"@types/react-router": "^5.1.16",
|
||||
"@types/react-router-dom": "^5.1.8",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.1",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"@types/semver": "^7.3.4",
|
||||
"@typescript-eslint/eslint-plugin": "^4.0.0",
|
||||
"@typescript-eslint/parser": "^4.0.0",
|
||||
"@vitejs/plugin-react-refresh": "^1.3.1",
|
||||
"babel-eslint": "^10.0.0",
|
||||
"chalk": "^4.1.2",
|
||||
"concurrently": "^6.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "^8.2.0",
|
||||
"electron": "16.0.4",
|
||||
"electron-builder": "^22.14.2",
|
||||
"electron-updater": "^4.6.1",
|
||||
"esbuild": "^0.9.5",
|
||||
"eslint": "^7.5.0",
|
||||
"eslint-config-prettier": "^8.1.0",
|
||||
"eslint-config-react-app": "^6.0.0",
|
||||
"eslint-plugin-flowtype": "^5.2.0",
|
||||
"eslint-plugin-import": "^2.22.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.3.1",
|
||||
"eslint-plugin-prettier": "^3.3.1",
|
||||
"eslint-plugin-react": "^7.20.3",
|
||||
"eslint-plugin-react-hooks": "^4.0.8",
|
||||
"less": "^4.1.1",
|
||||
"prettier": "2.2.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"sass": "^1.32.8",
|
||||
"typescript": "^4.2.4",
|
||||
"vite": "^2.5.0"
|
||||
},
|
||||
"build": {
|
||||
"productName": "在线视频下载",
|
||||
"appId": "mediago.ziying.site",
|
||||
"copyright": "caorushizi",
|
||||
"artifactName": "media-downloader-setup-${version}.${ext}",
|
||||
"directories": {
|
||||
"output": "build"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
".bin/**"
|
||||
],
|
||||
"win": {
|
||||
"icon": "assets/icon.ico",
|
||||
"requestedExecutionLevel": "requireAdministrator",
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dmg": {
|
||||
"contents": []
|
||||
},
|
||||
"mac": {
|
||||
"icon": "build/icons/icon.icns"
|
||||
},
|
||||
"linux": {
|
||||
"icon": "build/icons"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowElevation": true,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"installerIcon": "",
|
||||
"uninstallerIcon": "",
|
||||
"installerHeaderIcon": "",
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "",
|
||||
"include": "",
|
||||
"script": ""
|
||||
}
|
||||
"@waline/client": "^2.15.5",
|
||||
"vitepress": "1.0.0-beta.3",
|
||||
"vue": "^3.3.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
node: true,
|
||||
es2021: true,
|
||||
},
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier",
|
||||
],
|
||||
overrides: [],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
plugins: ["@typescript-eslint", "prettier"],
|
||||
rules: {
|
||||
"prettier/prettier": "warn",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
.bin
|
||||
node_modules
|
||||
build
|
||||
dist
|
||||
bin/Logs
|
||||
release
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
output
|
||||
bin
|
||||
mobile
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "media-downloader",
|
||||
"version": "2.0.2",
|
||||
"description": "在线视频下载器",
|
||||
"main": "main/index.js",
|
||||
"scripts": {},
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cliqz/adblocker-electron": "^1.26.6",
|
||||
"@cliqz/adblocker-electron-preload": "^1.26.6"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "main",
|
||||
"version": "0.1.0",
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
"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",
|
||||
"eslint-plugin-promise": "6.1.1",
|
||||
"prebuild-install": "^7.1.1",
|
||||
"prettier": "2.8.8",
|
||||
"semver": "^7.5.2",
|
||||
"typescript": "^5.1.3"
|
||||
},
|
||||
"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",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-is-dev": "^2.0.0",
|
||||
"electron-log": "^4.4.8",
|
||||
"electron-store": "^8.1.0",
|
||||
"execa": "^7.1.1",
|
||||
"fs-extra": "^11.1.1",
|
||||
"glob": "^10.3.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",
|
||||
"lodash": "^4.17.21",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"typeorm": "^0.3.17"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as esbuild from "esbuild";
|
||||
import {
|
||||
mainResolve,
|
||||
loadDotEnvDefined,
|
||||
copyResource,
|
||||
removeResource,
|
||||
} from "./utils.mjs";
|
||||
|
||||
const mainDefined = loadDotEnvDefined();
|
||||
|
||||
removeResource([mainResolve("app/build")]);
|
||||
|
||||
const path = "build/Release/better_sqlite3.node";
|
||||
|
||||
copyResource([
|
||||
{
|
||||
from: mainResolve("node_modules/better-sqlite3", path),
|
||||
to: mainResolve("app", path),
|
||||
},
|
||||
]);
|
||||
|
||||
esbuild.build({
|
||||
entryPoints: [
|
||||
mainResolve("src/index.ts"),
|
||||
mainResolve("src/preload.ts"),
|
||||
mainResolve("src/webview.ts"),
|
||||
],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
sourcemap: false,
|
||||
target: ["node16.13"],
|
||||
external: [
|
||||
"electron",
|
||||
"nock",
|
||||
"aws-sdk",
|
||||
"mock-aws-s3",
|
||||
"@cliqz/adblocker-electron-preload",
|
||||
],
|
||||
define: {
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
...mainDefined,
|
||||
},
|
||||
outdir: mainResolve("app/build/main"),
|
||||
loader: { ".png": "file" },
|
||||
minify: true,
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import builder from "electron-builder";
|
||||
import { readFileSync } from "node:fs";
|
||||
import semver from "semver";
|
||||
import {
|
||||
mainResolve,
|
||||
loadDotEnvRuntime,
|
||||
log,
|
||||
removeResource,
|
||||
} from "./utils.mjs";
|
||||
|
||||
removeResource([mainResolve("release")]);
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(mainResolve("./app/package.json")));
|
||||
loadDotEnvRuntime();
|
||||
|
||||
if (semver.neq(process.env.APP_VERSION, packageJson.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 = {
|
||||
productName: process.env.APP_NAME,
|
||||
buildVersion: process.env.APP_VERSION,
|
||||
appId: process.env.APP_ID,
|
||||
copyright: process.env.APP_COPYRIGHT,
|
||||
artifactName: "${productName}-setup-${buildVersion}.${ext}",
|
||||
directories: {
|
||||
output: "./release",
|
||||
},
|
||||
files: [
|
||||
{
|
||||
from: "./build",
|
||||
to: "./",
|
||||
},
|
||||
"./package.json",
|
||||
],
|
||||
extraResources: [
|
||||
{
|
||||
from: "./app/mobile",
|
||||
to: "mobile",
|
||||
},
|
||||
{
|
||||
from: "./app/bin/${platform}/",
|
||||
to: "bin",
|
||||
},
|
||||
],
|
||||
win: {
|
||||
icon: "../assets/icon.ico",
|
||||
target: [
|
||||
{
|
||||
target: "nsis",
|
||||
},
|
||||
],
|
||||
},
|
||||
dmg: {
|
||||
contents: [
|
||||
{
|
||||
x: 410,
|
||||
y: 150,
|
||||
type: "link",
|
||||
path: "/Applications",
|
||||
},
|
||||
{
|
||||
x: 130,
|
||||
y: 150,
|
||||
type: "file",
|
||||
},
|
||||
],
|
||||
},
|
||||
mac: {
|
||||
icon: "../assets/icon.icns",
|
||||
target: {
|
||||
target: "default",
|
||||
arch: ["x64", "arm64"],
|
||||
},
|
||||
},
|
||||
linux: {},
|
||||
nsis: {
|
||||
oneClick: true,
|
||||
allowElevation: true,
|
||||
allowToChangeInstallationDirectory: false,
|
||||
installerIcon: "",
|
||||
uninstallerIcon: "",
|
||||
installerHeaderIcon: "",
|
||||
createDesktopShortcut: true,
|
||||
createStartMenuShortcut: true,
|
||||
shortcutName: "",
|
||||
include: "",
|
||||
script: "",
|
||||
},
|
||||
publish: {
|
||||
provider: "github",
|
||||
repo: "m3u8-downloader",
|
||||
owner: "caorushizi",
|
||||
releaseType: "prerelease",
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { existsSync, cpSync, rmSync } from "node:fs";
|
||||
import dotenv from "dotenv";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// 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);
|
||||
const nodeEnv = process.env.NODE_ENV;
|
||||
log("当前的环境是: ", nodeEnv);
|
||||
|
||||
function loadEnv(path) {
|
||||
const result = {};
|
||||
|
||||
const _loadEnv = (path) => {
|
||||
if (!existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { error, parsed } = dotenv.config({ path });
|
||||
if (error != null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object.keys(parsed).forEach((key) => {
|
||||
result[key] = parsed[key];
|
||||
});
|
||||
};
|
||||
|
||||
_loadEnv(path);
|
||||
_loadEnv(`${path}.local`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function loadDotEnv() {
|
||||
const env = loadEnv(rootResolve(".env"));
|
||||
const envMode = loadEnv(rootResolve(`.env.${nodeEnv}`));
|
||||
|
||||
return { ...env, ...envMode };
|
||||
}
|
||||
|
||||
export function loadDotEnvRuntime() {
|
||||
const env = loadDotEnv();
|
||||
|
||||
Object.keys(env).forEach((key) => {
|
||||
if (!process.env[key]) {
|
||||
process.env[key] = env[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function loadDotEnvDefined() {
|
||||
const env = loadDotEnv();
|
||||
|
||||
return Object.keys(env).reduce((prev, cur) => {
|
||||
if (!cur.startsWith("APP_")) return prev;
|
||||
prev[`process.env.${[cur]}`] = JSON.stringify(env[cur]);
|
||||
return prev;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function copyResource(resource) {
|
||||
resource.forEach((r) => {
|
||||
const { from, to } = r;
|
||||
cpSync(from, to, {
|
||||
recursive: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function removeResource(resource) {
|
||||
resource.forEach((r) => {
|
||||
rmSync(r, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { inject, injectable } from "inversify";
|
||||
import {
|
||||
DatabaseService,
|
||||
DownloadStatus,
|
||||
IpcHandlerService,
|
||||
MainWindowService,
|
||||
ProtocolService,
|
||||
UpdateService,
|
||||
VideoRepository,
|
||||
WebviewService,
|
||||
type App,
|
||||
WebService,
|
||||
DevToolsService,
|
||||
StoreService,
|
||||
} from "./interfaces";
|
||||
import { TYPES } from "./types";
|
||||
import { Menu, Tray, app, nativeImage, nativeTheme } from "electron";
|
||||
import TrayIcon from "./tray-icon.png";
|
||||
import path from "path";
|
||||
|
||||
@injectable()
|
||||
export default class ElectronApp implements App {
|
||||
constructor(
|
||||
@inject(TYPES.MainWindowService)
|
||||
private readonly mainWindow: MainWindowService,
|
||||
@inject(TYPES.ProtocolService)
|
||||
private readonly protocolService: ProtocolService,
|
||||
@inject(TYPES.UpdateService)
|
||||
private readonly updateService: UpdateService,
|
||||
@inject(TYPES.IpcHandlerService)
|
||||
private readonly ipcHandler: IpcHandlerService,
|
||||
@inject(TYPES.DatabaseService)
|
||||
private readonly dataService: DatabaseService,
|
||||
@inject(TYPES.WebviewService)
|
||||
private readonly webview: WebviewService,
|
||||
@inject(TYPES.VideoRepository)
|
||||
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 async seriveInit(): Promise<void> {
|
||||
this.protocolService.create();
|
||||
await this.dataService.init();
|
||||
this.mainWindow.init();
|
||||
this.ipcHandler.init();
|
||||
this.updateService.init();
|
||||
this.webview.init(), this.devTools.init();
|
||||
this.webService.init();
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await this.seriveInit();
|
||||
|
||||
app.on("activate", () => {
|
||||
this.mainWindow.init();
|
||||
});
|
||||
|
||||
this.initAppTheme();
|
||||
this.resetDownloadStatus();
|
||||
|
||||
this.initTray();
|
||||
}
|
||||
|
||||
initAppTheme(): void {
|
||||
const theme = this.storeService.get("theme");
|
||||
nativeTheme.themeSource = theme;
|
||||
}
|
||||
|
||||
initTray() {
|
||||
const iconPath = path.resolve(__dirname, TrayIcon);
|
||||
const icon = nativeImage.createFromPath(iconPath);
|
||||
const tray = new Tray(icon);
|
||||
tray.setToolTip("在线视频下载");
|
||||
tray.addListener("click", () => {
|
||||
this.mainWindow.init();
|
||||
});
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{ label: "显示主窗口", click: () => this.mainWindow.init() },
|
||||
{ label: "退出 app", role: "quit" },
|
||||
]);
|
||||
tray.setContextMenu(contextMenu);
|
||||
}
|
||||
|
||||
// 如果重启后还有正在下载的视频,就将状态改成下载失败
|
||||
async resetDownloadStatus(): Promise<void> {
|
||||
// 重启后如果还有 downloading 状态的数据, 全部重置为失败
|
||||
const videos = await this.videoRepository.findWattingAndDownloadingVideos();
|
||||
const videoIds = videos.map((video) => video.id);
|
||||
await this.videoRepository.changeVideoStatus(
|
||||
videoIds,
|
||||
DownloadStatus.Failed
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { IpcMainEvent } from "electron/main";
|
||||
import { inject, injectable } from "inversify";
|
||||
import { handle } from "../helper/decorator";
|
||||
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";
|
||||
|
||||
@injectable()
|
||||
export default class DownloadController implements Controller {
|
||||
constructor(
|
||||
@inject(TYPES.StoreService)
|
||||
private readonly storeService: StoreService,
|
||||
@inject(TYPES.VideoRepository)
|
||||
private readonly videoRepository: VideoRepository,
|
||||
@inject(TYPES.DownloadService)
|
||||
private readonly downloadService: DownloadService,
|
||||
@inject(TYPES.MainWindowService)
|
||||
private readonly mainWindow: MainWindowServiceImpl
|
||||
) {}
|
||||
|
||||
@handle("add-download-item")
|
||||
async addDownloadItem(e: IpcMainEvent, video: DownloadItem) {
|
||||
const item = await this.videoRepository.addVideo(video);
|
||||
// 这里向页面发送消息,通知页面更新
|
||||
this.mainWindow.window?.webContents.send("download-item-notifier", item);
|
||||
return item;
|
||||
}
|
||||
|
||||
@handle("edit-download-item")
|
||||
async editDownloadItem(e: IpcMainEvent, video: DownloadItem) {
|
||||
const item = await this.videoRepository.editVideo(video);
|
||||
return item;
|
||||
}
|
||||
|
||||
@handle("download-now")
|
||||
async downloadNow(e: IpcMainEvent, video: DownloadItem) {
|
||||
// 添加下载项
|
||||
const item = await this.addDownloadItem(e, video);
|
||||
// 开始下载
|
||||
await this.startDownload(e, item.id);
|
||||
return item;
|
||||
}
|
||||
|
||||
@handle("get-download-items")
|
||||
async getDownloadItems(e: IpcMainEvent, pagination: DownloadItemPagination) {
|
||||
return await this.videoRepository.findVideos(pagination);
|
||||
}
|
||||
|
||||
@handle("start-download")
|
||||
async startDownload(e: IpcMainEvent, vid: number) {
|
||||
// 查找将要下载的视频
|
||||
const video = await this.videoRepository.findVideo(vid);
|
||||
if (!video) {
|
||||
return Promise.reject("没有找到该视频");
|
||||
}
|
||||
const { name, url, headers } = video;
|
||||
const local = this.storeService.get("local");
|
||||
|
||||
// 从配置中添加参数
|
||||
const deleteSegments = this.storeService.get("deleteSegments");
|
||||
|
||||
const task: Task = {
|
||||
id: vid,
|
||||
params: {
|
||||
url,
|
||||
local,
|
||||
name,
|
||||
headers,
|
||||
deleteSegments,
|
||||
},
|
||||
process: spawnDownload,
|
||||
};
|
||||
await this.videoRepository.changeVideoStatus(vid, DownloadStatus.Watting);
|
||||
this.downloadService.addTask(task);
|
||||
}
|
||||
|
||||
@handle("stop-download")
|
||||
async stopDownload(e: IpcMainEvent, id: number) {
|
||||
this.downloadService.stopTask(id);
|
||||
}
|
||||
|
||||
@handle("delete-download-item")
|
||||
async deleteDownloadItem(e: IpcMainEvent, id: number) {
|
||||
return await this.videoRepository.deleteDownloadItem(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
dialog,
|
||||
IpcMainEvent,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuItemConstructorOptions,
|
||||
nativeTheme,
|
||||
shell,
|
||||
clipboard,
|
||||
} from "electron";
|
||||
import { Favorite } from "entity/Favorite";
|
||||
import { convertToAudio } from "helper";
|
||||
import { db, workspace } from "helper/variables";
|
||||
import { inject, injectable } from "inversify";
|
||||
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 { TYPES } from "../types";
|
||||
import fs from "fs-extra";
|
||||
|
||||
@injectable()
|
||||
export default class HomeController implements Controller {
|
||||
private sharedState: Record<string, any> = {};
|
||||
|
||||
constructor(
|
||||
@inject(TYPES.StoreService)
|
||||
private readonly storeService: StoreService,
|
||||
@inject(TYPES.FavoriteRepository)
|
||||
private readonly favoriteRepository: FavoriteRepository,
|
||||
@inject(TYPES.MainWindowService)
|
||||
private readonly mainWindow: MainWindowService,
|
||||
@inject(TYPES.VideoRepository)
|
||||
private readonly videoRepository: VideoRepository,
|
||||
@inject(TYPES.BrowserWindowService)
|
||||
private readonly browserWindow: BrowserWindowService,
|
||||
@inject(TYPES.PlayerWindowService)
|
||||
private readonly playerWindow: PlayerWindowService,
|
||||
@inject(TYPES.WebviewService)
|
||||
private readonly webviewService: WebviewService
|
||||
) {}
|
||||
|
||||
@handle("get-env-path")
|
||||
async getEnvPath(): Promise<EnvPath> {
|
||||
return {
|
||||
binPath: __bin__,
|
||||
dbPath: db,
|
||||
workspace: workspace,
|
||||
platform: process.platform,
|
||||
local: this.storeService.get("local"),
|
||||
};
|
||||
}
|
||||
|
||||
@handle("get-favorites")
|
||||
getFavorites() {
|
||||
return this.favoriteRepository.findFavorites();
|
||||
}
|
||||
|
||||
@handle("add-favorite")
|
||||
addFavorite(e: IpcMainEvent, favorite: Favorite) {
|
||||
return this.favoriteRepository.addFavorite(favorite);
|
||||
}
|
||||
|
||||
@handle("remove-favorite")
|
||||
removeFavorite(e: IpcMainEvent, id: number): Promise<void> {
|
||||
return this.favoriteRepository.removeFavorite(id);
|
||||
}
|
||||
|
||||
@handle("get-app-store")
|
||||
getAppStore() {
|
||||
return this.storeService.store;
|
||||
}
|
||||
|
||||
@handle("on-favorite-item-context-menu")
|
||||
async onFavoriteItemContextMenu(e: IpcMainEvent, id: number) {
|
||||
const send = (action: string) => {
|
||||
const window = this.mainWindow.window;
|
||||
if (!window) return;
|
||||
window.webContents.send("favorite-item-event", {
|
||||
action,
|
||||
payload: id,
|
||||
});
|
||||
};
|
||||
const template: Array<MenuItemConstructorOptions | MenuItem> = [
|
||||
{
|
||||
label: "打开",
|
||||
click: () => {
|
||||
send("open");
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "删除",
|
||||
click: () => {
|
||||
send("delete");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
menu.popup();
|
||||
}
|
||||
|
||||
@handle("select-download-dir")
|
||||
async selectDownloadDir(): Promise<string> {
|
||||
const window = this.mainWindow.window;
|
||||
if (!window) return Promise.reject("未找到主窗口");
|
||||
|
||||
const result = await dialog.showOpenDialog(window, {
|
||||
properties: ["openDirectory"],
|
||||
});
|
||||
|
||||
if (!result.canceled) {
|
||||
const dir = result.filePaths[0];
|
||||
this.storeService.set("local", dir);
|
||||
return dir;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@handle("set-app-store")
|
||||
async setAppStore(e: IpcMainEvent, key: keyof AppStore, val: any) {
|
||||
// useProxy
|
||||
if (key === "useProxy") {
|
||||
const proxy = this.storeService.get("proxy");
|
||||
this.webviewService.setProxy(val, proxy);
|
||||
}
|
||||
// proxy
|
||||
if (key === "proxy") {
|
||||
const useProxy = this.storeService.get("useProxy");
|
||||
useProxy && this.webviewService.setProxy(true, val);
|
||||
}
|
||||
// block
|
||||
if (key === "blockAds") {
|
||||
this.webviewService.setBlocking(val);
|
||||
}
|
||||
// theme
|
||||
if (key === "theme") {
|
||||
nativeTheme.themeSource = val;
|
||||
}
|
||||
// isMobile
|
||||
if (key === "isMobile") {
|
||||
this.webviewService.setUserAgent(val);
|
||||
}
|
||||
|
||||
this.storeService.set(key, val);
|
||||
}
|
||||
|
||||
@handle("open-dir")
|
||||
async openDir(e: IpcMainEvent, dir: string) {
|
||||
await shell.openPath(dir);
|
||||
}
|
||||
|
||||
@handle("open-url")
|
||||
async openUrl(e: IpcMainEvent, url: string) {
|
||||
await shell.openExternal(url);
|
||||
}
|
||||
|
||||
@handle("on-download-list-context-menu")
|
||||
async downloadListContextMenu(e: IpcMainEvent, id: number) {
|
||||
const send = (action: string) => {
|
||||
const window = this.mainWindow.window;
|
||||
if (!window) return;
|
||||
|
||||
window.webContents.send("download-item-event", {
|
||||
action,
|
||||
payload: id,
|
||||
});
|
||||
};
|
||||
const item = await this.videoRepository.findVideo(id);
|
||||
const template: Array<MenuItemConstructorOptions | MenuItem> = [
|
||||
{
|
||||
label: "拷贝链接地址",
|
||||
click: () => {
|
||||
clipboard.writeText(item?.url || "");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "选择",
|
||||
click: () => {
|
||||
send("select");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "下载",
|
||||
click: () => {
|
||||
send("download");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "刷新",
|
||||
click: () => {
|
||||
send("refresh");
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "删除",
|
||||
click: () => {
|
||||
send("delete");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
menu.popup();
|
||||
}
|
||||
|
||||
@handle("convert-to-audio")
|
||||
async convertToAudio(e: IpcMainEvent, id: number) {
|
||||
const video = await this.videoRepository.findVideo(id);
|
||||
const local = this.storeService.get("local");
|
||||
const input = path.join(local, `${video?.name}.mp4`);
|
||||
const output = path.join(local, `${video?.name}.mp3`);
|
||||
|
||||
const exist = await fs.exists(input);
|
||||
if (exist) {
|
||||
return await convertToAudio(input, output);
|
||||
} else {
|
||||
return Promise.reject("未找到文件,可能是文件已经删除");
|
||||
}
|
||||
}
|
||||
|
||||
@handle("show-browser-window")
|
||||
async showBrowserWindow() {
|
||||
this.browserWindow.showWindow();
|
||||
}
|
||||
|
||||
@handle("combine-to-home-page")
|
||||
async combineToHomePage() {
|
||||
// 关闭浏览器窗口
|
||||
this.browserWindow.hideWindow();
|
||||
// 修改设置中的属性
|
||||
this.storeService.set("openInNewWindow", false);
|
||||
}
|
||||
|
||||
@handle("open-player-window")
|
||||
async openPlayerWindow(event: IpcMainEvent, id: number) {
|
||||
// 打开播放器窗口
|
||||
this.playerWindow.openWindow(id);
|
||||
}
|
||||
|
||||
@handle("get-local-ip")
|
||||
async getLocalIp() {
|
||||
return getLocalIP();
|
||||
}
|
||||
|
||||
@handle("get-shared-state")
|
||||
async getSharedState() {
|
||||
return this.sharedState;
|
||||
}
|
||||
|
||||
@handle("set-shared-state")
|
||||
async setSharedState(event: IpcMainEvent, state: any) {
|
||||
this.sharedState = state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { IpcMainEvent } from "electron";
|
||||
import { inject, injectable } from "inversify";
|
||||
import { handle } from "../helper/decorator";
|
||||
import { type Controller, WebviewService, StoreService } from "../interfaces";
|
||||
import { TYPES } from "../types";
|
||||
|
||||
@injectable()
|
||||
export default class WebviewController implements Controller {
|
||||
constructor(
|
||||
@inject(TYPES.WebviewService)
|
||||
private readonly webview: WebviewService,
|
||||
@inject(TYPES.StoreService)
|
||||
private readonly storeService: StoreService
|
||||
) {}
|
||||
|
||||
@handle("set-webview-bounds")
|
||||
async setWebviewBounds(e: IpcMainEvent, bounds: Electron.Rectangle) {
|
||||
this.webview.setBounds(bounds);
|
||||
}
|
||||
|
||||
@handle("webview-load-url")
|
||||
async browserViewLoadUrl(e: IpcMainEvent, url?: string): Promise<void> {
|
||||
await this.webview.loadURL(url);
|
||||
}
|
||||
|
||||
@handle("webview-go-back")
|
||||
async webviewGoBack(): Promise<boolean> {
|
||||
return this.webview.goBack();
|
||||
}
|
||||
|
||||
@handle("webview-reload")
|
||||
async webviewReload() {
|
||||
await this.webview.reload();
|
||||
}
|
||||
|
||||
@handle("webview-show")
|
||||
async webviewShow() {
|
||||
this.webview.show();
|
||||
}
|
||||
|
||||
@handle("webview-hide")
|
||||
async webviewHide() {
|
||||
this.webview.hide();
|
||||
}
|
||||
|
||||
@handle("webview-go-home")
|
||||
async webviewGoHome() {
|
||||
await this.webview.goHome();
|
||||
}
|
||||
|
||||
@handle("webview-change-user-agent")
|
||||
async webviewChangeUserAgent(e: IpcMainEvent, isMobile: boolean) {
|
||||
this.webview.setUserAgent(isMobile);
|
||||
this.storeService.set("isMobile", isMobile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from "typeorm";
|
||||
|
||||
@Entity({
|
||||
name: "favorite",
|
||||
})
|
||||
export class Favorite {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({
|
||||
type: "text",
|
||||
nullable: false,
|
||||
})
|
||||
title: string;
|
||||
|
||||
@Column({
|
||||
type: "text",
|
||||
nullable: false,
|
||||
})
|
||||
url: string;
|
||||
|
||||
@Column({
|
||||
type: "text",
|
||||
nullable: true,
|
||||
})
|
||||
icon?: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdDate: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedDate: Date;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { DownloadStatus } from "interfaces";
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from "typeorm";
|
||||
|
||||
@Entity({
|
||||
name: "video",
|
||||
})
|
||||
export class Video {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({
|
||||
type: "text",
|
||||
nullable: true,
|
||||
})
|
||||
name: string;
|
||||
|
||||
@Column({
|
||||
type: "text",
|
||||
nullable: false,
|
||||
})
|
||||
url: string;
|
||||
|
||||
@Column({
|
||||
type: "text",
|
||||
nullable: true,
|
||||
})
|
||||
headers: string;
|
||||
|
||||
@Column({
|
||||
type: "boolean",
|
||||
default: false,
|
||||
nullable: false,
|
||||
})
|
||||
isLive: boolean;
|
||||
|
||||
@Column({
|
||||
type: "text",
|
||||
nullable: false,
|
||||
default: DownloadStatus.Ready,
|
||||
})
|
||||
status: DownloadStatus;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdDate: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedDate: Date;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/* eslint-disable no-var */
|
||||
declare global {
|
||||
var __bin__: string;
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,13 @@
|
||||
export const handle = (route: string) => {
|
||||
return (target: any, propertyKey: string): void => {
|
||||
Reflect.defineMetadata("ipc-method", "handle", target, propertyKey);
|
||||
Reflect.defineMetadata("ipc-channel", route, target, propertyKey);
|
||||
};
|
||||
};
|
||||
|
||||
export const on = (route: string) => {
|
||||
return (target: any, propertyName: string): void => {
|
||||
Reflect.defineMetadata("ipc-method", "on", target, propertyName);
|
||||
Reflect.defineMetadata("ipc-channel", route, target, propertyName);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
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,39 @@
|
||||
import { spawn } from "child_process";
|
||||
import { ffmpegPath } from "./variables";
|
||||
|
||||
export const convertToAudio = async (
|
||||
input: string,
|
||||
output: string
|
||||
): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ffmpeg = spawn(ffmpegPath, [
|
||||
"-y",
|
||||
"-v",
|
||||
"error",
|
||||
"-i",
|
||||
input,
|
||||
"-acodec",
|
||||
"mp3",
|
||||
"-format",
|
||||
"mp3",
|
||||
output,
|
||||
]);
|
||||
let errData = "";
|
||||
|
||||
ffmpeg.stderr.on("data", (data) => {
|
||||
errData += String(data);
|
||||
});
|
||||
|
||||
ffmpeg.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
ffmpeg.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(errData));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import axios from "axios";
|
||||
import https from "https";
|
||||
|
||||
const agent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
|
||||
// 创建 axios 实例,并指定自定义的 HTTPS Agent
|
||||
const instance = axios.create({
|
||||
httpsAgent: agent,
|
||||
});
|
||||
|
||||
export default instance;
|
||||
@@ -0,0 +1,31 @@
|
||||
import os from "os";
|
||||
|
||||
export function getLocalIP() {
|
||||
const interfaces = os.networkInterfaces();
|
||||
let localIP = "";
|
||||
|
||||
// 遍历网络接口
|
||||
for (const key in interfaces) {
|
||||
const iface = interfaces[key];
|
||||
if (!iface) continue;
|
||||
|
||||
// 过滤出 IPv4 地址且非回环地址
|
||||
const filteredIface = iface.filter(
|
||||
(details) => details.family === "IPv4" && !details.internal
|
||||
);
|
||||
|
||||
if (filteredIface.length > 0) {
|
||||
localIP = filteredIface[0].address;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return localIP;
|
||||
}
|
||||
|
||||
export { sleep } from "./utils";
|
||||
export * from "./variables";
|
||||
export { on, handle } from "./decorator";
|
||||
export { spawnDownload } from "./download";
|
||||
export { convertToAudio } from "./ffmpeg";
|
||||
export { default as http } from "./http";
|
||||
@@ -0,0 +1,24 @@
|
||||
import EventEmitter from "events";
|
||||
|
||||
export async function sleep(second = 1): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, second * 1000));
|
||||
}
|
||||
|
||||
export function stripColors(str: string) {
|
||||
// 匹配控制台颜色字符的正则表达式
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const colorRegex = /\x1b\[(\d+)(;\d+)*m/g;
|
||||
// 将所有颜色字符替换为空字符串
|
||||
return str.replace(colorRegex, "");
|
||||
}
|
||||
|
||||
export function formatHeaders(headersStr: string): string {
|
||||
const headers: Record<string, any> | null = JSON.parse(headersStr);
|
||||
if (!headers) return "";
|
||||
const formatted = Object.entries(headers)
|
||||
.map(([key, value]) => `${key}:${value}`)
|
||||
.join("|");
|
||||
return formatted;
|
||||
}
|
||||
|
||||
export const event = new EventEmitter();
|
||||
@@ -0,0 +1,31 @@
|
||||
import { app } from "electron";
|
||||
import isDev from "electron-is-dev";
|
||||
import path from "path";
|
||||
|
||||
if (!isDev) {
|
||||
global.__bin__ = path.resolve(app.getAppPath(), "../bin");
|
||||
}
|
||||
|
||||
export const appData = app.getPath("appData");
|
||||
export const appName = process.env.APP_NAME || "electron-template";
|
||||
export const workspace = path.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");
|
||||
|
||||
// user agent
|
||||
export const pcUA =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36";
|
||||
export const mobileUA =
|
||||
"Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Mobile Safari/537.36";
|
||||
@@ -0,0 +1,7 @@
|
||||
declare module "*.svg";
|
||||
declare module "*.png";
|
||||
declare module "*.jpg";
|
||||
declare module "*.jpeg";
|
||||
declare module "*.gif";
|
||||
declare module "*.bmp";
|
||||
declare module "*.tiff";
|
||||
@@ -0,0 +1,32 @@
|
||||
import "reflect-metadata";
|
||||
import { app, protocol } from "electron";
|
||||
import { defaultScheme } from "./helper/variables";
|
||||
import { type App } from "./interfaces";
|
||||
import { container } from "./inversify.config";
|
||||
import { TYPES } from "./types";
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
const start = async (): Promise<void> => {
|
||||
if (!gotTheLock) {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: defaultScheme,
|
||||
privileges: {
|
||||
secure: true,
|
||||
standard: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
await app.whenReady();
|
||||
const mediago = container.get<App>(TYPES.App);
|
||||
mediago.init();
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
// empty
|
||||
});
|
||||
};
|
||||
|
||||
void start();
|
||||
@@ -0,0 +1,173 @@
|
||||
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;
|
||||
name: string;
|
||||
url: string;
|
||||
headers: string;
|
||||
}
|
||||
|
||||
export enum DownloadFilter {
|
||||
list = "list",
|
||||
done = "done",
|
||||
}
|
||||
|
||||
export interface DownloadItemPagination {
|
||||
current?: number;
|
||||
pageSize?: number;
|
||||
filter?: DownloadFilter;
|
||||
}
|
||||
|
||||
export interface VideoResponse {
|
||||
total: number;
|
||||
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",
|
||||
Downloading = "downloading",
|
||||
Stopped = "stopped",
|
||||
Success = "success",
|
||||
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">;
|
||||
process: (params: DownloadParams) => Promise<void>;
|
||||
};
|
||||
|
||||
export interface DownloadProgress {
|
||||
id: number;
|
||||
type: string;
|
||||
cur: string;
|
||||
total: string;
|
||||
speed: string;
|
||||
isLive: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadParams {
|
||||
id: number;
|
||||
url: string;
|
||||
local: string;
|
||||
name: string;
|
||||
headers: string;
|
||||
abortSignal: AbortController;
|
||||
deleteSegments?: boolean;
|
||||
callback: (progress: DownloadProgress) => void;
|
||||
}
|
||||
|
||||
export interface DevToolsService {
|
||||
init: () => Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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 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 { TYPES } from "./types";
|
||||
import WebServiceImpl from "services/WebServiceImpl";
|
||||
import MainWindow from "./windows/Main";
|
||||
import PlayerWindow from "windows/Player";
|
||||
import BrowserWin from "windows/Browser";
|
||||
|
||||
const container = new Container({
|
||||
skipBaseClassChecks: true,
|
||||
defaultScope: "Singleton",
|
||||
autoBindInjectable: true,
|
||||
});
|
||||
|
||||
container.bind<App>(TYPES.App).to(ElectronApp);
|
||||
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);
|
||||
|
||||
// windows
|
||||
container.bind<MainWindowService>(TYPES.MainWindowService).to(MainWindow);
|
||||
container.bind<BrowserWindowService>(TYPES.BrowserWindowService).to(BrowserWin);
|
||||
container.bind<PlayerWindowService>(TYPES.PlayerWindowService).to(PlayerWindow);
|
||||
|
||||
// === 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);
|
||||
container
|
||||
.bind<FavoriteRepository>(TYPES.FavoriteRepository)
|
||||
.to(FavoriteRepositoryImpl);
|
||||
|
||||
export { container };
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Video, Rectangle } from "electron";
|
||||
import { type Favorite } from "entity/Favorite";
|
||||
import {
|
||||
DownloadItem,
|
||||
DownloadItemPagination,
|
||||
VideoResponse,
|
||||
} from "interfaces";
|
||||
|
||||
declare interface EnvPath {
|
||||
binPath: string;
|
||||
dbPath: string;
|
||||
workspace: string;
|
||||
platform: string;
|
||||
local: string;
|
||||
}
|
||||
|
||||
declare interface BrowserWindowInitialVal {
|
||||
url?: string;
|
||||
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;
|
||||
name: string;
|
||||
headers: string;
|
||||
}
|
||||
|
||||
declare interface AppStore {
|
||||
// 本地存储地址
|
||||
local: string;
|
||||
// 下载完成提示音
|
||||
promptTone: boolean;
|
||||
// 代理地址
|
||||
proxy: string;
|
||||
// 是否开启代理
|
||||
useProxy: boolean;
|
||||
// 下载完成后删除原始文件
|
||||
deleteSegments: boolean;
|
||||
// 新窗口打开浏览器
|
||||
openInNewWindow: boolean;
|
||||
mainBounds?: Rectangle;
|
||||
browserBounds?: Rectangle;
|
||||
blockAds: boolean;
|
||||
// 主题
|
||||
theme: "system" | "light" | "dark";
|
||||
// 使用浏览器插件
|
||||
useExtension: boolean;
|
||||
// 是否使用手机UA
|
||||
isMobile: boolean;
|
||||
// 最大同时下载数
|
||||
maxRunner: number;
|
||||
}
|
||||
|
||||
declare interface BrowserStore {
|
||||
url: string;
|
||||
sourceList: WebSource[];
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { contextBridge, ipcRenderer, shell } from "electron";
|
||||
import { ElectronAPI } from "./main";
|
||||
|
||||
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) =>
|
||||
ipcRenderer.invoke("on-download-list-context-menu", id),
|
||||
onFavoriteItemContextMenu: (id) =>
|
||||
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) => {
|
||||
const key = `${channel}-${funcId}`;
|
||||
apiFunctions[key] = listener;
|
||||
ipcRenderer.on(channel, listener);
|
||||
},
|
||||
removeEventListener: (channel, funcId) => {
|
||||
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) =>
|
||||
ipcRenderer.invoke("combine-to-home-page", store),
|
||||
editDownloadItem: (video) => ipcRenderer.invoke("edit-download-item", video),
|
||||
openPlayerWindow: (videoId) =>
|
||||
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) =>
|
||||
ipcRenderer.invoke("webview-change-user-agent", isMobile),
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld(apiKey, handleApi);
|
||||
@@ -0,0 +1,48 @@
|
||||
import { inject, injectable } from "inversify";
|
||||
import {
|
||||
DatabaseService,
|
||||
FavoriteRepository,
|
||||
LoggerService,
|
||||
} from "../interfaces";
|
||||
import { TYPES } from "../types";
|
||||
import { Favorite } from "entity/Favorite";
|
||||
|
||||
@injectable()
|
||||
export default class FavoriteRepositoryImpl implements FavoriteRepository {
|
||||
constructor(
|
||||
@inject(TYPES.DatabaseService)
|
||||
private readonly dataService: DatabaseService,
|
||||
@inject(TYPES.LoggerService)
|
||||
private readonly logger: LoggerService
|
||||
) {}
|
||||
|
||||
async findFavorites(): Promise<Favorite[]> {
|
||||
return await this.dataService.manager.find(Favorite, {
|
||||
order: {
|
||||
createdDate: "desc",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async addFavorite(favorite: Favorite): Promise<Favorite> {
|
||||
const exist = await this.dataService.manager.findOne(Favorite, {
|
||||
where: {
|
||||
url: favorite.url,
|
||||
},
|
||||
});
|
||||
|
||||
if (exist) {
|
||||
throw new Error("网址已经存在");
|
||||
}
|
||||
|
||||
const item = new Favorite();
|
||||
item.title = favorite.title;
|
||||
item.url = favorite.url;
|
||||
item.icon = favorite.icon;
|
||||
return await this.dataService.manager.save(item);
|
||||
}
|
||||
|
||||
async removeFavorite(id: number): Promise<void> {
|
||||
await this.dataService.manager.getRepository(Favorite).delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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";
|
||||
|
||||
@injectable()
|
||||
export default class VideoRepositoryImpl implements VideoRepository {
|
||||
constructor(
|
||||
@inject(TYPES.DatabaseService)
|
||||
private readonly dataService: DatabaseService,
|
||||
@inject(TYPES.LoggerService)
|
||||
private readonly logger: LoggerService
|
||||
) {}
|
||||
|
||||
async addVideo(video: DownloadItem) {
|
||||
const item = new Video();
|
||||
item.name = video.name;
|
||||
item.url = video.url;
|
||||
item.headers = video.headers;
|
||||
return await this.dataService.manager.save(item);
|
||||
}
|
||||
|
||||
// 编辑视频
|
||||
async editVideo(video: DownloadItem) {
|
||||
const item = await this.dataService.appDataSource
|
||||
.getRepository(Video)
|
||||
.findOneBy({ id: video.id });
|
||||
if (!item) {
|
||||
throw new Error("视频不存在");
|
||||
}
|
||||
item.name = video.name;
|
||||
item.url = video.url;
|
||||
return await this.dataService.manager.save(item);
|
||||
}
|
||||
|
||||
// 查找所有视频
|
||||
async findAllVideos() {
|
||||
return await this.dataService.appDataSource.getRepository(Video).find({
|
||||
order: {
|
||||
createdDate: "DESC",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findVideos(pagination: DownloadItemPagination) {
|
||||
const {
|
||||
current = 0,
|
||||
pageSize = 50,
|
||||
filter = DownloadFilter.list,
|
||||
} = pagination;
|
||||
const filterCondition =
|
||||
filter === DownloadFilter.done
|
||||
? DownloadStatus.Success
|
||||
: Not(DownloadStatus.Success);
|
||||
|
||||
const [items, count] = await this.dataService.appDataSource
|
||||
.getRepository(Video)
|
||||
.findAndCount({
|
||||
where: {
|
||||
status: filterCondition,
|
||||
},
|
||||
order: {
|
||||
createdDate: "DESC",
|
||||
},
|
||||
skip: (current - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
return {
|
||||
total: count,
|
||||
list: items,
|
||||
};
|
||||
}
|
||||
|
||||
async findVideo(id: number) {
|
||||
return this.dataService.appDataSource.getRepository(Video).findOneBy({
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
async changeVideoStatus(id: number | number[], status: DownloadStatus) {
|
||||
const ids = !Array.isArray(id) ? [id] : id;
|
||||
return this.dataService.appDataSource
|
||||
.createQueryBuilder()
|
||||
.update(Video)
|
||||
.set({ status })
|
||||
.where({ id: In(ids) })
|
||||
.execute();
|
||||
}
|
||||
|
||||
async changeVideoIsLive(id: number | number[], isLive: boolean) {
|
||||
const ids = !Array.isArray(id) ? [id] : id;
|
||||
return this.dataService.appDataSource
|
||||
.createQueryBuilder()
|
||||
.update(Video)
|
||||
.set({ isLive })
|
||||
.where({ id: In(ids) })
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findWattingAndDownloadingVideos() {
|
||||
return await this.dataService.appDataSource.getRepository(Video).find({
|
||||
where: {
|
||||
status: In([DownloadStatus.Downloading, DownloadStatus.Watting]),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteDownloadItem(id: number) {
|
||||
return await this.dataService.appDataSource.getRepository(Video).delete(id);
|
||||
}
|
||||
|
||||
async findVideoByUrl(url: string) {
|
||||
return this.dataService.appDataSource.getRepository(Video).findOneBy({
|
||||
url,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { db } from "../helper/variables";
|
||||
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";
|
||||
|
||||
@injectable()
|
||||
export default class DatabaseServiceImpl implements DatabaseService {
|
||||
appDataSource: DataSource;
|
||||
|
||||
constructor(
|
||||
@inject(TYPES.LoggerService)
|
||||
private readonly logger: LoggerService
|
||||
) {
|
||||
this.appDataSource = new DataSource({
|
||||
type: "better-sqlite3",
|
||||
database: db,
|
||||
synchronize: true,
|
||||
logging: false,
|
||||
entities: [Video, Favorite],
|
||||
migrations: [],
|
||||
subscribers: [],
|
||||
});
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.logger.info("数据库地址是: ", db);
|
||||
await this.appDataSource.initialize();
|
||||
}
|
||||
|
||||
get manager(): EntityManager {
|
||||
return this.appDataSource.manager;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { DevToolsService, LoggerService } from "../interfaces";
|
||||
import { inject, injectable } from "inversify";
|
||||
import { TYPES } from "../types";
|
||||
import isDev from "electron-is-dev";
|
||||
import installExtension, {
|
||||
REDUX_DEVTOOLS,
|
||||
REACT_DEVELOPER_TOOLS,
|
||||
} from "electron-devtools-installer";
|
||||
|
||||
@injectable()
|
||||
export default class DevToolsServiceImpl implements DevToolsService {
|
||||
constructor(
|
||||
@inject(TYPES.LoggerService)
|
||||
private readonly logger: LoggerService
|
||||
) {}
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (!isDev) {
|
||||
return;
|
||||
}
|
||||
// 开发环境中可以通过设置环境变量来控制是否加载开发者工具
|
||||
if (!process.env.LOAD_DEVTOOLS) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.debug("当前环境为开发环境,开始加载开发者工具");
|
||||
await installExtension([REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS]);
|
||||
this.logger.debug("加载开发者工具成功");
|
||||
} catch (err: any) {
|
||||
this.logger.error("加载开发者工具失败", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import EventEmitter from "events";
|
||||
import { inject, injectable } from "inversify";
|
||||
import {
|
||||
DownloadService,
|
||||
DownloadStatus,
|
||||
StoreService,
|
||||
Task,
|
||||
VideoRepository,
|
||||
} from "../interfaces";
|
||||
import { TYPES } from "../types";
|
||||
import LoggerServiceImpl from "./LoggerServiceImpl";
|
||||
|
||||
@injectable()
|
||||
export default class DownloadServiceImpl
|
||||
extends EventEmitter
|
||||
implements DownloadService
|
||||
{
|
||||
private queue: Task[] = [];
|
||||
|
||||
private active: Task[] = [];
|
||||
|
||||
private limit: number;
|
||||
|
||||
private debug = process.env.APP_DOWNLOAD_DEBUG;
|
||||
|
||||
private signal: Record<number, AbortController> = {};
|
||||
|
||||
constructor(
|
||||
@inject(TYPES.LoggerService)
|
||||
private readonly logger: LoggerServiceImpl,
|
||||
@inject(TYPES.VideoRepository)
|
||||
private readonly videoRepository: VideoRepository,
|
||||
@inject(TYPES.StoreService)
|
||||
private readonly storeService: StoreService
|
||||
) {
|
||||
super();
|
||||
|
||||
const maxRunner = this.storeService.get("maxRunner");
|
||||
this.limit = maxRunner;
|
||||
|
||||
this.storeService.onDidChange("maxRunner", (maxRunner) => {
|
||||
maxRunner && (this.limit = maxRunner);
|
||||
});
|
||||
}
|
||||
|
||||
async addTask(task: Task) {
|
||||
this.queue.push(task);
|
||||
this.runTask();
|
||||
}
|
||||
|
||||
async stopTask(id: number) {
|
||||
if (this.signal[id]) {
|
||||
this.log(`taskId: ${id} stop`);
|
||||
this.signal[id].abort();
|
||||
}
|
||||
}
|
||||
|
||||
async execute(task: Task) {
|
||||
try {
|
||||
await this.videoRepository.changeVideoStatus(
|
||||
task.id,
|
||||
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({
|
||||
...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);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
delete this.signal[task.id];
|
||||
this.log(`taskId: ${task.id} success`);
|
||||
|
||||
await this.videoRepository.changeVideoStatus(
|
||||
task.id,
|
||||
DownloadStatus.Success
|
||||
);
|
||||
this.emit("download-success", task.id);
|
||||
} catch (err: any) {
|
||||
this.log(`taskId: ${task.id} failed`);
|
||||
if (err.name === "AbortError") {
|
||||
// 下载暂停
|
||||
await this.videoRepository.changeVideoStatus(
|
||||
task.id,
|
||||
DownloadStatus.Stopped
|
||||
);
|
||||
this.emit("download-stop", task.id);
|
||||
} else {
|
||||
// 下载失败
|
||||
await this.videoRepository.changeVideoStatus(
|
||||
task.id,
|
||||
DownloadStatus.Failed
|
||||
);
|
||||
this.emit("download-failed", task.id, err);
|
||||
}
|
||||
} finally {
|
||||
this.removeTask(task.id);
|
||||
|
||||
// 传输完成
|
||||
if (this.queue.length === 0 && this.active.length === 0) {
|
||||
// this.emit("download-finish");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeTask(id: number) {
|
||||
// 处理当前正在活动的任务
|
||||
const doneId = this.active.findIndex((i) => i.id === id);
|
||||
this.active.splice(doneId, 1);
|
||||
// 处理完成的任务
|
||||
if (this.active.length < this.limit) {
|
||||
this.runTask();
|
||||
}
|
||||
}
|
||||
|
||||
runTask() {
|
||||
while (this.active.length < this.limit && this.queue.length > 0) {
|
||||
const task = this.queue.shift();
|
||||
if (task) {
|
||||
this.active.push(task);
|
||||
this.execute(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log(...args: any[]) {
|
||||
if (this.debug) {
|
||||
this.logger.info(`[DownloadService] `, ...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import logger, { ElectronLog } 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;
|
||||
|
||||
constructor() {
|
||||
const datetime = dayjs().format("YYYY-MM-DD");
|
||||
const logPath = path.resolve(workspace, `logs/${datetime}-${appName}.log`);
|
||||
logger.transports.console.format = "{h}:{i}:{s} {text}";
|
||||
logger.transports.file.getFile();
|
||||
logger.transports.file.resolvePath = () => logPath;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
info(...args: any[]) {
|
||||
return this.logger.info(...args);
|
||||
}
|
||||
|
||||
warn(...args: any[]) {
|
||||
return this.logger.warn(...args);
|
||||
}
|
||||
|
||||
error(...args: any[]) {
|
||||
return this.logger.error(...args);
|
||||
}
|
||||
|
||||
debug(...args: any[]) {
|
||||
return this.logger.debug(...args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { protocol } from "electron";
|
||||
import isDev from "electron-is-dev";
|
||||
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";
|
||||
|
||||
@injectable()
|
||||
export default class ProtocolServiceImpl implements ProtocolService {
|
||||
create(): void {
|
||||
if (isDev) return;
|
||||
|
||||
protocol.registerBufferProtocol(
|
||||
defaultScheme,
|
||||
async (request, callback) => {
|
||||
let pathName = new URL(request.url).pathname;
|
||||
pathName = decodeURI(pathName);
|
||||
|
||||
const filePath = join(__dirname, "../renderer", pathName);
|
||||
const fileExist = await pathExists(filePath);
|
||||
|
||||
if (fileExist) {
|
||||
const data = await readFile(filePath);
|
||||
const extension = extname(pathName).toLowerCase();
|
||||
let mimeType = "";
|
||||
|
||||
if (extension === ".js") {
|
||||
mimeType = "text/javascript";
|
||||
} else if (extension === ".html") {
|
||||
mimeType = "text/html";
|
||||
} else if (extension === ".css") {
|
||||
mimeType = "text/css";
|
||||
} else if (extension === ".svg" || extension === ".svgz") {
|
||||
mimeType = "image/svg+xml";
|
||||
} else if (extension === ".json") {
|
||||
mimeType = "application/json";
|
||||
}
|
||||
|
||||
callback({ mimeType, data });
|
||||
} else {
|
||||
// 如果没有找到文件,直接返回 index.html , react history 模式
|
||||
const filePath = join(__dirname, "../renderer/index.html");
|
||||
const data = await readFile(filePath);
|
||||
callback({ mimeType: "text/html", data });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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";
|
||||
|
||||
@injectable()
|
||||
export default class StoreServiceImpl
|
||||
extends Store<AppStore>
|
||||
implements StoreService
|
||||
{
|
||||
constructor() {
|
||||
super({
|
||||
name: "config",
|
||||
cwd: workspace,
|
||||
fileExtension: "json",
|
||||
watch: true,
|
||||
defaults: {
|
||||
local: download,
|
||||
promptTone: true,
|
||||
proxy: "",
|
||||
useProxy: false,
|
||||
deleteSegments: true,
|
||||
openInNewWindow: false,
|
||||
blockAds: true,
|
||||
theme: AppTheme.System,
|
||||
useExtension: false,
|
||||
isMobile: false,
|
||||
maxRunner: 2,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
init(): void {
|
||||
// empty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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";
|
||||
|
||||
@injectable()
|
||||
export default class UpdateServiceImpl implements UpdateService {
|
||||
constructor(
|
||||
@inject(TYPES.LoggerService) private readonly logger: LoggerService
|
||||
) {}
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (isDev) return;
|
||||
|
||||
try {
|
||||
autoUpdater.disableWebInstaller = true;
|
||||
autoUpdater.logger = this.logger.logger;
|
||||
await autoUpdater.checkForUpdatesAndNotify({
|
||||
title: "自动更新完成",
|
||||
body: "下次重启时将会自动安装",
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.info("update error", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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,277 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 625 B |
@@ -0,0 +1,26 @@
|
||||
export const TYPES = {
|
||||
App: Symbol.for("App"),
|
||||
MainWindowService: Symbol.for("MainWindowService"),
|
||||
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 {
|
||||
System = "system",
|
||||
Light = "light",
|
||||
Dark = "dark",
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
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";
|
||||
|
||||
@injectable()
|
||||
export default class BrowserWindowServiceImpl
|
||||
extends Window
|
||||
implements BrowserWindowService
|
||||
{
|
||||
url = isDev
|
||||
? "http://localhost:8555/browser"
|
||||
: "mediago://index.html/browser";
|
||||
|
||||
constructor(
|
||||
@inject(TYPES.StoreService)
|
||||
private readonly storeService: StoreService
|
||||
) {
|
||||
super({
|
||||
width: 1100,
|
||||
minWidth: 414,
|
||||
height: 680,
|
||||
minHeight: 680,
|
||||
show: false,
|
||||
frame: true,
|
||||
webPreferences: {
|
||||
preload: resolve(__dirname, "./preload.js"),
|
||||
},
|
||||
});
|
||||
|
||||
this.storeService.onDidChange("openInNewWindow", this.handleNewWindowsVal);
|
||||
this.storeService.onDidAnyChange(this.storeChange);
|
||||
}
|
||||
|
||||
storeChange = (store: any) => {
|
||||
if (!this.window) return;
|
||||
// 向所有窗口发送通知
|
||||
this.window.webContents.send("store-change", store);
|
||||
};
|
||||
|
||||
handleNewWindowsVal = (newValue: any) => {
|
||||
if (!this.window) return;
|
||||
|
||||
// 向所有窗口发送通知
|
||||
if (newValue === false) {
|
||||
if (this.window && !this.window.isDestroyed()) {
|
||||
this.window.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleResize = () => {
|
||||
if (!this.window) return;
|
||||
|
||||
const bounds = this.window.getBounds();
|
||||
this.storeService.set("browserBounds", _.omit(bounds, ["x", "y"]));
|
||||
};
|
||||
|
||||
showWindow = () => {
|
||||
if (!this.window) {
|
||||
this.window = this.create();
|
||||
this.window.on("resized", this.handleResize);
|
||||
}
|
||||
|
||||
this.window.show();
|
||||
isDev && this.window.webContents.openDevTools();
|
||||
|
||||
const browserBounds = this.storeService.get("browserBounds");
|
||||
if (browserBounds) {
|
||||
this.window.setBounds(browserBounds);
|
||||
}
|
||||
};
|
||||
|
||||
hideWindow = () => {
|
||||
if (!this.window) return;
|
||||
|
||||
this.window.close();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Menu, Notification, app } from "electron";
|
||||
import isDev from "electron-is-dev";
|
||||
import { inject, injectable } from "inversify";
|
||||
import { resolve } from "path";
|
||||
import { TYPES } from "../types";
|
||||
import {
|
||||
DownloadProgress,
|
||||
DownloadService,
|
||||
LoggerService,
|
||||
MainWindowService,
|
||||
StoreService,
|
||||
VideoRepository,
|
||||
} from "../interfaces";
|
||||
import { event } from "helper/utils";
|
||||
import _ from "lodash";
|
||||
import Window from "./window";
|
||||
|
||||
@injectable()
|
||||
export default class MainWindowServiceImpl
|
||||
extends Window
|
||||
implements MainWindowService
|
||||
{
|
||||
url = isDev ? "http://localhost:8555/" : "mediago://index.html/";
|
||||
constructor(
|
||||
@inject(TYPES.LoggerService)
|
||||
private readonly logger: LoggerService,
|
||||
@inject(TYPES.DownloadService)
|
||||
private readonly downloadService: DownloadService,
|
||||
@inject(TYPES.VideoRepository)
|
||||
private readonly videoRepository: VideoRepository,
|
||||
@inject(TYPES.StoreService)
|
||||
private readonly storeService: StoreService
|
||||
) {
|
||||
super({
|
||||
width: 1100,
|
||||
minWidth: 1100,
|
||||
height: 680,
|
||||
minHeight: 680,
|
||||
show: false,
|
||||
frame: true,
|
||||
webPreferences: {
|
||||
preload: resolve(__dirname, "./preload.js"),
|
||||
},
|
||||
});
|
||||
|
||||
this.downloadService.on("download-ready-start", this.onDownloadReadyStart);
|
||||
this.downloadService.on("download-progress", this.onDownloadProgress);
|
||||
this.downloadService.on("download-success", this.onDownloadSuccess);
|
||||
this.downloadService.on("download-failed", this.onDownloadFailed);
|
||||
this.downloadService.on("download-start", this.onDownloadStart);
|
||||
this.downloadService.on("download-stop", this.onDownloadStart);
|
||||
this.storeService.onDidAnyChange(this.storeChange);
|
||||
app.on("second-instance", this.secondInstance);
|
||||
}
|
||||
|
||||
onDownloadReadyStart = ({ id, isLive }: { id: number; isLive: boolean }) => {
|
||||
this.videoRepository.changeVideoIsLive(id, isLive);
|
||||
this.send("change-video-is-live", { id, isLive });
|
||||
};
|
||||
|
||||
init(): void {
|
||||
if (this.window) {
|
||||
// 如果窗口已经存在,则直接显示
|
||||
this.window.show();
|
||||
return;
|
||||
}
|
||||
|
||||
Menu.setApplicationMenu(null);
|
||||
|
||||
this.window = this.create();
|
||||
|
||||
const mainBounds = this.storeService.get("mainBounds");
|
||||
if (mainBounds) {
|
||||
this.window.setBounds(mainBounds);
|
||||
}
|
||||
|
||||
// 处理当前窗口改变大小
|
||||
this.window.on("resized", this.handleResize);
|
||||
}
|
||||
|
||||
handleResize = () => {
|
||||
if (!this.window) return;
|
||||
|
||||
const bounds = this.window.getBounds();
|
||||
this.storeService.set("mainBounds", _.omit(bounds, ["x", "y"]));
|
||||
};
|
||||
|
||||
secondInstance = () => {
|
||||
if (!this.window) return;
|
||||
if (process.platform === "win32") {
|
||||
if (this.window) {
|
||||
if (this.window.isMinimized()) {
|
||||
this.window.restore();
|
||||
}
|
||||
if (this.window.isVisible()) {
|
||||
this.window.focus();
|
||||
} else {
|
||||
this.window.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
storeChange = (store: any) => {
|
||||
// 向所有窗口发送通知
|
||||
this.send("store-change", store);
|
||||
};
|
||||
|
||||
onDownloadProgress = (progress: DownloadProgress) => {
|
||||
this.send("download-progress", progress);
|
||||
};
|
||||
|
||||
onDownloadSuccess = async (id: number) => {
|
||||
const promptTone = this.storeService.get("promptTone");
|
||||
if (promptTone) {
|
||||
const video = await this.videoRepository.findVideo(id);
|
||||
|
||||
new Notification({
|
||||
title: "下载成功",
|
||||
body: `${video?.name} 下载成功`,
|
||||
}).show();
|
||||
}
|
||||
|
||||
this.send("download-success", id);
|
||||
};
|
||||
|
||||
onDownloadFailed = async (id: number, err: any) => {
|
||||
const promptTone = this.storeService.get("promptTone");
|
||||
if (promptTone) {
|
||||
const video = await this.videoRepository.findVideo(id);
|
||||
|
||||
new Notification({
|
||||
title: "下载失败",
|
||||
body: `${video?.name} 下载失败`,
|
||||
}).show();
|
||||
}
|
||||
this.logger.error("下载失败:", err);
|
||||
this.send("download-failed", id);
|
||||
};
|
||||
|
||||
onDownloadStart = async (id: number) => {
|
||||
this.send("download-start", id);
|
||||
};
|
||||
|
||||
onDownloadStop = async (id: number) => {
|
||||
this.send("download-stop", id);
|
||||
};
|
||||
|
||||
send(channel: string, ...args: any[]) {
|
||||
if (!this.window) return;
|
||||
|
||||
this.window.webContents.send(channel, ...args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import isDev from "electron-is-dev";
|
||||
import { inject, 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
|
||||
{
|
||||
url = isDev ? "http://localhost:8555/player" : "mediago://index.html/player";
|
||||
|
||||
constructor(
|
||||
@inject(TYPES.VideoRepository)
|
||||
private readonly videoRepository: VideoRepository
|
||||
) {
|
||||
super({
|
||||
width: 1100,
|
||||
minWidth: 1100,
|
||||
height: 680,
|
||||
minHeight: 680,
|
||||
show: false,
|
||||
frame: true,
|
||||
webPreferences: {
|
||||
preload: resolve(__dirname, "./preload.js"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openWindow = async (id: number) => {
|
||||
if (!this.window) {
|
||||
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();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { BrowserWindow, BrowserWindowConstructorOptions } from "electron";
|
||||
import isDev from "electron-is-dev";
|
||||
|
||||
export default class Window {
|
||||
window: BrowserWindow | null = null;
|
||||
options: BrowserWindowConstructorOptions;
|
||||
url: string;
|
||||
|
||||
constructor(options: BrowserWindowConstructorOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
create() {
|
||||
if (!this.url) {
|
||||
throw new Error("url is required");
|
||||
}
|
||||
|
||||
const window = new BrowserWindow(this.options);
|
||||
void window.loadURL(this.url);
|
||||
|
||||
window.once("ready-to-show", this.readyToShow);
|
||||
window.on("close", this.windowClose);
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
readyToShow = () => {
|
||||
if (!this.window) return;
|
||||
|
||||
this.window.show();
|
||||
isDev && this.window.webContents.openDevTools();
|
||||
};
|
||||
|
||||
windowClose = () => {
|
||||
if (!this.window) return;
|
||||
|
||||
// 防止 webview 同时被销毁
|
||||
this.window.setBrowserView(null);
|
||||
|
||||
// 销毁窗口
|
||||
this.window = null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2015",
|
||||
"module": "CommonJS",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": "./src",
|
||||
"resolveJsonModule": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"moduleResolution": "Node",
|
||||
"strictPropertyInitialization": false,
|
||||
"typeRoots": ["node_modules/@types"],
|
||||
"outDir": "app/output/build/main"
|
||||
},
|
||||
"include": ["./src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/* eslint-env node */
|
||||
require("@rushstack/eslint-patch/modern-module-resolution");
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
"plugin:vue/vue3-essential",
|
||||
"eslint:recommended",
|
||||
"@vue/eslint-config-typescript",
|
||||
"@vue/eslint-config-prettier/skip-formatting",
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
},
|
||||
rules: {
|
||||
"prettier/prettier": "error",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
test-results/
|
||||
playwright-report/
|
||||