Compare commits

...

18 Commits

Author SHA1 Message Date
士子☀️ c8ac4182fc Merge pull request #34 from caorushizi/release/v2.0.2 2023-07-09 13:25:36 +08:00
士子☀️ 55b70d3e6d release: v2.0.2 2023-07-09 13:24:06 +08:00
士子☀️ a19765b0d3 fix: browser loading status. 2023-07-08 23:05:13 +08:00
士子☀️ 5c7b65385a remove postinstall 2023-07-08 16:34:34 +08:00
caorushizi b6b84269a3 fix: mac app. 2023-07-08 15:55:12 +08:00
士子☀️ e44cb249d0 Merge pull request #30 from caorushizi/feat/v2.0.2 2023-07-08 12:11:51 +08:00
士子☀️ 8bea071865 release size. 2023-07-08 12:00:50 +08:00
caorushizi a782652fca fix: tray icon. 2023-07-06 22:49:11 +08:00
caorushizi 3a02803d3c fix: mac download. 2023-07-06 22:16:28 +08:00
士子☀️ 263a08ab4c Merge pull request #29 from caorushizi/feat/v2.0.2 2023-07-06 00:32:55 +08:00
士子☀️ ecdbd985ce feat: tray context menu. 2023-07-06 00:32:05 +08:00
士子☀️ dd403564cc docs: addition function. 2023-07-06 00:15:57 +08:00
士子☀️ 69d741b39a Merge pull request #28 from caorushizi/dev 2023-07-05 23:53:59 +08:00
士子☀️ 4c9c2e2d68 add: tray icon. 2023-07-05 23:52:38 +08:00
士子☀️ 8660701340 download list. 2023-07-05 22:48:41 +08:00
士子☀️ 341c5570af support live mode. 2023-07-05 01:13:29 +08:00
士子☀️ ee12753818 Update README.md 2023-07-04 12:56:52 +08:00
士子☀️ 055638278c feat: something 2023-07-04 07:52:43 +08:00
53 changed files with 545 additions and 248 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
APP_NAME=media-downloader
APP_ID=mediago.ziying.site
APP_COPYRIGHT=caorushizi
APP_VERSION=2.0.1
APP_VERSION=2.0.2
APP_SERVER_PORT=8433
+5
View File
@@ -47,3 +47,8 @@ jobs:
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-*
+2 -2
View File
@@ -1,3 +1,3 @@
registry=https://registry.npmmirror.com/
electron_mirror=https://npm.taobao.org/mirrors/electron/
registry=https://registry.npmmirror.com
electron_mirror=https://npmmirror.com/mirrors/electron/
package-manager=pnpm
+1 -5
View File
@@ -1,16 +1,12 @@
# media-downloader
m3u8 视频在线提取工具 流媒体下载 m3u8 下载 桌面客户端 windows mac。
可以直接在线获取 m3u8 链接地址,无需使用使用网络抓包,无需安装浏览器插件,可以直接带出请求标头……
模板: <https://github.com/caorushizi/electron-template>
可以直接在线获取 m3u8 链接地址,无需使用使用网络抓包,无需安装浏览器插件,可以直接带出请求标头……详情请参考[使用帮助](https://downloader.caorushizi.cn/guides.html?form=github)
## 上手指南
以下指南将帮助你在本地机器上安装和运行该项目,进行开发和测试。关于如何将该项目部署到在线环境,请参考部署小节。
【使用帮助】[blog.ziying.site](https://downloader.caorushizi.cn/guides.html?form=github)
## 安装要求
运行代码需要 node 和 pnpm,node 需要在官网下载安装,pnpm 可以通过`npm i -g pnpm`安装。
+21 -14
View File
@@ -1,24 +1,31 @@
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: [
["link", { rel: "shortcut icon", href: "/favicon.svg" }],
[
"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);
})();`,
],
],
head,
themeConfig: {
// https://vitepress.dev/reference/default-theme-config
nav: [
+21 -1
View File
@@ -67,7 +67,27 @@ outline: deep
![step 3](./images/guides-step3.png)
### 开始你的视频下载吧
## 附加功能
1. 下载完成后可以在下载列表中点击转换音频按钮将视频转换为音频
![step 1](./images/addition-step1.png)
2. 下载完成后播放视频
- PC 播放
![step 2](./images/addition-step2.png)
- 移动端播放
![step 3](./images/addition-step3.png)
3. 批量下载
![step 3](./images/addition-step4.png)
## 开始你的视频下载吧
是不是很简单,快去下载你的视频吧
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+6 -3
View File
@@ -4,10 +4,13 @@
"description": "electron-template",
"main": "index.js",
"scripts": {
"dev": "pnpm --parallel -F \"./packages/*\" run dev",
"build": "pnpm --parallel -F \"./packages/*\" run build",
"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 media-downloader run release",
"release": "pnpm run build && pnpm -F main run release",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"
+1
View File
@@ -3,3 +3,4 @@ node_modules
build
dist
bin/Logs
release
+4
View File
@@ -0,0 +1,4 @@
node_modules
output
bin
mobile
+13
View File
@@ -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"
}
}
+2 -3
View File
@@ -1,10 +1,9 @@
{
"name": "media-downloader",
"version": "2.0.1",
"name": "main",
"version": "0.1.0",
"description": "在线视频下载器",
"main": "main/index.js",
"scripts": {
"postinstall": "electron-builder install-app-deps",
"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",
+17 -5
View File
@@ -1,11 +1,23 @@
import * as esbuild from "esbuild";
import { rmSync } from "node:fs";
import { mainResolve, loadDotEnvDefined } from "./utils.mjs";
import {
mainResolve,
loadDotEnvDefined,
copyResource,
removeResource,
} from "./utils.mjs";
const mainDefined = loadDotEnvDefined();
rmSync(mainResolve("build"), { recursive: true, force: true });
rmSync(mainResolve("dist"), { recursive: true, force: true });
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: [
@@ -28,7 +40,7 @@ esbuild.build({
"process.env.NODE_ENV": '"production"',
...mainDefined,
},
outdir: mainResolve("build/main"),
outdir: mainResolve("app/build/main"),
loader: { ".png": "file" },
minify: true,
});
+15 -19
View File
@@ -1,9 +1,8 @@
import { spawn } from "child_process";
import { cpSync } from "node:fs";
import electron from "electron";
import * as esbuild from "esbuild";
import chokidar from "chokidar";
import { loadDotEnvRuntime, mainResolve, log } from "./utils.mjs";
import { loadDotEnvRuntime, mainResolve, log, copyResource } from "./utils.mjs";
let electronProcess = null;
@@ -11,21 +10,18 @@ process.env.NODE_ENV = "development";
loadDotEnvRuntime();
async function copySource() {
const oldSqlite3Path = mainResolve(
"node_modules/better-sqlite3/build/Release"
);
const newSqlite3Path = mainResolve("build/Release");
const path = "build/Release/better_sqlite3.node";
cpSync(oldSqlite3Path, newSqlite3Path, {
recursive: true,
});
const oldBinPath = mainResolve("bin");
const newBinPath = mainResolve("build/bin");
cpSync(oldBinPath, newBinPath, {
recursive: true,
});
copyResource([
{
from: mainResolve("node_modules/better-sqlite3", path),
to: mainResolve("app", path),
},
{
from: mainResolve("bin"),
to: mainResolve("app/bin"),
},
]);
}
const ctx = await esbuild.context({
@@ -47,10 +43,10 @@ const ctx = await esbuild.context({
],
define: {
// 开发环境中二进制可执行文件的路径
__bin__: `"${mainResolve("bin").replace(/\\/g, "\\\\")}"`,
__bin__: `"${mainResolve("bin", process.platform).replace(/\\/g, "\\\\")}"`,
},
plugins: [],
outdir: mainResolve("build/main"),
outdir: mainResolve("app/build/main"),
loader: { ".png": "file" },
});
@@ -71,7 +67,7 @@ watcher.on("change", async () => {
});
function startElectron() {
const args = ["--inspect=5858", mainResolve("build/main/index.js")];
const args = ["--inspect=5858", mainResolve("app/build/main/index.js")];
electronProcess = spawn(String(electron), args);
+18 -21
View File
@@ -1,9 +1,16 @@
import builder from "electron-builder";
import { readFileSync } from "node:fs";
import semver from "semver";
import { mainResolve, loadDotEnvRuntime, log } from "./utils.mjs";
import {
mainResolve,
loadDotEnvRuntime,
log,
removeResource,
} from "./utils.mjs";
const packageJson = JSON.parse(readFileSync(mainResolve("./package.json")));
removeResource([mainResolve("release")]);
const packageJson = JSON.parse(readFileSync(mainResolve("./app/package.json")));
loadDotEnvRuntime();
if (semver.neq(process.env.APP_VERSION, packageJson.version)) {
@@ -11,20 +18,6 @@ if (semver.neq(process.env.APP_VERSION, packageJson.version)) {
process.exit(0);
}
const extraResources = [
{
from: "build/mobile",
to: "mobile",
},
];
if (process.platform === "win32") {
// windows
extraResources.push("bin/ffmpeg.exe", "bin/N_m3u8DL-CLI_v3.0.2.exe");
} else {
// mac
extraResources.push("bin/N_m3u8DL-RE", "bin/ffmpeg");
}
// Let's get that intellisense working
/**
* @type {import('electron-builder').Configuration}
@@ -37,21 +30,25 @@ const options = {
copyright: process.env.APP_COPYRIGHT,
artifactName: "${productName}-setup-${buildVersion}.${ext}",
directories: {
output: "./dist",
output: "./release",
},
files: [
{
from: "./build",
to: "./",
filter: ["**/*"],
},
"./package.json",
],
extraResources: [
{
from: "./node_modules/better-sqlite3/build/Release",
to: "./build/Release",
from: "./app/mobile",
to: "mobile",
},
{
from: "./app/bin/${platform}/",
to: "bin",
},
],
extraResources,
win: {
icon: "../assets/icon.ico",
target: [
+18 -3
View File
@@ -1,4 +1,4 @@
import { existsSync } from "node:fs";
import { existsSync, cpSync, rmSync } from "node:fs";
import dotenv from "dotenv";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -8,8 +8,8 @@ const con = console;
export const log = con.log;
const __dirname = dirname(fileURLToPath(import.meta.url));
export const mainResolve = (r) => resolve(__dirname, "..", r);
export const rootResolve = (r) => resolve(__dirname, "../../..", r);
export const mainResolve = (...r) => resolve(__dirname, "..", ...r);
export const rootResolve = (...r) => resolve(__dirname, "../../..", ...r);
const nodeEnv = process.env.NODE_ENV;
log("当前的环境是: ", nodeEnv);
@@ -63,3 +63,18 @@ export function loadDotEnvDefined() {
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 });
});
}
+20 -1
View File
@@ -14,7 +14,9 @@ import {
StoreService,
} from "./interfaces";
import { TYPES } from "./types";
import { app, nativeTheme } from "electron";
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 {
@@ -60,6 +62,8 @@ export default class ElectronApp implements App {
this.initAppTheme();
this.resetDownloadStatus();
this.initTray();
}
initAppTheme(): void {
@@ -67,6 +71,21 @@ export default class ElectronApp implements App {
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 状态的数据, 全部重置为失败
@@ -6,6 +6,7 @@ import {
MenuItemConstructorOptions,
nativeTheme,
shell,
clipboard,
} from "electron";
import { Favorite } from "entity/Favorite";
import { convertToAudio } from "helper";
@@ -175,7 +176,14 @@ export default class HomeController implements Controller {
payload: id,
});
};
const item = await this.videoRepository.findVideo(id);
const template: Array<MenuItemConstructorOptions | MenuItem> = [
{
label: "拷贝链接地址",
click: () => {
clipboard.writeText(item?.url || "");
},
},
{
label: "选择",
click: () => {
+7
View File
@@ -32,6 +32,13 @@ export class Video {
})
headers: string;
@Column({
type: "boolean",
default: false,
nullable: false,
})
isLive: boolean;
@Column({
type: "text",
nullable: false,
+88 -22
View File
@@ -1,7 +1,7 @@
import { execa } from "execa";
import { macDownloaderPath, winDownloaderPath } from "./variables";
import iconv from "iconv-lite";
import { event, formatHeaders, formatString, stripColors } from "./utils";
import { formatHeaders, stripColors } from "./utils";
import { DownloadParams, DownloadProgress } from "interfaces";
export const spawnDownload = (params: DownloadParams): Promise<void> => {
@@ -13,17 +13,22 @@ export const spawnDownload = (params: DownloadParams): Promise<void> => {
};
const winSpawnDownload = async (params: DownloadParams): Promise<void> => {
const { id, abortSignal, url, local, name, deleteSegments, headers } = params;
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 = [
formatString(url),
"--workDir",
formatString(local),
"--saveName",
formatString(name),
];
const spawnParams = [url, "--workDir", local, "--saveName", name];
if (headers) {
spawnParams.push("--headers", formatHeaders(headers));
@@ -34,11 +39,10 @@ const winSpawnDownload = async (params: DownloadParams): Promise<void> => {
}
const downloader = execa(winDownloaderPath, spawnParams, {
detached: true,
shell: true,
signal: abortSignal.signal,
});
let isLive = false;
downloader.stdout?.on("data", (data) => {
const str = iconv.decode(Buffer.from(data), "gbk");
str.split("\n").forEach((item) => {
@@ -46,15 +50,33 @@ const winSpawnDownload = async (params: DownloadParams): Promise<void> => {
return;
}
process.env.NODE_ENV === "development" && console.log(item);
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, cur, total, speed };
event.emit("download-progress", progress);
const progress: DownloadProgress = {
id,
type: "progress",
cur,
total,
speed,
isLive,
};
callback(progress);
});
});
@@ -73,8 +95,21 @@ const winSpawnDownload = async (params: DownloadParams): Promise<void> => {
};
const macSpawnDownload = (params: DownloadParams): Promise<void> => {
const { id, abortSignal, url, local, name, deleteSegments, headers } = params;
const progressReg = /([\d.]+)% .*? ([\d.\w]+?) /g;
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 = [
@@ -85,29 +120,52 @@ const macSpawnDownload = (params: DownloadParams): Promise<void> => {
local,
"--save-name",
name,
"--auto-select",
];
if (headers) {
spawnParams.push("--headers", formatHeaders(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");
}
console.log("spawnParams", spawnParams);
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;
}
process.env.NODE_ENV === "development" && console.log(item);
const result = progressReg.exec(stripColors(item));
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;
}
@@ -119,8 +177,16 @@ const macSpawnDownload = (params: DownloadParams): Promise<void> => {
}
const total = "1000";
const progress: DownloadProgress = { id, cur, total, speed };
event.emit("download-progress", progress);
// FIXME: 无法获取是否为直播流
const progress: DownloadProgress = {
id,
type: "progress",
cur,
total,
speed,
isLive,
};
callback(progress);
});
});
+13
View File
@@ -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;
+1
View File
@@ -28,3 +28,4 @@ export * from "./variables";
export { on, handle } from "./decorator";
export { spawnDownload } from "./download";
export { convertToAudio } from "./ffmpeg";
export { default as http } from "./http";
+1 -5
View File
@@ -18,11 +18,7 @@ export function formatHeaders(headersStr: string): string {
const formatted = Object.entries(headers)
.map(([key, value]) => `${key}:${value}`)
.join("|");
return formatString(formatted);
}
export function formatString(str: string) {
return JSON.stringify(str);
return formatted;
}
export const event = new EventEmitter();
+4 -5
View File
@@ -15,15 +15,14 @@ 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_v3.0.2.exe"
);
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 = path.resolve(app.getAppPath(), "../mobile");
export const mobilePath = isDev
? path.resolve(app.getAppPath(), "../../mobile")
: path.resolve(app.getAppPath(), "../mobile");
// user agent
export const pcUA =
+7
View File
@@ -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";
+1 -3
View File
@@ -25,9 +25,7 @@ const start = async (): Promise<void> => {
mediago.init();
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
// empty
});
};
+8
View File
@@ -93,6 +93,11 @@ export interface VideoRepository {
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 {
@@ -145,9 +150,11 @@ export type Task = {
export interface DownloadProgress {
id: number;
type: string;
cur: string;
total: string;
speed: string;
isLive: boolean;
}
export interface DownloadParams {
@@ -158,6 +165,7 @@ export interface DownloadParams {
headers: string;
abortSignal: AbortController;
deleteSegments?: boolean;
callback: (progress: DownloadProgress) => void;
}
export interface DevToolsService {
+3 -3
View File
@@ -16,7 +16,7 @@ declare interface EnvPath {
declare interface BrowserWindowInitialVal {
url?: string;
sourceList?: LinkMessage[];
sourceList?: WebSource[];
}
declare interface ElectronAPI {
@@ -60,7 +60,7 @@ declare interface ElectronAPI {
setUserAgent: (isMobile: boolean) => Promise<void>;
}
declare interface LinkMessage {
declare interface WebSource {
url: string;
name: string;
headers: string;
@@ -94,5 +94,5 @@ declare interface AppStore {
declare interface BrowserStore {
url: string;
sourceList: LinkMessage[];
sourceList: WebSource[];
}
@@ -96,6 +96,16 @@ export default class VideoRepositoryImpl implements VideoRepository {
.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: {
@@ -107,4 +117,10 @@ export default class VideoRepositoryImpl implements VideoRepository {
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,
});
}
}
@@ -70,6 +70,16 @@ export default class DownloadServiceImpl
...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`);
@@ -97,11 +107,8 @@ export default class DownloadServiceImpl
this.emit("download-failed", task.id, err);
}
} finally {
// 处理当前正在活动的任务
const doneId = this.active.findIndex((i) => i.id === task.id);
this.active.splice(doneId, 1);
// 处理完成的任务
this.runTask();
this.removeTask(task.id);
// 传输完成
if (this.queue.length === 0 && this.active.length === 0) {
// this.emit("download-finish");
@@ -109,6 +116,16 @@ export default class DownloadServiceImpl
}
}
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();
@@ -10,14 +10,18 @@ export default class UpdateServiceImpl implements UpdateService {
@inject(TYPES.LoggerService) private readonly logger: LoggerService
) {}
init(): void {
async init(): Promise<void> {
if (isDev) return;
autoUpdater.disableWebInstaller = true;
autoUpdater.logger = this.logger.logger;
autoUpdater.checkForUpdatesAndNotify({
title: "自动更新完成",
body: "下次重启时将会自动安装",
});
try {
autoUpdater.disableWebInstaller = true;
autoUpdater.logger = this.logger.logger;
await autoUpdater.checkForUpdatesAndNotify({
title: "自动更新完成",
body: "下次重启时将会自动安装",
});
} catch (e) {
this.logger.info("update error", e);
}
}
}
+6 -8
View File
@@ -44,14 +44,12 @@ export default class WebServiceImpl implements WebService {
this.app.use(cors());
this.app.use(range);
this.app.use(serve(local));
if (process.env.NODE_ENV === "production") {
this.app.use(
serve(mobilePath, {
extensions: ["html", "js", "css"],
index: "index.html",
})
);
}
this.app.use(
serve(mobilePath, {
extensions: ["html", "js", "css"],
index: "index.html",
})
);
this.app.use(this.router.routes());
this.app.use(this.router.allowedMethods());
@@ -19,13 +19,14 @@ import { PERSIST_WEBVIEW, mobileUA, pcUA } from "helper/variables";
import { ElectronBlocker } from "@cliqz/adblocker-electron";
import fetch from "cross-fetch";
import path from "path";
import { LinkMessage } from "main";
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)
@@ -58,6 +59,7 @@ export default class WebviewServiceImpl implements WebviewService {
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 });
@@ -114,9 +116,11 @@ export default class WebviewServiceImpl implements WebviewService {
await this.view.webContents.loadURL(url || "");
} catch (err: unknown) {
this.logger.error("加载 url 时出现错误: ", err);
}
if (!canGoBack && !isNewWindow) {
this.view.webContents.clearHistory();
throw err;
} finally {
if (!canGoBack && !isNewWindow) {
this.view.webContents.clearHistory();
}
}
}
@@ -221,34 +225,44 @@ export default class WebviewServiceImpl implements WebviewService {
const detailsUrl = new URL(url);
if (sourceReg.test(detailsUrl.pathname)) {
this.handleM3u8(details);
try {
this.handleM3u8(details);
} catch (e) {
// empty
}
}
callback({});
};
handleM3u8 = (details: OnBeforeSendHeadersListenerDetails): void => {
handleM3u8 = async (
details: OnBeforeSendHeadersListenerDetails
): Promise<void> => {
const { id, url } = details;
this.logger.info(`在窗口中捕获 m3u8 链接: ${url} id: ${id}`);
const webContents = details.webContents;
const linkMessage: LinkMessage = {
const source: WebSource = {
url,
name: webContents?.getTitle() || "没有获取到名称",
headers: JSON.stringify(details.requestHeaders),
};
// 这里需要判断是否使用浏览器插件
const useExtension = this.storeService.get("useExtension");
if (useExtension) {
this.view.webContents.send("webview-link-message", linkMessage);
} else {
this.videoRepository.addVideo(linkMessage).then((item) => {
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
);
});
}
}
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 625 B

+3 -3
View File
@@ -1,4 +1,4 @@
import { LinkMessage } from "main";
import { WebSource } from "main";
import { IpcRendererEvent, ipcRenderer } from "electron/renderer";
// 创建浮窗容器元素
@@ -44,10 +44,10 @@ overflow: hidden;
text-overflow: ellipsis;
`;
const items: LinkMessage[] = [];
const items: WebSource[] = [];
ipcRenderer.on(
"webview-link-message",
(e: IpcRendererEvent, data: LinkMessage) => {
(e: IpcRendererEvent, data: WebSource) => {
floatingContainer.style.display = "block";
items.push(data);
+18 -17
View File
@@ -43,7 +43,8 @@ export default class MainWindowServiceImpl
},
});
event.on("download-progress", this.onDownloadProgress);
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);
@@ -52,6 +53,11 @@ export default class MainWindowServiceImpl
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) {
// 如果窗口已经存在,则直接显示
@@ -96,20 +102,15 @@ export default class MainWindowServiceImpl
};
storeChange = (store: any) => {
if (!this.window) return;
// 向所有窗口发送通知
this.window.webContents.send("store-change", store);
this.send("store-change", store);
};
onDownloadProgress = (progress: DownloadProgress) => {
if (!this.window) return;
this.window.webContents.send("download-progress", progress);
this.send("download-progress", progress);
};
onDownloadSuccess = async (id: number) => {
if (!this.window) return;
const promptTone = this.storeService.get("promptTone");
if (promptTone) {
const video = await this.videoRepository.findVideo(id);
@@ -120,12 +121,10 @@ export default class MainWindowServiceImpl
}).show();
}
this.window.webContents.send("download-success", id);
this.send("download-success", id);
};
onDownloadFailed = async (id: number, err: any) => {
if (!this.window) return;
const promptTone = this.storeService.get("promptTone");
if (promptTone) {
const video = await this.videoRepository.findVideo(id);
@@ -136,18 +135,20 @@ export default class MainWindowServiceImpl
}).show();
}
this.logger.error("下载失败:", err);
this.window.webContents.send("download-failed", id);
this.send("download-failed", id);
};
onDownloadStart = async (id: number) => {
if (!this.window) return;
this.window.webContents.send("download-start", id);
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("download-stop", id);
};
this.window.webContents.send(channel, ...args);
}
}
+2 -2
View File
@@ -10,10 +10,10 @@
"resolveJsonModule": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"moduleResolution": "node",
"moduleResolution": "Node",
"strictPropertyInitialization": false,
"typeRoots": ["node_modules/@types"],
"outDir": "build/main"
"outDir": "app/output/build/main"
},
"include": ["./src/**/*"]
}
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite",
"dev:mobile": "vite",
"build": "run-p type-check build-only",
"preview": "vite preview",
"test:unit": "vitest",
View File
+1 -5
View File
@@ -15,11 +15,7 @@ const player = ref<Player | null>(null);
const list = ref<VideoData[]>([]);
onMounted(async () => {
let baseUrl = location.href;
if (import.meta.env.MODE === "development") {
baseUrl = `http://${location.hostname}:8433/`;
}
const res = await axios.get(`${baseUrl}api/video-list`);
const res = await axios.get(`/api/video-list`);
if (videoRef.value) {
const options: IPlayerOptions = {
el: videoRef.value,
+1 -1
View File
@@ -27,7 +27,7 @@ export default defineConfig({
port: 8556,
},
build: {
outDir: path.resolve(__dirname, "../main/build/mobile"),
outDir: path.resolve(__dirname, "../main/app/mobile"),
emptyOutDir: true,
},
});
+2 -2
View File
@@ -29,8 +29,8 @@
},
"devDependencies": {
"@types/node": "^20.3.1",
"@types/react": "^18.2.13",
"@types/react-dom": "^18.2.6",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/sort-by": "^1.2.0",
"@typescript-eslint/eslint-plugin": "^5.60.0",
"@typescript-eslint/parser": "5.60.0",
+18 -9
View File
@@ -11,8 +11,8 @@ import {
Popover,
QRCode,
Dropdown,
Typography,
} from "antd";
import type { MenuProps } from "antd";
import "./index.scss";
import PageContainer from "../../components/PageContainer";
import { useAsyncEffect, usePagination } from "ahooks";
@@ -28,12 +28,12 @@ import {
SyncOutlined,
MobileOutlined,
MoreOutlined,
UserOutlined,
} from "@ant-design/icons";
import { useDispatch, useSelector } from "react-redux";
import { selectAppStore } from "../../store";
import { tdApp } from "../../utils";
import { increase } from "../../store/downloadSlice";
const { Text } = Typography;
enum DownloadFilter {
list = "list",
@@ -83,10 +83,8 @@ const HomePage: FC = () => {
const [baseUrl, setBaseUrl] = useState("");
useAsyncEffect(async () => {
const isDev = import.meta.env.MODE === "development";
const localIP = await getLocalIP();
const port = isDev ? 8556 : import.meta.env.APP_SERVER_PORT;
setBaseUrl(`http://${localIP}:${port}/`);
setBaseUrl(`http://${localIP}:${import.meta.env.APP_SERVER_PORT}/`);
}, []);
const onDownloadProgress = (e: any, progress: DownloadProgress) => {
@@ -132,6 +130,10 @@ const HomePage: FC = () => {
refresh();
};
const onChangeVideoIsLive = () => {
refresh();
};
useEffect(() => {
rendererEvent("download-progress", onDownloadProgress);
rendererEvent("download-success", onDownloadSuccess);
@@ -139,6 +141,7 @@ const HomePage: FC = () => {
rendererEvent("download-start", onDownloadStart);
rendererEvent("download-item-event", onDownloadMenuEvent);
rendererEvent("download-item-notifier", onReceiveDownloadItem);
rendererEvent("change-video-is-live", onChangeVideoIsLive);
return () => {
removeEventListener("download-progress", onDownloadProgress);
@@ -147,6 +150,7 @@ const HomePage: FC = () => {
removeEventListener("download-start", onDownloadStart);
removeEventListener("download-item-event", onDownloadMenuEvent);
removeEventListener("download-item-notifier", onReceiveDownloadItem);
removeEventListener("change-video-is-live", onChangeVideoIsLive);
};
}, []);
@@ -324,12 +328,12 @@ const HomePage: FC = () => {
label: "播放视频",
key: "play",
icon: <PlayCircleOutlined />,
disabled: curConverting,
},
{
label: "转换为音频",
key: "convert",
icon: <SyncOutlined />,
disabled: curConverting,
},
],
onClick: ({ key }) => {
@@ -362,10 +366,14 @@ const HomePage: FC = () => {
} else if (item.status === DownloadStatus.Stopped) {
tag = <Tag color="default"></Tag>;
}
return (
<Space>
{item.name}
{tag}
<Text>{item.name}</Text>
<Space size={[0, 8]}>
{tag}
{item.isLive && <Tag color={"default"}></Tag>}
</Space>
</Space>
);
};
@@ -490,6 +498,7 @@ const HomePage: FC = () => {
]}
/>
</ModalForm>
<Button onClick={() => openDir(appStore.local)}></Button>
</Space>
}
className="home-page"
@@ -44,7 +44,7 @@ const SettingPage: React.FC = () => {
}
};
const renderButtonLable = () => {
const renderButtonLabel = () => {
return (
<Button onClick={onSelectDir} icon={<FolderOpenOutlined />}>
@@ -52,30 +52,11 @@ const SettingPage: React.FC = () => {
);
};
const renderTooltipLable = () => {
const renderTooltipLabel = (label: string, tooltip: string) => {
return (
<div className="item-label">
<div className="item-label-text"></div>
<Tooltip
title={"该代理会对软件自带浏览器以及下载时生效"}
placement={"right"}
>
<QuestionCircleOutlined />
</Tooltip>
</div>
);
};
const renderExtensionLabel = () => {
return (
<div className="item-label">
<div className="item-label-text">使</div>
<Tooltip
title={
"开启后浏览器嗅探到的资源将不会直接显示在下载列表中,所有资源嗅探会交给浏览器插件处理"
}
placement={"right"}
>
<div className="item-label-text">{label}</div>
<Tooltip title={tooltip} placement={"right"}>
<QuestionCircleOutlined />
</Tooltip>
</div>
@@ -102,7 +83,7 @@ const SettingPage: React.FC = () => {
formRef={formRef}
layout="horizontal"
submitter={false}
labelCol={{ style: { width: "130px" } }}
labelCol={{ style: { width: "140px" } }}
labelAlign={"left"}
colon={false}
initialValues={settings}
@@ -115,7 +96,7 @@ const SettingPage: React.FC = () => {
disabled
name="local"
placeholder="请选择视频下载目录"
label={renderButtonLable()}
label={renderButtonLabel()}
/>
<ProFormSelect
name="theme"
@@ -139,7 +120,10 @@ const SettingPage: React.FC = () => {
/>
<ProFormSwitch
name="useProxy"
label={renderTooltipLable()}
label={renderTooltipLabel(
"代理开关",
"该代理会对软件自带浏览器以及下载时生效"
)}
rules={[
({ getFieldValue, setFieldValue }) => ({
validator() {
@@ -154,12 +138,21 @@ const SettingPage: React.FC = () => {
/>
<ProFormSwitch label="开启广告过滤" name="blockAds" />
<ProFormSwitch label="以手机模式进入" name="isMobile" />
<ProFormSwitch label={renderExtensionLabel()} name="useExtension" />
<ProFormSwitch
label={renderTooltipLabel(
"使用沉浸式嗅探",
"开启后沉浸式嗅探到的资源将不会直接显示在下载列表中,所有资源嗅探会交给浏览器插件处理"
)}
name="useExtension"
/>
</ProFormGroup>
<ProFormGroup title="下载设置" direction={"vertical"}>
<ProFormSwitch label="下载完成删除分片" name="deleteSegments" />
<ProFormDigit
label="最大同时下载数量"
label={renderTooltipLabel(
"最大同时下载数量",
"直播录制不占用同时下载数量"
)}
name="maxRunner"
min={1}
max={10}
@@ -15,8 +15,11 @@
overflow: auto;
.webview-container {
height: 100%;
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
.webview-inner {
height: 100%;
width: 100%;
@@ -13,7 +13,17 @@ import {
StarOutlined,
} from "@ant-design/icons";
import { useAsyncEffect, useRequest } from "ahooks";
import { Avatar, Button, Form, Input, List, message, Space } from "antd";
import {
Avatar,
Button,
Empty,
Form,
Input,
List,
message,
Space,
Spin,
} from "antd";
import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import PageContainer from "../../components/PageContainer";
@@ -22,6 +32,7 @@ import { generateUrl, getFavIcon } from "../../utils";
import "./index.scss";
import { ModalForm, ProFormText } from "@ant-design/pro-components";
import {
BrowserStatus,
PageMode,
selectBrowserStore,
setAppStore,
@@ -43,7 +54,6 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
rendererEvent,
removeEventListener,
webviewGoBack,
webviewReload,
webwiewGoHome,
onFavoriteItemContextMenu,
combineToHomePage,
@@ -67,13 +77,28 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
}, []);
const loadUrl = async (url: string) => {
await webviewLoadURL(url);
dispatch(
setBrowserStore({
url: url,
mode: PageMode.Browser,
})
);
try {
dispatch(
setBrowserStore({
mode: PageMode.Browser,
status: BrowserStatus.Loading,
})
);
await webviewLoadURL(url);
dispatch(
setBrowserStore({
url: url,
status: BrowserStatus.Loaded,
})
);
} catch (err) {
dispatch(
setBrowserStore({
status: BrowserStatus.Failed,
errMsg: (err as any).message,
})
);
}
};
const goto = async () => {
@@ -129,10 +154,6 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
);
};
const onClickReload = () => {
webviewReload();
};
const onClickEnter = async () => {
if (!store.url) {
return;
@@ -213,13 +234,15 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
// 渲染工具栏
const renderToolbar = () => {
const disabled =
store.status !== BrowserStatus.Loaded || store.mode !== PageMode.Browser;
return (
<Space.Compact className="action-bar" block>
<Button type="text" title="切换为手机模式" onClick={onSetDefaultUA}>
{appStore.isMobile ? <MobileFilled /> : <MobileOutlined />}
</Button>
<Button
disabled={store.mode === PageMode.Default}
disabled={disabled}
title="首页"
type="text"
onClick={onClickGoHome}
@@ -227,26 +250,21 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
<HomeOutlined />
</Button>
<Button
disabled={store.mode === PageMode.Default}
disabled={disabled}
title="回退"
type="text"
onClick={onClickGoBack}
>
<ArrowLeftOutlined />
</Button>
<Button
disabled={store.mode === PageMode.Default}
title="刷新"
type="text"
onClick={onClickReload}
>
<Button disabled={disabled} title="刷新" type="text" onClick={goto}>
<ReloadOutlined />
</Button>
<Button
type="text"
title={curIsFavorite ? "取消收藏" : "收藏"}
onClick={onClickAddFavorite}
disabled={store.mode === PageMode.Default}
disabled={disabled}
>
{curIsFavorite ? <StarFilled /> : <StarOutlined />}
</Button>
@@ -257,6 +275,9 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
const url = e.target.value;
dispatch(setBrowserStore({ url }));
}}
onFocus={(e) => {
e.target.select();
}}
onKeyDown={onInputKeyDown}
placeholder="请输入网址链接……"
/>
@@ -279,11 +300,24 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
// 渲染浏览器面板
const renderBrowserPanel = () => {
return (
<div className="webview-container">
<WebView className="webview-inner" />
</div>
);
let content = <div></div>;
if (store.status === BrowserStatus.Loading) {
content = <Spin />;
} else if (store.status === BrowserStatus.Failed) {
content = (
<Empty description={store.errMsg || "加载失败"}>
<Space>
<Button type="primary" onClick={onClickGoHome}>
</Button>
<Button onClick={goto}></Button>
</Space>
</Empty>
);
} else if (store.status === BrowserStatus.Loaded) {
content = <WebView className="webview-inner" />;
}
return <div className="webview-container">{content}</div>;
};
// 渲染收藏 item
+5 -1
View File
@@ -12,6 +12,7 @@ declare interface DownloadItem {
url: string;
headers: string;
status: string;
isLive: boolean;
}
declare interface VideoResponse {
@@ -73,7 +74,7 @@ declare interface Favorite {
icon?: string;
}
declare interface LinkMessage {
declare interface WebSource {
url: string;
name: string;
headers: string;
@@ -111,6 +112,8 @@ declare interface BrowserStore {
mode: PageMode;
url: string;
title: string;
status: BrowserStatus;
errMsg?: string;
}
declare interface DownloadProgress {
@@ -118,6 +121,7 @@ declare interface DownloadProgress {
cur: string;
total: string;
speed: string;
isLive: boolean;
}
interface ObjectConstructor {
@@ -9,10 +9,19 @@ export enum PageMode {
Browser = "browser",
}
export enum BrowserStatus {
Default = "default",
Loaded = "loaded",
Loading = "loading",
Failed = "failed",
}
const initialState: BrowserStore = {
mode: PageMode.Default,
url: "",
title: "",
status: BrowserStatus.Default,
errMsg: "",
};
const convertPlainObject = (obj: unknown) => {
+1 -1
View File
@@ -13,7 +13,7 @@ export default defineConfig({
envDir: "../..",
envPrefix: "APP",
build: {
outDir: path.resolve(__dirname, "../main/build/renderer"),
outDir: path.resolve(__dirname, "../main/app/build/renderer"),
emptyOutDir: true,
},
});
+12 -3
View File
@@ -1,4 +1,4 @@
lockfileVersion: '6.1'
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
@@ -192,6 +192,15 @@ importers:
specifier: ^5.1.3
version: 5.1.3
packages/main/app:
dependencies:
'@cliqz/adblocker-electron':
specifier: ^1.26.6
version: 1.26.6(electron@25.2.0)
'@cliqz/adblocker-electron-preload':
specifier: ^1.26.6
version: 1.26.6(electron@25.2.0)
packages/mobile:
dependencies:
axios:
@@ -332,10 +341,10 @@ importers:
specifier: ^20.3.1
version: 20.3.1
'@types/react':
specifier: ^18.2.13
specifier: ^18.2.0
version: 18.2.13
'@types/react-dom':
specifier: ^18.2.6
specifier: ^18.2.0
version: 18.2.6
'@types/sort-by':
specifier: ^1.2.0