From 9e812215d242c813443e1c65ee19225e9b26cf86 Mon Sep 17 00:00:00 2001 From: eric Date: Tue, 24 Mar 2026 11:56:11 -0500 Subject: [PATCH] 's' --- config/ffmpeg_proxy.ini | 6 + douyin_youtube_ffplay.py | 3 + electron/src/main/index.ts | 8 +- electron/src/renderer/index.html | 2 +- electron/src/renderer/src/App.css | 789 +++++++++++++++--- electron/src/renderer/src/App.tsx | 130 ++- .../src/renderer/src/NetworkDashboard.tsx | 204 +++++ electron/src/renderer/src/ProxySettings.tsx | 112 +++ electron/src/renderer/src/SystemMetrics.tsx | 144 ++++ tiktok2.py | 7 +- web2.py | 45 + web2_network.py | 267 ++++++ web2_proxy_config.py | 96 +++ web2_supervisor.py | 3 + web2_system_metrics.py | 129 +++ youtube2.py | 7 +- 16 files changed, 1797 insertions(+), 155 deletions(-) create mode 100644 config/ffmpeg_proxy.ini create mode 100644 electron/src/renderer/src/NetworkDashboard.tsx create mode 100644 electron/src/renderer/src/ProxySettings.tsx create mode 100644 electron/src/renderer/src/SystemMetrics.tsx create mode 100644 web2_network.py create mode 100644 web2_proxy_config.py create mode 100644 web2_system_metrics.py diff --git a/config/ffmpeg_proxy.ini b/config/ffmpeg_proxy.ini new file mode 100644 index 0000000..afcdf7d --- /dev/null +++ b/config/ffmpeg_proxy.ini @@ -0,0 +1,6 @@ +[proxy] +# 仅作用于本软件拉起的录制/推流进程,不会修改 Windows「系统代理」 +# 与本机 Clash Verge 等「混合端口」一致即可(示例端口 7890) +enabled = no +http = http://127.0.0.1:7890 +socks = socks5://127.0.0.1:7890 diff --git a/douyin_youtube_ffplay.py b/douyin_youtube_ffplay.py index 6a655df..c2c48db 100644 --- a/douyin_youtube_ffplay.py +++ b/douyin_youtube_ffplay.py @@ -11,6 +11,8 @@ import re import os import configparser +from web2_proxy_config import merge_child_env + class StreamEnded(Exception): """主播真实下播的专用异常""" @@ -279,6 +281,7 @@ def start_douyin_youtube_ffplay( ) if _ffmpeg_cwd: _popen_kw["cwd"] = _ffmpeg_cwd + _popen_kw["env"] = merge_child_env() proc = subprocess.Popen(ffmpeg_command, **_popen_kw) proc.last_out_ts = time.time() diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index 30db9f0..c48d679 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -149,12 +149,12 @@ function resolvePreload(): string { function createWindow(): void { mainWindow = new BrowserWindow({ - width: 920, - height: 860, - minWidth: 640, + width: 1020, + height: 720, + minWidth: 720, minHeight: 560, show: false, - backgroundColor: '#f0f0f0', + backgroundColor: '#14161c', title: '直播录制助手', autoHideMenuBar: true, webPreferences: { diff --git a/electron/src/renderer/index.html b/electron/src/renderer/index.html index f081a90..f166cf5 100644 --- a/electron/src/renderer/index.html +++ b/electron/src/renderer/index.html @@ -3,7 +3,7 @@ - + 直播录制助手 diff --git a/electron/src/renderer/src/App.css b/electron/src/renderer/src/App.css index 763e3fe..c42a354 100644 --- a/electron/src/renderer/src/App.css +++ b/electron/src/renderer/src/App.css @@ -1,47 +1,53 @@ -/* 直播录制助手 — 浅色客户端 + 精致层次(离线可用,无外链字体) */ +/* 直播录制助手 — 借鉴 Clash Verge 类客户端:深色侧栏 + 主工作区(离线无外链字体) */ :root { --font-ui: "Segoe UI", "Microsoft YaHei UI", "PingFang SC", system-ui, sans-serif; --font-mono: "Cascadia Mono", "Consolas", ui-monospace, monospace; - --bg-app-0: #e8edf4; - --bg-app-1: #f0f4f9; - --bg-chrome: rgba(255, 255, 255, 0.92); - --bg-card: #ffffff; - --bg-card-muted: #f8fafc; - --bg-input: #fcfcfd; - --bg-log-0: #2b2d31; - --bg-log-1: #1a1b1e; - --border-subtle: rgba(15, 23, 42, 0.08); - --border-card: rgba(15, 23, 42, 0.06); - --border-strong: rgba(15, 23, 42, 0.14); - --text: #1a1d21; - --text-muted: #5f6b7a; - --text-on-dark: #e4e6eb; + --bg-app-0: #12141a; + --bg-app-1: #16181f; + --bg-sidebar: #1b1f28; + --bg-sidebar-hover: #232830; + --bg-workspace: #14161c; + --bg-chrome: #1a1d24; + --bg-card: #22262e; + --bg-card-elevated: #282c36; + --bg-card-muted: #2a2f3a; + --bg-input: #1e222a; + --bg-log-0: #1e2128; + --bg-log-1: #14161c; + --border-subtle: rgba(255, 255, 255, 0.06); + --border-card: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.12); + --text: #e8eaed; + --text-muted: #8b929e; + --text-on-dark: #c8cdd4; - --accent: #0067c0; - --accent-2: #0f6cbd; - --accent-soft: rgba(0, 103, 192, 0.1); - --accent-glow: rgba(0, 103, 192, 0.22); - --accent-hover: #005a9e; - --success: #0e7a0d; - --success-soft: rgba(14, 122, 13, 0.12); - --danger: #c42b1c; - --danger-soft: rgba(196, 43, 28, 0.1); - --warn: #c35a12; - --warn-soft: rgba(195, 90, 18, 0.12); + --accent: #5b9dff; + --accent-2: #7ab3ff; + --accent-dim: rgba(91, 157, 255, 0.14); + --accent-soft: rgba(91, 157, 255, 0.18); + --accent-glow: rgba(91, 157, 255, 0.35); + --accent-hover: #6aa8ff; + --success: #3dd68c; + --success-soft: rgba(61, 214, 140, 0.12); + --danger: #f56565; + --danger-soft: rgba(245, 101, 101, 0.12); + --warn: #f6ad55; + --warn-soft: rgba(246, 173, 85, 0.12); - --radius: 6px; + --radius: 8px; --radius-md: 10px; - --radius-lg: 14px; + --radius-lg: 12px; --radius-pill: 999px; - --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05); - --shadow-md: 0 4px 14px rgba(15, 23, 42, 0.07), 0 1px 2px rgba(15, 23, 42, 0.04); - --shadow-lg: 0 12px 40px rgba(15, 23, 42, 0.1), 0 2px 8px rgba(15, 23, 42, 0.05); - --shadow-inset-top: inset 0 1px 0 rgba(255, 255, 255, 0.75); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35); + --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.35); + --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.45); + --shadow-inset-top: inset 0 1px 0 rgba(255, 255, 255, 0.06); --ease: cubic-bezier(0.2, 0, 0, 1); + --sidebar-width: 220px; } *, @@ -56,7 +62,7 @@ html { } ::selection { - background: var(--accent-soft); + background: var(--accent-dim); color: var(--text); } @@ -67,73 +73,200 @@ body { font-size: 14px; line-height: 1.55; color: var(--text); - background-color: var(--bg-app-1); - background-image: - radial-gradient(ellipse 120% 80% at 50% -20%, rgba(0, 103, 192, 0.09), transparent 55%), - linear-gradient(165deg, var(--bg-app-0) 0%, var(--bg-app-1) 45%, #eef2f7 100%); + background-color: var(--bg-workspace); + background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(91, 157, 255, 0.08), transparent 50%); } body.ready { opacity: 1; } -/* 根布局 */ +/* 根布局(旧顶栏已弃用,改侧栏) */ .app-frame { min-height: 100vh; display: flex; flex-direction: column; } -.app-chrome { - position: relative; +.app-frame--verge { + flex-direction: row; + min-height: 100vh; +} + +/* —— Clash Verge 风格侧栏 —— */ +.app-sidebar { + width: var(--sidebar-width); flex-shrink: 0; - padding: 20px 24px 18px; - background: var(--bg-chrome); - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); + display: flex; + flex-direction: column; + background: linear-gradient(180deg, var(--bg-sidebar) 0%, #181c24 100%); + border-right: 1px solid var(--border-subtle); + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.25); +} + +.app-sidebar__brand { + display: flex; + align-items: center; + gap: 12px; + padding: 20px 16px 18px; border-bottom: 1px solid var(--border-subtle); - box-shadow: var(--shadow-sm), 0 8px 32px rgba(15, 23, 42, 0.04); } -.app-chrome::before { - content: ""; - position: absolute; - left: 0; - right: 0; - top: 0; - height: 3px; - background: linear-gradient(90deg, #0f6cbd 0%, #4a9fe8 48%, #7c5cff 100%); - opacity: 0.95; +.app-sidebar__logo { + width: 40px; + height: 40px; + border-radius: 12px; + background: linear-gradient(145deg, #3d7eff 0%, #5b9dff 40%, #7c5cff 100%); + box-shadow: 0 4px 16px rgba(91, 157, 255, 0.35); + flex-shrink: 0; } -.app-chrome__title { - margin: 0; - font-size: 22px; - font-weight: 650; - letter-spacing: -0.03em; - line-height: 1.25; +.app-sidebar__app-name { + font-size: 15px; + font-weight: 700; + letter-spacing: -0.02em; color: var(--text); - text-shadow: 0 1px 0 rgba(255, 255, 255, 0.8); + line-height: 1.25; } -.app-chrome__subtitle { - margin: 8px 0 0; +.app-sidebar__app-tag { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} + +.app-sidebar__nav { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 10px; + flex: 1; +} + +.app-sidebar__link { + position: relative; + display: flex; + align-items: center; + width: 100%; + padding: 11px 14px; + border: none; + border-radius: var(--radius-md); + background: transparent; + color: var(--text-muted); + font-family: var(--font-ui); + font-size: 14px; + font-weight: 600; + text-align: left; + cursor: pointer; + transition: + background 0.15s var(--ease), + color 0.15s var(--ease); +} + +.app-sidebar__link:hover { + background: var(--bg-sidebar-hover); + color: var(--text); +} + +.app-sidebar__link.is-active { + background: rgba(91, 157, 255, 0.12); + color: var(--accent); + box-shadow: inset 3px 0 0 var(--accent); +} + +.app-sidebar__link-label { + position: relative; + z-index: 1; +} + +.app-sidebar__link-glow { + position: absolute; + inset: 0; + border-radius: var(--radius-md); + background: radial-gradient(circle at 0% 50%, rgba(91, 157, 255, 0.15), transparent 65%); + pointer-events: none; +} + +/* 主工作区 */ +.app-workspace { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + background: var(--bg-workspace); +} + +.app-workspace__header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 24px 14px; + border-bottom: 1px solid var(--border-subtle); + background: rgba(20, 22, 28, 0.85); + backdrop-filter: blur(12px); +} + +.app-workspace__title { + margin: 0; + font-size: 20px; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--text); +} + +.app-workspace__subtitle { + margin: 4px 0 0; font-size: 13px; color: var(--text-muted); - letter-spacing: 0.01em; +} + +.app-workspace__scroll { + flex: 1; + overflow: auto; + padding: 16px 24px 28px; +} + +.app-workspace__inner { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.app-workspace__inner--center { + align-items: center; + justify-content: center; + padding: 32px 24px; +} + +.app-panel { + max-width: 920px; + margin: 0 auto; +} + +.app-panel > .card { + margin-bottom: 0; +} + +.app-body--record { + max-width: 920px; + margin: 0 auto; + padding: 0; } .app-body { flex: 1; - padding: 20px 24px 32px; - max-width: 920px; + padding: 0; + max-width: none; } .app-body--padded { display: flex; align-items: flex-start; justify-content: center; - padding-top: 40px; + padding: 40px 24px; } .app-toolbar { @@ -172,10 +305,10 @@ body.ready { max-width: 100%; cursor: pointer; box-shadow: var(--shadow-inset-top), var(--shadow-sm); - background-color: #f8fafc; + background-color: var(--bg-input); background-image: - url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%235f6b7a' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"), - linear-gradient(180deg, #ffffff 0%, #f4f6f9 100%); + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%238b929e' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"), + linear-gradient(180deg, #2a2f38 0%, #22262e 100%); background-repeat: no-repeat, no-repeat; background-position: right 12px center, 0 0; background-size: 16px 16px, 100% 100%; @@ -186,7 +319,7 @@ body.ready { } .select-process:hover:not(:disabled) { - border-color: rgba(0, 103, 192, 0.45); + border-color: rgba(91, 157, 255, 0.45); box-shadow: var(--shadow-inset-top), 0 0 0 1px var(--accent-glow); } @@ -211,9 +344,9 @@ body.ready { padding: 8px 14px 8px 10px; border-radius: var(--radius-pill); border: 1px solid var(--border-card); - background: linear-gradient(180deg, #ffffff 0%, #f4f6f9 100%); + background: var(--bg-card); color: var(--text-muted); - box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.9); + box-shadow: var(--shadow-sm), var(--shadow-inset-top); } .status-pill__dot { @@ -221,7 +354,7 @@ body.ready { height: 9px; border-radius: 50%; background: currentColor; - box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85); + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35); } .status-pill[data-variant='loading'] .status-pill__dot { @@ -256,34 +389,34 @@ body.ready { } .status-pill[data-variant='online'] { - border-color: rgba(14, 122, 13, 0.28); - background: linear-gradient(180deg, #f4fbf4 0%, #e8f5e8 100%); + border-color: rgba(61, 214, 140, 0.35); + background: rgba(61, 214, 140, 0.1); color: var(--success); } .status-pill[data-variant='stopped'], .status-pill[data-variant='offline'] { - border-color: rgba(196, 43, 28, 0.22); - background: linear-gradient(180deg, #fff8f7 0%, #fcefea 100%); + border-color: rgba(245, 101, 101, 0.35); + background: rgba(245, 101, 101, 0.1); color: var(--danger); } .status-pill[data-variant='warn'] { - border-color: rgba(195, 90, 18, 0.28); - background: linear-gradient(180deg, #fffaf5 0%, #fff0e0 100%); + border-color: rgba(246, 173, 85, 0.35); + background: rgba(246, 173, 85, 0.1); color: var(--warn); } .status-pill[data-variant='error'] { - border-color: rgba(196, 43, 28, 0.28); - background: linear-gradient(180deg, #fff8f7 0%, #fcefea 100%); + border-color: rgba(245, 101, 101, 0.35); + background: rgba(245, 101, 101, 0.12); color: var(--danger); } .status-pill[data-variant='unknown'], .status-pill[data-variant='empty'] { - border-color: rgba(0, 103, 192, 0.2); - background: linear-gradient(180deg, #f5f9ff 0%, #e8f2fc 100%); + border-color: rgba(91, 157, 255, 0.25); + background: rgba(91, 157, 255, 0.1); color: var(--accent-2); } @@ -295,7 +428,7 @@ body.ready { gap: 8px 14px; margin-bottom: 18px; padding: 12px 16px; - background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(248, 250, 252, 0.98) 100%); + background: var(--bg-card-elevated); border: 1px solid var(--border-card); border-radius: var(--radius-md); font-size: 13px; @@ -332,10 +465,10 @@ body.ready { border-radius: var(--radius-md); background: linear-gradient( 110deg, - #e8ecf2 0%, - #f4f6fa 40%, - #e8ecf2 80%, - #e8ecf2 100% + #2a2f38 0%, + #363c48 40%, + #2a2f38 80%, + #2a2f38 100% ); background-size: 200% 100%; animation: shimmer 1.15s ease-in-out infinite; @@ -384,10 +517,8 @@ body.ready { height: 56px; margin: 0 auto 16px; border-radius: 16px; - background: linear-gradient(145deg, #e8eef6 0%, #d4e4f7 45%, #c8daf0 100%); - box-shadow: - var(--shadow-inset-top), - 0 8px 24px rgba(0, 103, 192, 0.12); + background: linear-gradient(145deg, #2a3444 0%, #1e2632 100%); + box-shadow: var(--shadow-inset-top), 0 8px 24px rgba(0, 0, 0, 0.35); position: relative; } @@ -396,7 +527,7 @@ body.ready { position: absolute; inset: 14px; border-radius: 8px; - border: 2px dashed rgba(0, 103, 192, 0.25); + border: 2px dashed rgba(91, 157, 255, 0.28); opacity: 0.85; } @@ -415,8 +546,8 @@ body.ready { align-items: flex-start; padding: 20px 22px; border-radius: var(--radius-lg); - background: linear-gradient(180deg, #ffffff 0%, #fffbfb 100%); - border: 1px solid rgba(196, 43, 28, 0.22); + background: var(--bg-card); + border: 1px solid rgba(245, 101, 101, 0.35); box-shadow: var(--shadow-md); } @@ -500,9 +631,9 @@ body.ready { } .card--accent { - border-color: rgba(0, 103, 192, 0.18); - background: linear-gradient(180deg, #fbfdff 0%, #f3f8fd 100%); - box-shadow: var(--shadow-md), 0 0 0 1px rgba(0, 103, 192, 0.04); + border-color: rgba(91, 157, 255, 0.25); + background: linear-gradient(180deg, rgba(91, 157, 255, 0.08) 0%, var(--bg-card) 100%); + box-shadow: var(--shadow-md), 0 0 0 1px rgba(91, 157, 255, 0.06); } .card--log .card__desc { @@ -522,7 +653,7 @@ body.ready { border-radius: var(--radius-md); resize: vertical; min-height: 108px; - box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.04); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.25); transition: border-color 0.18s var(--ease), box-shadow 0.2s var(--ease); @@ -532,7 +663,7 @@ body.ready { outline: none; border-color: var(--accent); box-shadow: - inset 0 1px 2px rgba(15, 23, 42, 0.04), + inset 0 1px 2px rgba(0, 0, 0, 0.25), 0 0 0 3px var(--accent-soft); } @@ -559,31 +690,31 @@ body.ready { } .btn-secondary { - background: linear-gradient(180deg, #ffffff 0%, #f3f5f8 100%); + background: linear-gradient(180deg, #2f3540 0%, #282c34 100%); color: var(--text); border-color: var(--border-strong); box-shadow: var(--shadow-inset-top), var(--shadow-sm); } .btn-secondary:hover { - background: linear-gradient(180deg, #fafbfc 0%, #eceff4 100%); - border-color: rgba(15, 23, 42, 0.22); + background: linear-gradient(180deg, #3a404c 0%, #323842 100%); + border-color: rgba(255, 255, 255, 0.18); } .btn-primary { - background: linear-gradient(180deg, #0f6cbd 0%, #0067c0 55%, #005a9e 100%); + background: linear-gradient(180deg, #4a8eef 0%, #3d7eff 45%, #2f6fd6 100%); color: #fff; - border-color: rgba(0, 0, 0, 0.08); + border-color: rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-inset-top), - 0 4px 12px rgba(0, 103, 192, 0.28); + 0 4px 16px rgba(91, 157, 255, 0.35); } .btn-primary:hover { - background: linear-gradient(180deg, #1a7ad4 0%, #0f6cbd 55%, #0067c0 100%); + background: linear-gradient(180deg, #5b9dff 0%, #4a8eef 55%, #3d7eff 100%); box-shadow: var(--shadow-inset-top), - 0 6px 18px rgba(0, 103, 192, 0.35); + 0 6px 22px rgba(91, 157, 255, 0.45); } .btn-primary:active, @@ -616,11 +747,11 @@ body.ready { font-family: var(--font-mono); font-size: 13px; color: var(--accent-2); - background: linear-gradient(135deg, #f5f9ff 0%, #e8f2fc 100%); - border: 1px dashed rgba(0, 103, 192, 0.35); + background: rgba(91, 157, 255, 0.08); + border: 1px dashed rgba(91, 157, 255, 0.35); border-radius: var(--radius-md); word-break: break-all; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8); + box-shadow: var(--shadow-inset-top); } /* 录制按钮区 */ @@ -695,7 +826,7 @@ body.ready { } .ctrl-btn--status { - background: linear-gradient(180deg, #ffffff 0%, #f1f4f8 100%); + background: linear-gradient(180deg, #323842 0%, #2a2f38 100%); color: var(--text); border-color: var(--border-strong); box-shadow: var(--shadow-inset-top), var(--shadow-sm); @@ -707,8 +838,8 @@ body.ready { .ctrl-btn--status:hover { filter: none; - border-color: rgba(0, 103, 192, 0.45); - background: linear-gradient(180deg, #ffffff 0%, #e8f2fc 100%); + border-color: rgba(91, 157, 255, 0.45); + background: linear-gradient(180deg, #3a404c 0%, #323842 100%); box-shadow: var(--shadow-inset-top), 0 0 0 1px var(--accent-glow); } @@ -735,8 +866,8 @@ body.ready { color: var(--accent-2); padding: 5px 10px; border-radius: var(--radius-pill); - background: linear-gradient(180deg, #e8f2fc 0%, #dceaf8 100%); - border: 1px solid rgba(0, 103, 192, 0.18); + background: rgba(91, 157, 255, 0.1); + border: 1px solid rgba(91, 157, 255, 0.22); box-shadow: var(--shadow-inset-top); } @@ -768,7 +899,7 @@ body.ready { .log-panel--muted { text-align: left; color: var(--text-muted); - background: linear-gradient(180deg, #fafbfc 0%, #f3f5f8 100%); + background: var(--bg-card-elevated); border: 1px solid var(--border-card); min-height: auto; height: auto; @@ -794,3 +925,425 @@ body.ready { .log-panel::-webkit-scrollbar-thumb:hover { background: #6b6f7a; } + +/* —— 网络仪表盘 —— */ +.card--network { + margin-bottom: 4px; + border-color: rgba(124, 92, 255, 0.28); + background: linear-gradient(165deg, rgba(124, 92, 255, 0.08) 0%, var(--bg-card) 55%); + box-shadow: var(--shadow-md), 0 0 0 1px rgba(124, 92, 255, 0.08); +} + +.net-dash__header { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.net-dash__lead { + margin-top: 4px !important; + max-width: 52rem; +} + +.net-dash__refresh { + flex-shrink: 0; +} + +.net-dash__error { + margin: 0 0 12px; + padding: 10px 12px; + font-size: 13px; + color: var(--danger); + background: var(--danger-soft); + border: 1px solid rgba(245, 101, 101, 0.35); + border-radius: var(--radius-md); +} + +.net-dash__loading { + font-size: 13px; + color: var(--text-muted); + padding: 8px 0 4px; +} + +.net-site-row { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + margin-bottom: 14px; +} + +@media (max-width: 720px) { + .net-site-row { + grid-template-columns: 1fr; + } +} + +.net-site { + padding: 12px 14px; + border-radius: var(--radius-md); + background: var(--bg-card-elevated); + border: 1px solid var(--border-card); + box-shadow: var(--shadow-sm); +} + +.net-site__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} + +.net-site__title { + font-size: 13px; + font-weight: 650; + color: var(--text); +} + +.net-badge { + font-size: 11px; + font-weight: 700; + padding: 3px 8px; + border-radius: var(--radius-pill); + letter-spacing: 0.02em; +} + +.net-badge--ok { + color: var(--success); + background: var(--success-soft); + border: 1px solid rgba(61, 214, 140, 0.35); +} + +.net-badge--bad { + color: var(--danger); + background: var(--danger-soft); + border: 1px solid rgba(245, 101, 101, 0.35); +} + +.net-site__grid { + display: grid; + grid-template-columns: 72px 1fr; + gap: 6px 10px; + font-size: 12px; + align-items: baseline; +} + +.net-k { + color: var(--text-muted); + font-weight: 600; +} + +.net-v { + color: var(--text); + word-break: break-word; +} + +.net-v--mono { + font-family: var(--font-mono); + font-size: 11.5px; +} + +.net-site__err { + margin: 10px 0 0; + font-size: 11px; + line-height: 1.45; + color: var(--warn); +} + +.net-routes { + margin-bottom: 8px; + padding: 12px 14px; + border-radius: var(--radius-md); + background: rgba(91, 157, 255, 0.06); + border: 1px solid rgba(91, 157, 255, 0.15); +} + +.net-routes__label { + display: block; + font-size: 12px; + font-weight: 650; + color: var(--text-muted); + margin-bottom: 6px; +} + +.net-routes__pill { + display: inline-block; + font-size: 13px; + font-weight: 700; + padding: 6px 12px; + border-radius: var(--radius-pill); + margin-bottom: 8px; +} + +.net-routes__pill--yes { + color: var(--success); + background: var(--success-soft); + border: 1px solid rgba(61, 214, 140, 0.35); +} + +.net-routes__pill--no { + color: var(--warn); + background: var(--warn-soft); + border: 1px solid rgba(195, 90, 18, 0.28); +} + +.net-routes__pill--unk { + color: var(--text-muted); + background: var(--bg-card-muted); + border: 1px solid var(--border-card); +} + +.net-routes__note { + margin: 0; + font-size: 12px; + line-height: 1.5; + color: var(--text-muted); +} + +.net-speed__title { + margin: 0 0 10px; + font-size: 13px; + font-weight: 650; + color: var(--text); +} + +.net-speed__grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +@media (max-width: 520px) { + .net-speed__grid { + grid-template-columns: 1fr; + } +} + +.net-speed__col { + padding: 12px 14px; + border-radius: var(--radius-md); + background: var(--bg-card-elevated); + border: 1px solid var(--border-subtle); +} + +.net-speed__head { + font-size: 12px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--accent-2); + margin-bottom: 10px; +} + +.net-metric { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 10px; + padding: 6px 0; + border-bottom: 1px dashed var(--border-subtle); + font-size: 13px; +} + +.net-metric:last-of-type { + border-bottom: none; +} + +.net-metric__k { + color: var(--text-muted); + font-weight: 600; +} + +.net-metric__v { + font-family: var(--font-mono); + font-weight: 600; + color: var(--text); +} + +.net-speed__hint { + margin: 8px 0 0; + font-size: 10px; + line-height: 1.4; + color: var(--warn); + word-break: break-word; +} + +.net-speed__foot { + margin: 12px 0 0; + font-size: 11px; + line-height: 1.5; + color: var(--text-muted); +} + +/* —— 录制代理 + 系统指标 —— */ +.card--proxy { + border-color: rgba(61, 214, 140, 0.22); + background: linear-gradient(165deg, rgba(61, 214, 140, 0.06) 0%, var(--bg-card) 100%); +} + +.proxy-form { + display: flex; + flex-direction: column; + gap: 10px; + max-width: 36rem; +} + +.proxy-check { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + user-select: none; +} + +.proxy-input { + font-family: var(--font-mono); + font-size: 13px; + padding: 9px 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-input); + color: var(--text); +} + +.proxy-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-soft); +} + +.proxy-form__msg { + margin: 4px 0 0; + font-size: 13px; + color: var(--success); +} + +.card--metrics { + border-color: rgba(124, 92, 255, 0.22); + background: linear-gradient(165deg, rgba(124, 92, 255, 0.07) 0%, var(--bg-card) 100%); +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-bottom: 12px; +} + +@media (max-width: 720px) { + .metrics-grid { + grid-template-columns: 1fr; + } +} + +.metrics-block { + padding: 12px 14px; + border-radius: var(--radius-md); + background: var(--bg-card-elevated); + border: 1px solid var(--border-subtle); +} + +.metrics-block__title { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 6px; +} + +.metrics-big { + font-size: 22px; + font-weight: 750; + letter-spacing: -0.02em; + color: var(--text); +} + +.metrics-sub { + margin-top: 4px; + font-size: 11px; + color: var(--text-muted); + line-height: 1.4; +} + +.metrics-rate { + display: flex; + flex-direction: column; + gap: 4px; + font-family: var(--font-mono); + font-size: 14px; + font-weight: 650; + color: var(--accent-2); +} + +.metrics-swap { + font-size: 12px; + color: var(--text-muted); + margin-bottom: 10px; +} + +.metrics-disks { + margin-top: 8px; +} + +.metrics-disks__title { + font-size: 12px; + font-weight: 650; + margin-bottom: 6px; + color: var(--text); +} + +.metrics-disk-list { + margin: 0; + padding: 0; + list-style: none; + font-size: 12px; + color: var(--text-muted); +} + +.metrics-disk-list li { + display: flex; + flex-wrap: wrap; + gap: 6px 12px; + padding: 6px 0; + border-bottom: 1px dashed var(--border-subtle); +} + +.metrics-disk-list__mp { + font-family: var(--font-mono); + color: var(--text); + min-width: 5rem; +} + +.metrics-ff { + margin-top: 12px; +} + +.metrics-ff__title { + font-size: 12px; + font-weight: 650; + margin-bottom: 6px; +} + +.metrics-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.metrics-table th, +.metrics-table td { + text-align: left; + padding: 6px 8px; + border-bottom: 1px solid var(--border-subtle); +} + +.metrics-table th { + color: var(--text-muted); + font-weight: 600; +} diff --git a/electron/src/renderer/src/App.tsx b/electron/src/renderer/src/App.tsx index a36cf55..08143eb 100644 --- a/electron/src/renderer/src/App.tsx +++ b/electron/src/renderer/src/App.tsx @@ -1,10 +1,22 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { apiUrl } from './api' +import NetworkDashboard from './NetworkDashboard' +import ProxySettings from './ProxySettings' +import SystemMetrics from './SystemMetrics' import { UI_DROPDOWN_YOUTUBE_ONLY } from './constants' import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from './scriptUtils' type Entry = { script: string; label: string; pm2: string } +type NavId = 'record' | 'network' | 'proxy' | 'system' + +const NAV_ITEMS: { id: NavId; label: string; hint: string }[] = [ + { id: 'record', label: '录制', hint: '任务与推流控制' }, + { id: 'network', label: '网络', hint: '连通与测速' }, + { id: 'proxy', label: '代理', hint: '仅录制进程' }, + { id: 'system', label: '系统', hint: '负载与网速' }, +] + type StatusVariant = | 'loading' | 'online' @@ -49,6 +61,8 @@ export default function App() { const [saveYoutubeLoading, setSaveYoutubeLoading] = useState(false) const [saveUrlLoading, setSaveUrlLoading] = useState(false) + const [nav, setNav] = useState('record') + const logRef = useRef(null) const scrollLog = useCallback(() => { @@ -249,18 +263,26 @@ export default function App() { if (bootError) { return ( -
-
-
直播录制助手
-
-
-
-
- ! -
+
+ +
+
+
+
+ ! +
+
+

无法启动

+
{bootError}
+
@@ -268,14 +290,73 @@ export default function App() { ) } - return ( -
-
-
直播录制助手
-

打开即可用,无需单独安装运行环境

-
+ const navMeta = NAV_ITEMS.find((n) => n.id === nav) -
+ return ( +
+ + +
+
+
+

{navMeta?.label ?? ''}

+

{navMeta?.hint}

+
+ {nav === 'record' && ( +
+ + {statusMeta.line} +
+ )} +
+ +
+ {nav === 'network' && ( +
+ +
+ )} + {nav === 'proxy' && ( +
+ +
+ )} + {nav === 'system' && ( +
+ +
+ )} + + {nav === 'record' && ( +
-
- - {statusMeta.line} -
{initialLoad && ( @@ -474,6 +545,9 @@ export default function App() { )} +
+ )} +
) diff --git a/electron/src/renderer/src/NetworkDashboard.tsx b/electron/src/renderer/src/NetworkDashboard.tsx new file mode 100644 index 0000000..9f63875 --- /dev/null +++ b/electron/src/renderer/src/NetworkDashboard.tsx @@ -0,0 +1,204 @@ +import { useCallback, useEffect, useState } from 'react' +import { apiUrl } from './api' + +type SiteInfo = { + host: string + reachable: boolean + latency_ms: number + error: string | null + resolved_ip: string | null + country: string | null + country_code: string | null + geo_error: string | null +} + +type SpeedBlock = { + label: string + download_mbps: number | null + download_error: string | null + download_bytes_sampled: number + upload_mbps: number | null + upload_error: string | null + upload_target: string | null +} + +type NetworkPayload = { + google: SiteInfo + douyin: SiteInfo + routes: { different: boolean | null; note: string } + speed: { overseas: SpeedBlock; domestic: SpeedBlock } + meta?: { at: number; download_cap_bytes: number; upload_bytes: number } + error?: string +} + +function fmtMbps(v: number | null | undefined): string { + if (v == null || Number.isNaN(v)) return '—' + return `${v.toFixed(2)} Mbps` +} + +function SiteRow({ title, site }: { title: string; site: SiteInfo }) { + const ok = site.reachable + return ( +
+
+ {title} + + {ok ? '可访问' : '不可访问'} + +
+
+ 延迟 + {site.latency_ms != null ? `${site.latency_ms.toFixed(0)} ms` : '—'} + 解析 IP + {site.resolved_ip ?? '—'} + 地区(参考) + + {site.country ?? '—'} + {site.country_code ? ` (${site.country_code})` : ''} + +
+ {(site.error || site.geo_error) && ( +

+ {[site.error, site.geo_error].filter(Boolean).join(' · ')} +

+ )} +
+ ) +} + +export default function NetworkDashboard() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [err, setErr] = useState(null) + + const load = useCallback(async () => { + setLoading(true) + setErr(null) + try { + const res = await fetch(apiUrl('/network_status')) + const json = (await res.json()) as NetworkPayload & { error?: string } + if (!res.ok) { + setErr(json.error || `请求失败 ${res.status}`) + setData(null) + return + } + setData(json) + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + setData(null) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void load() + }, [load]) + + useEffect(() => { + const id = window.setInterval(() => void load(), 45000) + return () => clearInterval(id) + }, [load]) + + return ( +
+
+
+

+ 网络仪表盘 +

+

+ 检测 Google(偏海外)与抖音(偏国内)是否通畅、解析出口是否像两条线,并估算上下行(采样约 0.6MB + 下行 / 256KB 上行)。 +

+
+ +
+ + {err && ( +

+ {err} +

+ )} + + {loading && !data && !err &&
正在检测网络…
} + + {data && ( + <> +
+ + +
+ +
+ 线路是否像「两条线」 + + {data.routes.different === true + ? '是(解析国家不同)' + : data.routes.different === false + ? '否或不确定' + : '未知'} + +

{data.routes.note}

+
+ +
+

网速(估算)

+
+
+
海外
+
+ 下行 + {fmtMbps(data.speed.overseas.download_mbps)} +
+
+ 上行 + {fmtMbps(data.speed.overseas.upload_mbps)} +
+ {(data.speed.overseas.download_error || data.speed.overseas.upload_error) && ( +

+ {[data.speed.overseas.download_error, data.speed.overseas.upload_error] + .filter(Boolean) + .join(' · ')} +

+ )} +
+
+
国内
+
+ 下行 + {fmtMbps(data.speed.domestic.download_mbps)} +
+
+ 上行 + {fmtMbps(data.speed.domestic.upload_mbps)} +
+ {(data.speed.domestic.download_error || data.speed.domestic.upload_error) && ( +

+ {[data.speed.domestic.download_error, data.speed.domestic.upload_error] + .filter(Boolean) + .join(' · ')} +

+ )} +
+
+

+ 上行若失败多为网站不接受测试流量,不代表您无法上传直播;海外项走 Google / httpbin,国内项走抖音首页流与门户 + POST。 +

+
+ + )} +
+ ) +} diff --git a/electron/src/renderer/src/ProxySettings.tsx b/electron/src/renderer/src/ProxySettings.tsx new file mode 100644 index 0000000..ebf75e7 --- /dev/null +++ b/electron/src/renderer/src/ProxySettings.tsx @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useState } from 'react' +import { apiUrl } from './api' + +type ProxyCfg = { enabled: boolean; http: string; socks: string } + +export default function ProxySettings() { + const [cfg, setCfg] = useState({ enabled: false, http: '', socks: '' }) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [msg, setMsg] = useState('') + + const load = useCallback(async () => { + setLoading(true) + setMsg('') + try { + const res = await fetch(apiUrl('/proxy_config')) + const data = (await res.json()) as ProxyCfg + if (!res.ok) throw new Error('读取失败') + setCfg({ + enabled: !!data.enabled, + http: data.http || '', + socks: data.socks || '', + }) + } catch (e) { + setMsg(e instanceof Error ? e.message : String(e)) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void load() + }, [load]) + + const save = async () => { + setSaving(true) + setMsg('') + try { + const res = await fetch(apiUrl('/proxy_config'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(cfg), + }) + if (!res.ok) throw new Error('保存失败') + setMsg('已保存。重启录制任务后生效。') + } catch (e) { + setMsg(e instanceof Error ? e.message : String(e)) + } finally { + setSaving(false) + } + } + + return ( +
+

+ 录制专用代理(不改系统网络) +

+

+ 仅对本软件拉起的 FFmpeg / 录制进程生效,不会修改 Windows + 系统代理。若你使用 Clash Verge 等,请在本机开启「混合端口」后,把地址填成与之一致(常见为 7890)。 + YouTube RTMP 推流若仍失败,可能需要代理软件开启 TUN 或自行调整规则。 +

+ {loading ? ( +

正在读取配置…

+ ) : ( +
+ + + setCfg((c) => ({ ...c, http: e.target.value }))} + autoComplete="off" + /> + + setCfg((c) => ({ ...c, socks: e.target.value }))} + autoComplete="off" + /> +
+ + +
+ {msg &&

{msg}

} +
+ )} +
+ ) +} diff --git a/electron/src/renderer/src/SystemMetrics.tsx b/electron/src/renderer/src/SystemMetrics.tsx new file mode 100644 index 0000000..e7c1dd4 --- /dev/null +++ b/electron/src/renderer/src/SystemMetrics.tsx @@ -0,0 +1,144 @@ +import { useCallback, useEffect, useState } from 'react' +import { apiUrl } from './api' + +type Metrics = { + ts?: number + cpu_percent?: number | null + cpu_count_logical?: number + memory?: { percent?: number; used?: number; total?: number; available?: number } + swap?: { percent?: number; used?: number; total?: number } + disks?: Array<{ + mountpoint?: string + percent?: number + free?: number + total?: number + }> + network?: { up_mbps?: number | null; down_mbps?: number | null } + ffmpeg_processes?: Array<{ pid?: number; name?: string; rss?: number }> + error?: string +} + +function fmtBytes(n: number | undefined): string { + if (n == null || Number.isNaN(n)) return '—' + if (n < 1024) return `${n} B` + if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB` + if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB` + return `${(n / 1024 ** 3).toFixed(2)} GB` +} + +export default function SystemMetrics() { + const [data, setData] = useState(null) + const [err, setErr] = useState(null) + + const load = useCallback(async () => { + try { + const res = await fetch(apiUrl('/system_metrics')) + const json = (await res.json()) as Metrics + if (!res.ok) { + setErr(json.error || `HTTP ${res.status}`) + return + } + setErr(null) + setData(json) + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + } + }, []) + + useEffect(() => { + void load() + const id = window.setInterval(() => void load(), 2000) + return () => clearInterval(id) + }, [load]) + + const m = data?.memory + const net = data?.network + + return ( +
+

+ 本机负载与网速 +

+

+ 网速为全机所有网卡合计的估算值(首次刷新可能无速率,约 2 秒后出现)。下方列出当前检测到的 + ffmpeg 进程。 +

+ {err && ( +

+ {err} +

+ )} + {!data && !err &&

正在读取…

} + {data && ( +
+
+
CPU
+
+ {data.cpu_percent != null ? `${data.cpu_percent.toFixed(1)}%` : '—'} +
+
逻辑处理器 {data.cpu_count_logical ?? '—'} 个
+
+
+
内存
+
{m?.percent != null ? `${m.percent.toFixed(1)}%` : '—'}
+
+ {fmtBytes(m?.used)} / {fmtBytes(m?.total)}(可用 {fmtBytes(m?.available)}) +
+
+
+
全机网速
+
+ ↑ {net?.up_mbps != null ? `${net.up_mbps.toFixed(2)} Mbps` : '—'} + ↓ {net?.down_mbps != null ? `${net.down_mbps.toFixed(2)} Mbps` : '—'} +
+
含所有网卡收发合计
+
+
+ )} + {data?.swap && data.swap.total != null && data.swap.total > 0 && ( +
+ 交换分区 {data.swap.percent != null ? `${data.swap.percent.toFixed(0)}%` : '—'} ·{' '} + {fmtBytes(data.swap.used)} / {fmtBytes(data.swap.total)} +
+ )} + {data?.disks && data.disks.length > 0 && ( +
+
磁盘空间
+
    + {data.disks.slice(0, 8).map((d) => ( +
  • + {d.mountpoint} + + {d.percent != null ? `${d.percent.toFixed(0)}%` : '—'} 已用 · 剩余 {fmtBytes(d.free)} + +
  • + ))} +
+
+ )} + {data?.ffmpeg_processes && data.ffmpeg_processes.length > 0 && ( +
+
ffmpeg 进程
+ + + + + + + + + + {data.ffmpeg_processes.map((p) => ( + + + + + + ))} + +
PID名称内存
{p.pid}{p.name}{fmtBytes(p.rss)}
+
+ )} +
+ ) +} diff --git a/tiktok2.py b/tiktok2.py index 6f2da1f..21e38c9 100644 --- a/tiktok2.py +++ b/tiktok2.py @@ -1296,9 +1296,12 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: ffmpeg_command.insert(11, "-headers") ffmpeg_command.insert(12, headers) - if proxy_address: + _ff_proxy = (proxy_address or "").strip() or ( + os.environ.get("STREAM_HTTP_PROXY") or "" + ).strip() or None + if _ff_proxy: ffmpeg_command.insert(1, "-http_proxy") - ffmpeg_command.insert(2, proxy_address) + ffmpeg_command.insert(2, _ff_proxy) recording.add(record_name) start_record_time = datetime.datetime.now() diff --git a/web2.py b/web2.py index 3e491f0..0d3dd3d 100644 --- a/web2.py +++ b/web2.py @@ -14,6 +14,9 @@ from web2_supervisor import ( strip_ansi_codes, use_builtin, ) +from web2_network import run_network_dashboard +from web2_proxy_config import read_proxy_config, write_proxy_config +from web2_system_metrics import snapshot as system_metrics_snapshot app = FastAPI(title="多进程录制控制台") @@ -316,6 +319,48 @@ async def health(): return {"ok": True} +@app.get("/network_status") +async def network_status(): + """Google / 抖音 连通与简易网速(供仪表盘)。""" + try: + return await run_network_dashboard() + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + + +@app.get("/proxy_config") +async def get_proxy_config(): + """仅作用于录制子进程的代理(不写 Windows 系统代理)。""" + return read_proxy_config() + + +@app.post("/proxy_config") +async def post_proxy_config(request: Request): + try: + data = await request.json() + except Exception: + return JSONResponse({"error": "无效的 JSON"}, status_code=400) + if not isinstance(data, dict): + return JSONResponse({"error": "无效数据"}, status_code=400) + write_proxy_config( + { + "enabled": bool(data.get("enabled")), + "http": str(data.get("http") or ""), + "socks": str(data.get("socks") or ""), + } + ) + return {"ok": True} + + +@app.get("/system_metrics") +async def system_metrics(): + """CPU/内存/磁盘/网卡速率与 ffmpeg 进程摘要。""" + try: + return system_metrics_snapshot() + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + + # ---------------- 进程列表(供前端同步,由 discover_script_entries 自动扫描) ---------------- @app.get("/process_monitor") async def process_monitor(): diff --git a/web2_network.py b/web2_network.py new file mode 100644 index 0000000..dfa18bb --- /dev/null +++ b/web2_network.py @@ -0,0 +1,267 @@ +""" +网络检测:Google(海外)与抖音(国内)可达性、解析 IP/地区、简易上下行 Mbps。 +说明:上行依赖对端是否接受 POST,失败时返回 null 与原因,属正常现象。 +""" + +from __future__ import annotations + +import asyncio +import socket +import time +from typing import Any, Optional + +import httpx + +# ---------- 目标 ---------- +GOOGLE_HOST = "www.google.com" +GOOGLE_CHECK_URL = "https://www.google.com/generate_204" +GOOGLE_DOWNLOAD_URL = ( + "https://www.gstatic.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" +) + +DOUYIN_HOST = "www.douyin.com" +DOUYIN_CHECK_URL = "https://www.douyin.com/" +# 首页 HTML 流足够大,便于采样测下行 +DOUYIN_DOWNLOAD_URL = "https://www.douyin.com/" + +OVERSEAS_UPLOAD_URL = "https://httpbin.org/post" +DOMESTIC_UPLOAD_URLS = ( + "https://www.douyin.com/", + "https://www.163.com/", +) + +IP_API_TEMPLATE = "http://ip-api.com/json/{ip}?fields=status,country,countryCode,message,query" + +DOWNLOAD_CAP_BYTES = 600_000 +UPLOAD_BYTES = 256 * 1024 +CONNECT_TIMEOUT = 8.0 +READ_TIMEOUT = 25.0 + + +def _is_probably_china_country(code: Optional[str]) -> bool: + return (code or "").upper() == "CN" + + +async def _resolve_ipv4(host: str) -> tuple[Optional[str], Optional[str]]: + def _sync() -> tuple[Optional[str], Optional[str]]: + try: + infos = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM) + except socket.gaierror as e: + return None, str(e) + for fam, _, _, _, sockaddr in infos: + if fam == socket.AF_INET: + return sockaddr[0], None + for fam, _, _, _, sockaddr in infos: + if fam == socket.AF_INET6: + return sockaddr[0], None + return None, "no address" + + return await asyncio.to_thread(_sync) + + +async def _country_for_ip(client: httpx.AsyncClient, ip: str) -> dict[str, Any]: + if not ip: + return {"country": None, "country_code": None, "error": "no ip"} + try: + r = await client.get(IP_API_TEMPLATE.format(ip=ip), timeout=6.0) + r.raise_for_status() + data = r.json() + except Exception as e: + return {"country": None, "country_code": None, "error": str(e)} + if data.get("status") != "success": + return { + "country": None, + "country_code": None, + "error": data.get("message") or "lookup failed", + } + return { + "country": data.get("country"), + "country_code": data.get("countryCode"), + "error": None, + } + + +async def _latency_get(client: httpx.AsyncClient, url: str) -> tuple[bool, float, Optional[str]]: + t0 = time.monotonic() + try: + r = await client.get(url, follow_redirects=True) + ok = r.status_code < 400 + ms = (time.monotonic() - t0) * 1000 + return ok, ms, None if ok else f"HTTP {r.status_code}" + except Exception as e: + ms = (time.monotonic() - t0) * 1000 + return False, ms, str(e) + + +async def _measure_download_mbps( + client: httpx.AsyncClient, url: str, cap: int = DOWNLOAD_CAP_BYTES +) -> tuple[Optional[float], Optional[str], int]: + total = 0 + t0 = time.monotonic() + try: + async with client.stream("GET", url, follow_redirects=True) as r: + r.raise_for_status() + async for chunk in r.aiter_bytes(): + total += len(chunk) + if total >= cap: + break + dt = time.monotonic() - t0 + if dt < 0.001 or total == 0: + return None, "无数据或过快", total + mbps = (total * 8) / dt / 1_000_000 + return round(mbps, 2), None, total + except Exception as e: + return None, str(e), total + + +async def _measure_upload_mbps( + client: httpx.AsyncClient, url: str, size: int = UPLOAD_BYTES +) -> tuple[Optional[float], Optional[str]]: + data = b"\x00" * size + t0 = time.monotonic() + try: + r = await client.post( + url, + content=data, + headers={"Content-Type": "application/octet-stream"}, + ) + dt = time.monotonic() - t0 + if dt < 0.001: + return None, "耗时过短" + mbps = (size * 8) / dt / 1_000_000 + return round(mbps, 2), None + except Exception as e: + return None, str(e) + + +async def _try_domestic_upload(client: httpx.AsyncClient) -> tuple[Optional[float], Optional[str], Optional[str]]: + last_err: Optional[str] = None + for url in DOMESTIC_UPLOAD_URLS: + up, err = await _measure_upload_mbps(client, url) + if up is not None: + return up, None, url + last_err = err + return None, last_err, None + + +async def run_network_dashboard() -> dict[str, Any]: + """ + 返回结构供前端仪表盘使用。 + routes_different:基于解析 IP 的国家码;若 API 失败则给 likely / unknown。 + """ + timeout = httpx.Timeout(READ_TIMEOUT, connect=CONNECT_TIMEOUT) + limits = httpx.Limits(max_keepalive_connections=4, max_connections=8) + + ua = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + ) + async with httpx.AsyncClient( + timeout=timeout, + limits=limits, + verify=True, + headers={"User-Agent": ua}, + ) as client: + g_ip, g_res_err = await _resolve_ipv4(GOOGLE_HOST) + d_ip, d_res_err = await _resolve_ipv4(DOUYIN_HOST) + + async def _empty_geo() -> dict[str, Any]: + return {"country": None, "country_code": None, "error": "no ip"} + + g_country_task = _country_for_ip(client, g_ip) if g_ip else _empty_geo() + d_country_task = _country_for_ip(client, d_ip) if d_ip else _empty_geo() + + ( + (g_ok, g_ms, g_err), + (d_ok, d_ms, d_err), + g_geo, + d_geo, + ) = await asyncio.gather( + _latency_get(client, GOOGLE_CHECK_URL), + _latency_get(client, DOUYIN_CHECK_URL), + g_country_task, + d_country_task, + ) + + if not isinstance(g_geo, dict): + g_geo = {"country": None, "country_code": None, "error": None} + if not isinstance(d_geo, dict): + d_geo = {"country": None, "country_code": None, "error": None} + + g_code = g_geo.get("country_code") + d_code = d_geo.get("country_code") + + routes_different: Optional[bool] = None + routes_note = "" + if g_code and d_code: + routes_different = _is_probably_china_country(g_code) != _is_probably_china_country(d_code) + routes_note = "依据解析 IP 的国家码:海外站点与国内站点出口不一致" if routes_different else ( + "依据解析 IP:两国别相同或未区分境内外(若您期望分流,请检查代理/路由)" + ) + else: + routes_note = "未能查询到双方 IP 国家,无法严格判断线路是否分流" + if g_ip and d_ip and g_ip != d_ip: + routes_note += ";两站点解析 IP 不同,通常走不同目标。" + if g_res_err: + routes_note += f" Google DNS: {g_res_err}" + if d_res_err: + routes_note += f" 抖音 DNS: {d_res_err}" + + # 测速(并行) + od_mbps, od_err, od_bytes = await _measure_download_mbps(client, GOOGLE_DOWNLOAD_URL) + dd_mbps, dd_err, dd_bytes = await _measure_download_mbps(client, DOUYIN_DOWNLOAD_URL) + + ou_mbps, ou_err = await _measure_upload_mbps(client, OVERSEAS_UPLOAD_URL) + du_mbps, du_err, du_url = await _try_domestic_upload(client) + + return { + "google": { + "host": GOOGLE_HOST, + "reachable": g_ok, + "latency_ms": round(g_ms, 1), + "error": g_err or g_res_err, + "resolved_ip": g_ip, + "country": g_geo.get("country"), + "country_code": g_code, + "geo_error": g_geo.get("error"), + }, + "douyin": { + "host": DOUYIN_HOST, + "reachable": d_ok, + "latency_ms": round(d_ms, 1), + "error": d_err or d_res_err, + "resolved_ip": d_ip, + "country": d_geo.get("country"), + "country_code": d_code, + "geo_error": d_geo.get("error"), + }, + "routes": { + "different": routes_different, + "note": routes_note.strip(), + }, + "speed": { + "overseas": { + "label": "海外(Google / gstatic)", + "download_mbps": od_mbps, + "download_error": od_err, + "download_bytes_sampled": od_bytes, + "upload_mbps": ou_mbps, + "upload_error": ou_err, + "upload_target": OVERSEAS_UPLOAD_URL, + }, + "domestic": { + "label": "国内(字节 CDN / 门户)", + "download_mbps": dd_mbps, + "download_error": dd_err, + "download_bytes_sampled": dd_bytes, + "upload_mbps": du_mbps, + "upload_error": du_err, + "upload_target": du_url, + }, + }, + "meta": { + "at": time.time(), + "download_cap_bytes": DOWNLOAD_CAP_BYTES, + "upload_bytes": UPLOAD_BYTES, + }, + } diff --git a/web2_proxy_config.py b/web2_proxy_config.py new file mode 100644 index 0000000..3b59e03 --- /dev/null +++ b/web2_proxy_config.py @@ -0,0 +1,96 @@ +""" +仅给「录制子进程」使用的代理配置(FFmpeg / youtube2 等),不修改 Windows 系统代理。 +用户在本机运行 Clash Verge 等时,通常填 http://127.0.0.1:7890 与 socks5://127.0.0.1:7890。 +""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from configparser import ConfigParser + +BASE_DIR = Path(__file__).resolve().parent +PROXY_INI = BASE_DIR / "config" / "ffmpeg_proxy.ini" + + +def _default_ini_text() -> str: + return ( + "[proxy]\n" + "# 开启后仅作用于本软件拉起的录制/推流进程,不会改系统代理\n" + "enabled = no\n" + "# 与 Clash Verge「混合端口」一致即可(示例)\n" + "http = http://127.0.0.1:7890\n" + "socks = socks5://127.0.0.1:7890\n" + ) + + +def ensure_proxy_config_file() -> None: + PROXY_INI.parent.mkdir(parents=True, exist_ok=True) + if not PROXY_INI.exists(): + PROXY_INI.write_text(_default_ini_text(), encoding="utf-8") + + +def read_proxy_config() -> dict[str, Any]: + ensure_proxy_config_file() + cp = ConfigParser() + cp.read(PROXY_INI, encoding="utf-8") + if not cp.has_section("proxy"): + cp.add_section("proxy") + enabled = cp.get("proxy", "enabled", fallback="no").strip().lower() in ( + "1", + "true", + "yes", + "on", + "是", + "开", + ) + http = (cp.get("proxy", "http", fallback="") or "").strip() + socks = (cp.get("proxy", "socks", fallback="") or "").strip() + return {"enabled": enabled, "http": http, "socks": socks} + + +def write_proxy_config(data: dict[str, Any]) -> None: + ensure_proxy_config_file() + cp = ConfigParser() + cp.read(PROXY_INI, encoding="utf-8") + if not cp.has_section("proxy"): + cp.add_section("proxy") + cp.set("proxy", "enabled", "yes" if data.get("enabled") else "no") + cp.set("proxy", "http", str(data.get("http") or "").strip()) + cp.set("proxy", "socks", str(data.get("socks") or "").strip()) + with open(PROXY_INI, "w", encoding="utf-8") as f: + cp.write(f) + + +def subprocess_env_overlay() -> dict[str, str]: + """ + 合并进子进程 environ 的键值(不含整个 os.environ)。 + youtube2 / tiktok2 会读 STREAM_HTTP_PROXY / STREAM_SOCKS_PROXY。 + """ + cfg = read_proxy_config() + out: dict[str, str] = {} + if not cfg["enabled"]: + return out + http = cfg["http"] + socks = cfg["socks"] + if http: + out["STREAM_HTTP_PROXY"] = http + out["HTTP_PROXY"] = http + out["HTTPS_PROXY"] = http + out["http_proxy"] = http + out["https_proxy"] = http + if socks: + out["STREAM_SOCKS_PROXY"] = socks + out["ALL_PROXY"] = socks + out["all_proxy"] = socks + return out + + +def merge_child_env(base: dict[str, str] | None = None) -> dict[str, str]: + """供 asyncio subprocess / Popen 使用(完整继承系统环境后再叠加代理)。""" + env = dict(os.environ) + if base: + env.update(base) + env.update(subprocess_env_overlay()) + return env diff --git a/web2_supervisor.py b/web2_supervisor.py index 7892f85..7feff3a 100644 --- a/web2_supervisor.py +++ b/web2_supervisor.py @@ -15,6 +15,8 @@ from typing import Any, Callable, Optional import psutil +from web2_proxy_config import merge_child_env + def use_builtin() -> bool: return os.environ.get("WEB2_SUPERVISOR", "builtin").strip().lower() != "pm2" @@ -114,6 +116,7 @@ class BuiltinSupervisor: stdout=out_f, stderr=err_f, creationflags=0, + env=merge_child_env(), ) except Exception as e: out_f.close() diff --git a/web2_system_metrics.py b/web2_system_metrics.py new file mode 100644 index 0000000..ad815c2 --- /dev/null +++ b/web2_system_metrics.py @@ -0,0 +1,129 @@ +""" +系统与网卡指标(供仪表盘轮询)。下行/上行为全机网卡合计速率(估算)。 +""" +from __future__ import annotations + +import time +from typing import Any, Optional + +import psutil + +_last_net_time: float = 0.0 +_last_net: Optional[dict[str, tuple[int, int]]] = None # name -> (sent, recv) + + +def _net_rates_mbps() -> tuple[Optional[float], Optional[float], dict[str, Any]]: + """返回 (上行 Mbps, 下行 Mbps, 原始片段)。""" + global _last_net_time, _last_net + now = time.monotonic() + per = psutil.net_io_counters(pernic=True) + cur: dict[str, tuple[int, int]] = {k: (v.bytes_sent, v.bytes_recv) for k, v in per.items()} + detail: dict[str, Any] = {} + up_mbps = down_mbps = None + if _last_net is not None and _last_net_time > 0: + dt = now - _last_net_time + if dt > 0.05: + ds = dr = 0 + for name, (sent, recv) in cur.items(): + if name in _last_net: + ps0, pr0 = _last_net[name] + ds += max(0, sent - ps0) + dr += max(0, recv - pr0) + up_mbps = round((ds * 8) / dt / 1_000_000, 3) + down_mbps = round((dr * 8) / dt / 1_000_000, 3) + detail["delta_seconds"] = round(dt, 3) + _last_net = cur + _last_net_time = now + for name, (sent, recv) in cur.items(): + detail.setdefault("interfaces", {})[name] = { + "bytes_sent": sent, + "bytes_recv": recv, + } + return up_mbps, down_mbps, detail + + +def snapshot() -> dict[str, Any]: + try: + psutil.cpu_percent(interval=None) + cpu = psutil.cpu_percent(interval=0.15) + except Exception: + cpu = None + try: + vm = psutil.virtual_memory() + mem = { + "percent": vm.percent, + "used": vm.used, + "total": vm.total, + "available": vm.available, + } + except Exception: + mem = {} + try: + sm = psutil.swap_memory() + swap = {"percent": sm.percent, "used": sm.used, "total": sm.total} + except Exception: + swap = {} + + disks = [] + try: + for part in psutil.disk_partitions(all=False): + try: + usage = psutil.disk_usage(part.mountpoint) + disks.append( + { + "device": part.device, + "mountpoint": part.mountpoint, + "fstype": part.fstype, + "percent": usage.percent, + "free": usage.free, + "total": usage.total, + } + ) + except (PermissionError, OSError): + continue + except Exception: + pass + + up_mbps, down_mbps, net_detail = _net_rates_mbps() + + boot = None + try: + boot = int(psutil.boot_time()) + except Exception: + pass + + ffmpeg_procs = [] + try: + for p in psutil.process_iter(["pid", "name", "memory_info"]): + try: + name = (p.info.get("name") or "").lower() + if "ffmpeg" not in name: + continue + mi = p.info.get("memory_info") + ffmpeg_procs.append( + { + "pid": p.info["pid"], + "name": p.info.get("name"), + "rss": mi.rss if mi else 0, + } + ) + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + except Exception: + pass + + return { + "ts": time.time(), + "cpu_percent": cpu, + "cpu_count_logical": psutil.cpu_count(logical=True), + "memory": mem, + "swap": swap, + "disks": disks, + "network": { + "up_mbps": up_mbps, + "down_mbps": down_mbps, + "detail": net_detail, + }, + "boot_time_unix": boot, + "ffmpeg_processes": ffmpeg_procs[:24], + } diff --git a/youtube2.py b/youtube2.py index 1b894ce..9f39aeb 100644 --- a/youtube2.py +++ b/youtube2.py @@ -1298,9 +1298,12 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: ffmpeg_command.insert(11, "-headers") ffmpeg_command.insert(12, headers) - if proxy_address: + _ff_proxy = (proxy_address or "").strip() or ( + os.environ.get("STREAM_HTTP_PROXY") or "" + ).strip() or None + if _ff_proxy: ffmpeg_command.insert(1, "-http_proxy") - ffmpeg_command.insert(2, proxy_address) + ffmpeg_command.insert(2, _ff_proxy) recording.add(record_name) start_record_time = datetime.datetime.now()