feat: 请求转发

This commit is contained in:
maotoumao
2024-11-07 22:15:45 +08:00
parent 0120829daf
commit d1dc08bcee
15 changed files with 390 additions and 69 deletions

View File

@@ -91,7 +91,7 @@ const config: ForgeConfig = {
js: "./src/webworkers/db-worker.ts",
name: "db",
nodeIntegration: true,
},
}
],
},
}),

View File

@@ -0,0 +1,105 @@
const http = require("http");
const https = require("https");
const defaultPort = 52735;
const maxRetries = 20;
let retryCount = 0;
function forwardRequest(clientRes, url, method, headers) {
const options = {
method: method,
headers: headers,
};
const protocol = url.startsWith("https") ? https : http;
const req = protocol.request(url, options, (targetRes) => {
// 将目标响应的状态码和头部转发到客户端
clientRes.writeHead(targetRes.statusCode, targetRes.headers);
// 将目标响应的数据流转发到客户端
targetRes.pipe(clientRes, {
end: true,
});
});
req.on("error", (error) => {
console.error("Error forwarding request:", error);
clientRes.writeHead(500, {"Content-Type": "text/plain"});
clientRes.end("Internal Server Error");
});
// 结束目标请求
req.end();
}
function safeParse(data) {
try {
return JSON.parse(data) || {};
} catch (e) {
return {};
}
}
function startServer(port) {
// 创建一个 HTTP 服务器
const server = http.createServer((req, res) => {
if (req.method !== "GET") {
res.writeHead(405, {"Content-Type": "text/plain"});
return res.end("Only GET requests are allowed");
}
if (req.url === "/heartbeat") {
res.writeHead(200, {"Content-Type": "text/plain"});
return res.end("OK");
}
const query = new URLSearchParams(req.url.slice(1));
const url = query.get("url");
const method = query.get("method") || "GET"; // 默认使用 GET 方法
const headers = safeParse(query.get("headers"));
res.setHeader("Access-Control-Allow-Origin", "*"); // 允许所有源
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); // 允许的方法
if (!url) {
res.writeHead(400, {"Content-Type": "text/plain"});
return res.end("Bad Request: Missing URL");
}
forwardRequest(res, url, method, {
...(req.headers || {}),
...(headers || {})
});
});
server.listen(port, () => {
process.send?.({
type: "port",
port
});
console.log(`Proxy server is running on http://localhost:${port}`);
});
server.on("error", (err) => {
console.error("Server error:", err);
if (retryCount < maxRetries) {
retryCount++;
const newPort = port + 1; // 尝试下一个端口
console.log(`Retrying on port: ${newPort} (attempt ${retryCount})`);
startServer(newPort);
} else {
process.send?.({type: "error", error: "Max retries reached"});
}
})
}
startServer(defaultPort);

View File

@@ -33,7 +33,7 @@ async function registerGlobalShortCut() {
const globalShortCuts = AppConfig.getConfig("shortCut.shortcuts");
for (const shortCutKey of shortCutKeys) {
const globalShortCutConfig = globalShortCuts[shortCutKey]?.global;
const globalShortCutConfig = globalShortCuts?.[shortCutKey]?.global;
if (globalShortCutConfig?.length) {
await registerSingleShortCut(shortCutKey, globalShortCutConfig);
@@ -46,7 +46,7 @@ async function registerSingleShortCut(key: IShortCutKeys, shortCut: string[]) {
if (shortCut.length) {
const prevConfig = AppConfig.getConfig("shortCut.shortcuts");
if (prevConfig[key].global?.length) {
if (prevConfig[key]?.global?.length) {
globalShortcut.unregister(prevConfig[key].global.join("+"));
}

View File

@@ -19,7 +19,7 @@ import WindowDrag from "@shared/window-drag/main";
import {IAppConfig} from "@/types/app-config";
import axios from "axios";
import {HttpsProxyAgent} from "https-proxy-agent";
import ServiceManager from "@shared/service-manager/main";
// portable
if (process.platform === "win32") {
@@ -162,6 +162,8 @@ app.whenReady().then(async () => {
});
async function bootstrap() {
ServiceManager.setup(windowManager);
const downloadPath = AppConfig.getConfig("download.path");
if (!downloadPath) {
AppConfig.setConfig({

View File

@@ -2,4 +2,5 @@
import "./common-preload";
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
import "@/shared/message-hub/preload/main";
import "@shared/message-hub/preload/main";
import "@shared/service-manager/preload";

View File

@@ -2,6 +2,7 @@ import SvgAsset from "@/renderer/components/SvgAsset";
import "./index.scss";
import trackPlayer from "@/renderer/core/track-player";
import { useTranslation } from "react-i18next";
import { PlayerState } from "@/common/constant";
export default function Controller() {
const playerState = trackPlayer.usePlayerState();
@@ -20,7 +21,7 @@ export default function Controller() {
<div
className="play-or-pause controller-btn primary-btn"
onClick={() => {
if(playerState === trackPlayer.PlayerState.Playing) {
if(playerState === PlayerState.Playing) {
trackPlayer.pause();
} else {
trackPlayer.resumePlay();
@@ -29,7 +30,7 @@ export default function Controller() {
>
<SvgAsset
iconName={
playerState !== trackPlayer.PlayerState.Playing ? "play" : "pause"
playerState !== PlayerState.Playing ? "play" : "pause"
}
></SvgAsset>
</div>
@@ -37,7 +38,7 @@ export default function Controller() {
className="skip controller-btn"
title={t("music_bar.next_music")}
onClick={() => {
trackPlayer.skipToNext();
}}
>

View File

@@ -2,7 +2,6 @@ import SvgAsset from "@/renderer/components/SvgAsset";
import "./index.scss";
import SwitchCase from "@/renderer/components/SwitchCase";
import trackPlayer from "@/renderer/core/track-player";
import {RepeatMode} from "@/renderer/core/track-player/enum";
import {useRef, useState} from "react";
import Condition from "@/renderer/components/Condition";
import Slider from "rc-slider";
@@ -14,6 +13,7 @@ import {useTranslation} from "react-i18next";
import AppConfig from "@shared/app-config/renderer";
import {isCN} from "@/shared/i18n/renderer";
import useAppConfig from "@/hooks/useAppConfig";
import {RepeatMode} from "@/common/constant";
export default function Extra() {
const repeatMode = trackPlayer.useRepeatMode();

View File

@@ -1,26 +1,6 @@
import LyricParser, { IParsedLrcItem } from "@/renderer/utils/lyric-parser";
import {PlayerState, RepeatMode} from "@/common/constant";
/** 播放器状态 */
export enum PlayerState {
/** 无音频 */
None,
/** 播放中 */
Playing,
/** 暂停 */
Paused,
/** 缓冲中 */
Buffering,
}
/** 播放模式 */
export enum RepeatMode {
/** 随机 */
Shuffle = "shuffle",
/** 播放队列 */
Queue = "queue-repeat",
/** 单曲循环 */
Loop = "loop",
}
/** 错误信息 */
export enum ErrorReason {

View File

@@ -2,12 +2,14 @@
* 播放音乐
*/
import {encodeUrlHeaders} from "@/common/normalize-util";
import {ErrorReason, PlayerState, TrackPlayerEvent} from "./enum";
import {ErrorReason, TrackPlayerEvent} from "./enum";
import trackPlayerEventsEmitter from "./event";
import albumImg from "@/assets/imgs/album-cover.jpg";
import getUrlExt from "@/renderer/utils/get-url-ext";
import Hls from "hls.js";
import {isSameMedia} from "@/common/media-util";
import {PlayerState} from "@/common/constant";
import ServiceManager from "@shared/service-manager/renderer";
class TrackPlayerInternal {
private audioContext: AudioContext;
@@ -29,6 +31,8 @@ class TrackPlayerInternal {
});
this.registerEvents();
// @ts-ignore
window.ad = this.audio;
}
private throwError(reason: ErrorReason) {
@@ -92,15 +96,46 @@ class TrackPlayerInternal {
trackSource: IMusic.IMusicSource,
musicItem: IMusic.IMusicItem
) {
// 1. original url
let url = trackSource.url;
if (trackSource.headers || trackSource.userAgent) {
const trackSourceHeaders = trackSource.headers ?? {};
if (trackSource.userAgent) {
trackSourceHeaders["user-agent"] = trackSource.userAgent;
}
const urlObj = new URL(trackSource.url);
let headers: Record<string, any> | null = null;
url = encodeUrlHeaders(url, trackSourceHeaders);
if (trackSource.headers || trackSource.userAgent) {
headers = {...(trackSource.headers ?? {})};
if (trackSource.userAgent) {
headers["user-agent"] = trackSource.userAgent;
}
}
if (urlObj.username && urlObj.password) {
const authHeader = `Basic ${btoa(
`${decodeURIComponent(urlObj.username)}:${decodeURIComponent(
urlObj.password
)}`
)}`;
urlObj.username = "";
urlObj.password = "";
headers = {
...(headers || {}),
Authorization: authHeader,
}
url = urlObj.toString();
}
// hack URL
if (headers) {
const forwardedUrl = ServiceManager.RequestForwarderService.forwardRequest(url, "GET", headers);
if (forwardedUrl) {
url = forwardedUrl;
headers = null;
} else if (!headers["Authorization"]) {
url = encodeUrlHeaders(url, headers);
headers = null;
}
}
if (!url) {
this.throwError(ErrorReason.EmptyResource);
return;
@@ -118,36 +153,25 @@ class TrackPlayerInternal {
],
});
// 拓展播放功能
if (getUrlExt(url) === ".m3u8" && Hls.isSupported()) {
if (getUrlExt(trackSource.url) === ".m3u8" && Hls.isSupported()) {
// Todo: headers
this.hls.loadSource(url);
} else if (headers) {
fetch(url, {
method: "GET",
headers: {
...trackSource.headers,
},
})
.then(async (res) => {
console.log("response", res.headers.get("content-type"));
const blob = await res.blob();
if (isSameMedia(this.currentMusic, musicItem)) {
this.audio.src = URL.createObjectURL(blob);
}
});
} else {
const urlObj = new URL(trackSource.url);
if (urlObj.username && urlObj.password) {
// TODO: 这部分逻辑需要抽离出来 特殊逻辑
const authHeader = `Basic ${btoa(
`${decodeURIComponent(urlObj.username)}:${decodeURIComponent(
urlObj.password
)}`
)}`;
urlObj.username = "";
urlObj.password = "";
fetch(urlObj.toString(), {
method: "GET",
headers: {
...trackSource.headers,
Authorization: authHeader,
},
})
.then(async (res) => {
const blob = await res.blob();
if (isSameMedia(this.currentMusic, musicItem)) {
this.audio.src = URL.createObjectURL(blob);
}
});
} else {
this.audio.src = url;
}
this.audio.src = url;
}
}
@@ -177,6 +201,7 @@ class TrackPlayerInternal {
seekTo(seconds: number) {
if (this.hasSource() && isFinite(seconds)) {
const duration = this.audio.duration;
console.log(duration);
this.audio.currentTime = Math.min(
seconds,
isNaN(duration) ? Infinity : duration

View File

@@ -2,8 +2,6 @@ import Store from "@/common/store";
import trackPlayer from "./internal";
import {
ICurrentLyric,
PlayerState,
RepeatMode,
TrackPlayerEvent,
} from "./enum";
import trackPlayerEventsEmitter from "./event";
@@ -15,7 +13,7 @@ import {
isSameMedia,
sortByTimestampAndIndex,
} from "@/common/media-util";
import { timeStampSymbol, sortIndexSymbol } from "@/common/constant";
import {timeStampSymbol, sortIndexSymbol, PlayerState, RepeatMode} from "@/common/constant";
import { callPluginDelegateMethod } from "../plugin-delegate";
import LyricParser from "@/renderer/utils/lyric-parser";
import {
@@ -571,7 +569,7 @@ async function playIndex(nextIndex: number, options: IPlayOptions = {}) {
if (!mediaSource?.url) {
throw new Error("Empty Source");
}
console.log("MEDIA SOURCE", mediaSource, musicItem);
console.log("MEDIA SOURCE", JSON.stringify(mediaSource), musicItem);
if (isSameMedia(musicItem, musicQueueStore.getValue()[currentIndex])) {
setCurrentQuality(quality);
setCurrentMusic(musicItem);

View File

@@ -24,6 +24,7 @@ import {
setupRecentlyPlaylist,
} from "../core/recently-playlist";
import {TrackPlayerEvent} from "../core/track-player/enum";
import ServiceManager from "@shared/service-manager/renderer";
setAutoFreeze(false);
@@ -46,6 +47,7 @@ export default async function () {
localMusic.setupLocalMusic();
await Downloader.setupDownloader();
setupRecentlyPlaylist();
ServiceManager.setup();
// 自动更新插件
if (AppConfig.getConfig("plugin.autoUpdatePlugin")) {

View File

@@ -0,0 +1,3 @@
export enum ServiceName {
RequestForwarder = "request-forwarder",
}

View File

@@ -0,0 +1,132 @@
import {ChildProcess, fork} from "child_process";
import {ipcMain} from "electron";
import {IWindowManager} from "@/types/main/window-manager";
import {ServiceName} from "@shared/service-manager/common";
import getResourcePath from "@/utils/main/get-resource-path";
class ServiceInstance {
private serviceProcess: ChildProcess = null;
private retryTimeOut = 6000;
private started = false;
private subprocessName: string;
private hostChangeCallback: (host: string | null) => void;
public serviceName: string;
constructor(serviceName: string, subprocessPath: string) {
this.serviceName = serviceName;
this.subprocessName = subprocessPath;
}
onHostChange(callback: (host: string | null) => void) {
this.hostChangeCallback = callback;
}
start() {
if (this.started) {
return;
}
this.started = true;
const servicePath = getResourcePath(".service/" + this.subprocessName + ".js");
this.serviceProcess = fork(servicePath);
interface IMessage {
type: "port",
port: number
}
this.serviceProcess.on("message", (msg: IMessage) => {
const host = "http://127.0.0.1:" + msg.port;
this.hostChangeCallback(host);
})
this.serviceProcess.on("error", () => {
if (this.started) {
setTimeout(() => {
this.start(); // 自动重启子进程
}, this.retryTimeOut);
this.retryTimeOut = this.retryTimeOut > 300000 ? 300000 : this.retryTimeOut * 2;
}
})
this.serviceProcess.on("exit", (code) => {
if (this.started) {
console.error(`Service exited with code ${code}. Restarting...`);
setTimeout(() => {
this.start(); // 自动重启子进程
}, this.retryTimeOut);
this.retryTimeOut = this.retryTimeOut > 300000 ? 300000 : this.retryTimeOut * 2;
}
});
}
stop() {
this.started = false;
this.serviceProcess.removeAllListeners();
this.serviceProcess.kill();
this.serviceProcess = null;
this.retryTimeOut = 6000;
this.hostChangeCallback(null);
}
}
interface IServiceData {
instance: ServiceInstance;
host: string | null;
}
class ServiceManager {
private windowManager: IWindowManager;
private serviceMap = new Map<ServiceName, IServiceData>();
private addService(serviceName: ServiceName) {
const instance = new ServiceInstance(serviceName, serviceName);
this.serviceMap.set(serviceName, {instance, host: null});
instance.onHostChange((host) => {
const mainWindow = this.windowManager?.mainWindow;
if (mainWindow) {
mainWindow.webContents.send("@shared/service-manager/host-changed", serviceName, host);
}
this.serviceMap.get(serviceName).host = host;
});
return instance;
}
startService(serviceName: ServiceName) {
this.serviceMap.get(serviceName)?.instance?.start?.();
}
stopService(serviceName: ServiceName) {
this.serviceMap.get(serviceName)?.instance?.stop?.();
}
setup(windowManager: IWindowManager) {
this.windowManager = windowManager;
// put services here
this.addService(ServiceName.RequestForwarder).start();
ipcMain.handle("@shared/service-manager/get-service-hosts", () => {
const serviceHosts: Record<string, string> = {};
this.serviceMap.forEach((val, key) => {
if (val.host) {
serviceHosts[key] = val.host;
}
})
return serviceHosts;
});
}
}
export default new ServiceManager();

View File

@@ -0,0 +1,34 @@
import {contextBridge, ipcRenderer} from "electron";
import {ServiceName} from "@shared/service-manager/common";
const serviceHostMap = new Map<ServiceName, string>();
ipcRenderer.on("@shared/service-manager/host-changed", (_evt, serviceName: ServiceName, host: string | null) => {
if (host) {
serviceHostMap.set(serviceName, host);
} else {
serviceHostMap.delete(serviceName)
}
});
async function setup() {
const hosts = (await ipcRenderer.invoke("@shared/service-manager/get-service-hosts")) || {};
const serviceNames = Object.keys(hosts);
for (const serviceName of serviceNames) {
serviceHostMap.set(serviceName as any, hosts[serviceName]);
}
}
function getServiceHost(serviceName: ServiceName) {
return serviceHostMap.get(serviceName);
}
const mod = {
setup,
getServiceHost
}
contextBridge.exposeInMainWorld("@shared/service-manager", mod);

View File

@@ -0,0 +1,38 @@
import {ServiceName} from "@shared/service-manager/common";
interface IMod {
setup: () => Promise<void>;
getServiceHost: (serviceName: ServiceName) => string | null;
}
const mod = window["@shared/service-manager" as any] as unknown as IMod;
class RequestForwarderService {
static forwardRequest(url: string, method?: string, headers?: Record<any, any>): string | null {
const host = mod.getServiceHost(ServiceName.RequestForwarder);
if (!host) {
return null;
}
const fUrl = new URL(host)
fUrl.searchParams.set("url", url);
if (method) {
fUrl.searchParams.set("method", method);
}
if (headers) {
fUrl.searchParams.set("headers", JSON.stringify(headers));
}
return fUrl.toString();
}
}
const ServiceManager = {
setup: mod.setup,
RequestForwarderService
}
export default ServiceManager;